Преглед на файлове

feat: system package manager integration, dependency groups, repository tooling, .usmignore, data packages, container deploy and verbose script output

clanker преди 1 седмица
родител
ревизия
d538a8d713

+ 167 - 1
README.md

@@ -60,6 +60,7 @@ USM supports several flags that modify the build and installation behavior:
 - `"buildInSourceTree"`: Tell USM to not create a separate build directory, and compile the package "in place".
 - `"setManifestPropertyEnvs"`: Sets environment variables based on manifest properties (TODO: document specifics).
 - `"simpleBuildEnvironment"`: Copy the full source tree to the build directory before running the build exec, and execute all build-related scripts (build, install, rebuild, test, postInstall) from the build directory instead of the source directory. This provides a clean, isolated build environment.
+- `"dataPackage"`: Marks the package as a data package; see [Data packages](#data-packages) below.
 
 ## Resource types
 
@@ -128,6 +129,61 @@ Last line consists of the signatures for the repository
 
 Complete package is simply a tar.xz file containing a MANIFEST.usm file and all the requisite files that would be acquired if the acquire script was run. That is to say, a USMC file could be created by simply running `usm manifest acquire` followed by `tar -cJf ../package.usmc .`.
 
+## .usmignore
+
+`usm manifest package` consults an optional `.usmignore` file at the root of the package (next to `MANIFEST.usm`) to prune files and directories from the produced archive.
+
+Semantics:
+
+- Blank lines and lines starting with `#` are skipped.
+- A pattern containing `/` matches the FULL path relative to the package root; a pattern without `/` matches any path suffix (equivalently, the basename at any depth).
+- A trailing `/` marks a directory-only pattern: it matches the directory itself and everything beneath it.
+- `*` and `?` are wildcards and never match `/`.
+- There is no negation: `!` is a literal pattern character and cannot re-include an ignored path.
+- The root `.usmignore` and `MANIFEST.usm` files are always packaged; they can never be ignored.
+- The `.git` directory is always ignored. When no `.usmignore` file exists, `.git` is the only default ignore.
+
+```
+# .usmignore
+
+# Build trees at any depth (directory-only: the directory and all beneath it)
+builddir/
+
+# Database artifacts by basename at any depth
+*.sqlite
+
+# A specific generated tree, anchored to the package root
+docs/generated/
+
+# Wildcards; '*' and '?' never cross a path separator
+src/secret.*
+notes/?draft.md
+```
+
+## Data packages
+
+A manifest with the `"dataPackage"` flag ships static files: fonts, icons, shared data sets and the like. Data packages define no build or install machinery:
+
+- Validation rejects a `dataPackage` manifest that defines any lifecycle executable (`build`, `install`, `rebuild`, `test`, `remove`, `postInstall`); only `acquire` is permitted.
+- `usm manifest build` is a no-op: no build directory and no build artifacts are created.
+- `usm manifest install` and `usm manifest validate` copy (or check) each `provides` entry directly from the source tree — path bases are treated as `source:` regardless of how they are written — into the destination's suggested path. Staging a data install works the same as any other package, e.g. `USM_DESTDIR=/tmp/stage usm manifest install`.
+- `usm manifest acquire` and resource bookkeeping on install/remove are unchanged.
+
+```json
+{
+  "name": "example-fonts",
+  "version": "1.0.0",
+  "summary": "The Example font family",
+  "licences": [],
+  "flags": ["dataPackage"],
+  "provides": {
+    "res:fonts/Example-Regular.ttf": "source:fonts/Example-Regular.ttf",
+    "res:fonts/Example-Bold.ttf": "source:fonts/Example-Bold.ttf"
+  },
+  "depends": { "runtime": [], "build": [], "manage": ["bin:bash"] }
+}
+```
+
 
 
 
@@ -211,10 +267,23 @@ JSONL transaction journal format
 - `usm manifest remove`: Remove the package from the system
 - `usm manifest acquire`: Run the acquire script to download sources
 - `usm manifest package`: Create a package archive
+- `usm manifest deploy [--exec CMD] [--base IMAGE] [--repository FILE]... [--no-build] [--installer-url URL]`: Build a container image from the manifest directory; see [Container deployment](#container-deployment)
 - `usm manifest autoprovides [--replace] [--debug] [build path]`: Scan installation and generate provides section
 - `usm manifest test [build path]`: Run package tests
 - `usm manifest validate [build path]`: Validate package by building and checking resources
 
+### Repository Commands
+
+- `usm repository init <name>`: Create the canonical repository layout in the current directory: `<name>.usmr`, `keys/` (signing keys, private key mode 0600 and gitignored via `.gitignore`), and an empty `public/` packages directory
+- `usm repository add <pkg.usmc>`: Validate the package (manifest + checksum), copy it into `public/`, then rebuild and re-sign `PACKAGES.usml`; a package with the same name and version is rejected unless identical
+- `usm repository remove <name|filename>`: Delete the matching `.usmc` from `public/` (exact filename, or `<name>-<version>` prefix; an ambiguous name lists the candidates and asks for the filename), then rebuild and re-sign
+- `usm repository list [repo.usmr]`: Print name, version and summary of every package in the signed listing (from the repository root, or fetched from the given `.usmr`'s URI)
+- `usm repository verify <repo.usmr>`: Check the listing signature, then deep-verify every entry's sha512 against the `.usmc` fetched from the repository URI; exits non-zero on any failure
+- `usm repository publish <path>`: Copy the `.usmr` and `PACKAGES.usml` to `<path>` (always overwriting) and each `public/*.usmc` only when the destination copy is missing or older by mtime
+- `usm repository new <name>` / `usm repository build-list`: Legacy commands using loose `public-key`/`private-key` files in the working directory
+
+All commands except `init`, `new` and `verify` locate the repository root by searching the current directory and its ancestors for a directory containing a `*.usmr` file, a `keys/` directory and a `public/` directory. Signing keys load from `keys/public-key` and `keys/private-key` in the repository root, falling back to the legacy loose `public-key` and `private-key` files.
+
 ### Validation Command
 
 The `validate` command performs comprehensive validation of USM packages:
@@ -234,4 +303,101 @@ The validation process includes:
 
 Exit codes indicate specific validation failures, making it suitable for CI/CD environments.
 
-For detailed information, see `slopdocs/utility.usm.manifest.validate.md`.
+For detailed information, see `slopdocs/utility.usm.manifest.validate.md`.
+
+## System package manager integration
+
+USM can delegate missing resources to the system package manager (DNF and friends) before falling back to USM repositories. The integration is configured in `usm.config` and talks to a helper executable through two stable contracts; a reference DNF helper ships at `spm/dnf/usm-spm-dnf` (provided as `libexec:usm-spm-dnf`).
+
+### Configuration
+
+```json
+"system_package_manager": { "query": ["usm-spm-dnf","query"], "install": ["usm-spm-dnf","install"] }
+```
+
+Both keys or neither (validation error otherwise); argv arrays, no shell.
+
+### Query contract
+
+`<query> <usm-ref>…` → STDOUT single JSON object, no side effects:
+
+```json
+{ "not-found": ["pc:foo.pc"],
+  "packages": [ { "name": "vala", "resources": ["bin:valac","vapi:gtk-4.0.vapi"], "dependency-count": 5, "installed-dependency-count": 3 } ] }
+```
+
+`dependency-count` = packages the manager would install for this package alone incl. itself. Exit 0 with non-empty `not-found`; non-zero only on query failure.
+
+### Install contract
+
+`<install> <native-name>…` → STDOUT JSONL events:
+
+`{"type":"begin","total":N}` / `{"type":"package","name":…,"current":n,"total":N,"progress":f}` / `{"type":"package-complete","name":…}` / `{"type":"complete","status":"ok","installed":N}` / terminal `{"type":"error","message":…}` before non-zero exit.
+
+### Exit codes
+
+| Code | Meaning |
+|---|---|
+| 0 | Query completed (refs may still be listed in `not-found`), or install transaction finished |
+| 1 | Unexpected helper failure, after emitting a terminal `error` event |
+| 2 | Usage error (invalid arguments) |
+| 3 | Query failure, or install marking/resolution failure |
+| 4 | Package download failure |
+| 5 | rpm transaction failure |
+
+The query subcommand never modifies system state (at most it refreshes package-metadata caches). The install subcommand requires root, follows the system's dnf GPG settings, and never prompts interactively: a missing GPG key or unresolved dependency fails instead.
+
+## Container deployment
+
+`usm manifest deploy` builds a single-stage container image for the manifest in the current directory: the package is created with the `usm manifest package` logic, a deploy context is generated into `.usm-deploy/`, the image is built with podman (build happens INSIDE the container — the image is not cross-compiled on the host), and the result is saved as an xz-compressed archive. `usm deploy <path>` is the package-then-deploy convenience taking either a manifest directory (delegates directly) or a `.usmc` archive (extracted to a temporary directory first; the finished artifact is moved next to the invocation directory).
+
+```bash
+usm manifest deploy                                  # defaults below
+usm manifest deploy --exec "my-app 8080"             # entrypoint words
+usm manifest deploy --base quay.io/fedora/fedora:43  # base image override
+usm manifest deploy --repository web-stack.usmr      # repeatable; replaces the set
+usm manifest deploy --no-build                       # stop after generating the context
+usm deploy ./example-app --repository web-stack.usmr # directory form
+usm deploy example-app-1.0.0.usmc                    # package form
+```
+
+| Flag | Meaning |
+|---|---|
+| `--exec CMD` | Container command, split on whitespace into the exec-form ENTRYPOINT. Default: the package's single `bin:` provide as `/usr/bin/<name>`; zero or several `bin:` provides without `--exec` is an error reported before anything is generated |
+| `--base IMAGE` | Base image (default `registry.fedoraproject.org/fedora:43`, declared as `DEPLOY_DEFAULT_BASE_IMAGE` in `src/cli/Deploy.vala`) |
+| `--repository FILE` | Use exactly the given `.usmr` files (repeatable) instead of the machine-configured repositories (`$USM_CONFIGDIR/repos.d`, by default `/etc/usm/repos.d`) |
+| `--no-build` | Stop after generating the context |
+| `--installer-url URL` | Override the canonical USM installer source; the `file://` form is the sanctioned local-testing path (see below) |
+
+The generated context contains a `Containerfile`, a minimal `usm.config` (managed state under `/var/usm`, SPM wired to the DNF helper the installer ships at `/opt/usm/bin/usm-spm-dnf`), `repos/` with repository descriptors, `repo-trees/` for `file://` repositories, and `package/package.usmc`. The image pre-seeds the package into the USM cache and runs `usm install` in-container: the SPM (DNF) provides platform and toolchain resources, USM repositories provide the rest, and failures fail the build loudly. Only repository PUBLIC keys ever enter a context or image; application secrets such as `web-config.json` are never packaged and belong at runtime, e.g. `podman run --rm -v ./web-config.json:/etc/my-app/web-config.json:ro <image>`.
+
+Add the context and artifacts to the project's `.usmignore` so they never get packaged:
+
+```
+.usm-deploy/
+*.image.tar*
+```
+
+### Installer URL
+
+The Containerfile installs USM from the URL held in the `Usm.Installer.CANONICAL_URL` constant in `src/cli/Deploy.vala` (default of the `USM_INSTALLER_URL` build ARG). The current value is a placeholder on a reserved documentation domain that fails the build loudly until replaced with the real hosted installer. The installer is downloaded to a file and run as `sh <file> -y` rather than `curl | sh`, because it extracts its payload archive relative to `$0`.
+
+For local testing, point `--installer-url` at a `file://` URL; the referenced script is carried into the context automatically:
+
+```bash
+usm manifest deploy --installer-url file:///path/to/install-usm.sh
+```
+
+### file:// repository provisioning
+
+A `.usmr` whose URI is `file://` references a tree on the build host that the container cannot see. Deploy copies that tree into the context (`repo-trees/<name>/`) and rewrites the copied descriptor's URI to the in-image location `file:///usr/share/usm-repos/<name>`, so in-container `usm install` resolves it without the host. Key material named `keys/` or `private-key*` is never copied. Remote (`https://` and friends) descriptors are copied verbatim and fetched at image build time.
+
+### Building, loading and running
+
+```bash
+usm manifest deploy                    # podman build -t <name>:<version> + save + xz
+podman load -i <name>-<version>.image.tar.xz
+podman run --rm <name>:<version>
+```
+
+The artifact is `<name>-<version>.image.tar.xz` next to the deploy context; `podman load -i` accepts the xz-compressed archive directly. Tag characters outside `[A-Za-z0-9_.-]` (usm release suffixes like `+`, for instance) are mapped to dashes in the tag; the artifact filename keeps the unsanitised version.

+ 11 - 0
installer/compile.sh

@@ -57,6 +57,17 @@ cp -r "$USM_SOURCE_DIR"/* "$SOURCES_DIR/usm/" 2>/dev/null || true
 cp "$USM_SOURCE_DIR/../MANIFEST.usm" "$SOURCES_DIR/usm/" 2>/dev/null || true
 cp "$USM_SOURCE_DIR/../usm.config" "$SOURCES_DIR/usm/" 2>/dev/null || true
 
+# The DNF SPM helper lives in spm/ at the project root, outside src/, but
+# src/meson.build configures it via '../spm/dnf/usm-spm-dnf' — the payload
+# must therefore carry spm/ as a SIBLING of usm/ so the relative input
+# resolves inside the extracted tree
+echo "Copying USM SPM helper..."
+mkdir -p "$SOURCES_DIR/spm"
+cp -r "$USM_SOURCE_DIR/../spm"/* "$SOURCES_DIR/spm/" 2>/dev/null || {
+    echo "Error: Could not copy the spm/ directory from next to $USM_SOURCE_DIR"
+    exit 1
+}
+
 # Step 2: Create the combined script header
 echo "Building combined script..."
 HEADER_FILE="$WORK_DIR/header.sh"

+ 452 - 0
spm/dnf/usm-spm-dnf

@@ -0,0 +1,452 @@
+#!/usr/bin/env python3
+"""USM system-package-manager helper for DNF-based systems.
+
+Implements the USM SPM contract (see usm/README.md, "System package manager
+integration") for Fedora-style systems:
+
+    usm-spm-dnf query <usm-ref>...      -> contract JSON on STDOUT
+    usm-spm-dnf install [--test] <name>... -> contract JSONL events on STDOUT
+
+API choice: Fedora 43 ships dnf5 as the CLI, but this machine exposes the
+DNF4 Python bindings (`python3-dnf`, `import dnf` succeeds) while the dnf5
+bindings (`python3-libdnf5`, `import libdnf5`) are not installed. This helper
+is therefore implemented against the DNF4 Python API, which resolves against
+the same repositories and rpmdb as dnf5. If python3-libdnf5 becomes the only
+option, this file is the one to port.
+
+The query subcommand never modifies system state (at most it refreshes
+package-metadata caches). The install subcommand must run as root; --test
+resolves and downloads but runs the rpm transaction in test mode only.
+"""
+
+import argparse
+import json
+import os
+import sys
+
+import dnf
+import dnf.callback
+import dnf.transaction
+import dnf.yum.rpmtrans
+import hawkey
+
+BASE_ARCH = hawkey.detect_arch()
+
+INSTALL_ACTIONS = frozenset([
+    dnf.transaction.PKG_INSTALL,
+    dnf.transaction.PKG_UPGRADE,
+    dnf.transaction.PKG_DOWNGRADE,
+    dnf.transaction.PKG_REINSTALL,
+])
+
+EXIT_OK = 0
+EXIT_FAILURE = 1
+EXIT_RESOLVE = 3
+EXIT_DOWNLOAD = 4
+EXIT_TRANSACTION = 5
+
+
+class HelperError(Exception):
+    """Fatal helper failure; message is emitted as a contract error event."""
+
+    def __init__(self, message, exit_code=EXIT_FAILURE):
+        super(HelperError, self).__init__(message)
+        self.exit_code = exit_code
+
+
+def emit_event(event):
+    """Write one contract JSONL event to STDOUT and flush it."""
+    sys.stdout.write(json.dumps(event) + "\n")
+    sys.stdout.flush()
+
+
+def fail(message, exit_code=EXIT_FAILURE):
+    """Emit the terminal error event and exit non-zero."""
+    emit_event({"type": "error", "message": message})
+    sys.exit(exit_code)
+
+
+def fail_plain(message, exit_code=EXIT_FAILURE):
+    """Report a query failure on STDERR only, keeping STDOUT contract-clean."""
+    sys.stderr.write("usm-spm-dnf: %s\n" % message)
+    sys.exit(exit_code)
+
+
+def make_base(load_system_repo, test=False):
+    """Build a filled dnf.Base, using a user cachedir when unprivileged.
+
+    Filelists metadata is enabled in code before the sack load: the DNF4
+    Python API downloads only primary metadata by default (bin:/lib:
+    provides queries) and silently ignores optional_metadata_types set via
+    dnf.conf, so pc:/vapi:/file-path queries would return not-found on any
+    machine whose cache lacks filelists (fresh containers, for instance).
+    """
+    base = dnf.Base()
+    if "filelists" not in base.conf.optional_metadata_types:
+        base.conf.optional_metadata_types.append("filelists")
+    if os.geteuid() != 0:
+        base.conf.cachedir = os.path.expanduser("~/.cache/dnf")
+        base.conf.history_record = False
+    if test:
+        base.conf.tsflags.append("test")
+    base.read_all_repos()
+    base.fill_sack(
+        load_system_repo=load_system_repo,
+        load_available_repos=True,
+    )
+    return base
+
+
+def make_closure_base():
+    """A repo-only sack restricted to the running arch for goal closures.
+
+    dependency-count models "a solo install on this machine", so packages
+    for foreign architectures are excluded the way a real single-arch
+    install would never pull them.
+    """
+    base = make_base(load_system_repo=False)
+    foreign = base.sack.query().filter(
+        arch=[a for a in ("i686", "i386", "armv7hl", "ppc64le", "s390x")
+              if a != BASE_ARCH])
+    base.sack.add_excludes(foreign)
+    return base
+
+
+def translate_ref(ref):
+    """Map a USM resource ref to (filter_kind, value) sack query descriptors.
+
+    Returns None for refs whose type has no file/provides translation.
+    """
+    prefix, sep, resource = ref.partition(":")
+    if not sep or not resource:
+        return None
+    file_roots = {
+        "bin": "/usr/bin",
+        "sbin": "/usr/sbin",
+        "libexec": "/usr/libexec",
+        "gir": "/usr/share/gir-1.0",
+        "typelib": "/usr/lib64/girepository-1.0",
+        "res": "/usr/share",
+        "cfg": "/etc",
+        "man": "/usr/share/man",
+        "info": "/usr/share/info",
+        "locale": "/usr/share/locale",
+    }
+    if prefix in file_roots:
+        return [("file", file_roots[prefix] + "/" + resource)]
+    if prefix == "vapi":
+        return [
+            ("file", "/usr/share/vala/vapi/" + resource),
+            ("file__glob", "/usr/share/vala-*/vapi/" + resource),
+        ]
+    if prefix == "lib":
+        return [
+            ("provides", resource),
+            ("file", "/usr/lib64/" + resource),
+        ]
+    if prefix == "pc":
+        return [
+            ("file", "/usr/share/pkgconfig/" + resource),
+            ("file", "/usr/lib64/pkgconfig/" + resource),
+        ]
+    if prefix == "inc":
+        return [("file__glob", "/usr/include/" + resource + "*")]
+    if prefix == "rootpath":
+        return [("file", "/" + resource.lstrip("/"))]
+    if prefix == "tag":
+        return [("file", "/usr/share/usm-tags/" + resource.replace(".", "/") + ".tag")]
+    return None
+
+
+def arch_rank(package):
+    """Sort key preferring the running arch, then noarch, then others."""
+    if package.arch == BASE_ARCH:
+        return (0, package.arch)
+    if package.arch == "noarch":
+        return (1, package.arch)
+    return (2, package.arch)
+
+
+def find_candidates(union, descriptors):
+    """Best package per name matching any descriptor, latest version per arch."""
+    by_name = {}
+    for kind, value in descriptors:
+        matches = union.filter(**{kind: value}).filter(latest_per_arch=True)
+        for package in matches:
+            current = by_name.get(package.name)
+            if current is None or arch_rank(package) < arch_rank(current):
+                by_name[package.name] = package
+    return by_name
+
+
+def closure_counts(closure_base, installed_keys, package, closure_cache):
+    """(dependency-count, installed-dependency-count) for a solo install.
+
+    The full closure (including the package itself) comes from a goal run
+    against a sack with no @System, so every dependency resolves as an
+    install; the overlap with @System is then counted by (name, arch).
+    """
+    cache_key = (package.name, package.arch)
+    if cache_key in closure_cache:
+        return closure_cache[cache_key]
+
+    target_query = closure_base.sack.query().filter(
+        name=package.name,
+        arch=package.arch,
+        epoch=package.epoch,
+        version=package.version,
+        release=package.release,
+    )
+    target = next(iter(target_query), None)
+    if target is None:
+        target = next(iter(
+            closure_base.sack.query().filter(
+                name=package.name, arch=package.arch)
+            .filter(latest_per_arch=True)), None)
+
+    counts = (1, 1 if cache_key in installed_keys else 0)
+    if target is not None:
+        goal = dnf.goal.Goal(closure_base.sack)
+        goal.install(target)
+        if goal.run():
+            transaction = (goal.list_installs() + goal.list_upgrades()
+                           + goal.list_downgrades() + goal.list_reinstalls())
+            counts = (
+                len(transaction),
+                sum(1 for member in transaction
+                    if (member.name, member.arch) in installed_keys),
+            )
+        else:
+            sys.stderr.write(
+                "usm-spm-dnf: could not resolve solo install of %s: %s\n"
+                % (package, goal.problem_string() if hasattr(goal, "problem_string") else "unresolved dependency"))
+    else:
+        sys.stderr.write(
+            "usm-spm-dnf: %s not found in enabled repositories, "
+            "estimating dependency counts as installed-only\n" % package)
+    closure_cache[cache_key] = counts
+    return counts
+
+
+def cmd_query(args):
+    try:
+        return run_query(args)
+    except SystemExit:
+        raise
+    except Exception as e:
+        fail_plain("query failed: %s" % e, EXIT_RESOLVE)
+
+
+def run_query(args):
+    base = make_base(load_system_repo=True)
+    installed_keys = set(
+        (package.name, package.arch)
+        for package in base.sack.query().installed())
+    union = base.sack.query().available().union(base.sack.query().installed())
+
+    refs = list(dict.fromkeys(args.refs))
+    not_found = []
+    candidates = {}
+    for ref in refs:
+        descriptors = translate_ref(ref)
+        if descriptors is None:
+            sys.stderr.write(
+                "usm-spm-dnf: resource type of \"%s\" has no "
+                "system-package-manager translation\n" % ref)
+            not_found.append(ref)
+            continue
+        matches = find_candidates(union, descriptors)
+        if not matches:
+            not_found.append(ref)
+            continue
+        for name, package in matches.items():
+            entry = candidates.get(name)
+            if entry is None:
+                entry = {"package": package, "resources": []}
+                candidates[name] = entry
+            entry["resources"].append(ref)
+
+    closure_base = None
+    closure_cache = {}
+    packages = []
+    for name in sorted(candidates):
+        entry = candidates[name]
+        if closure_base is None:
+            closure_base = make_closure_base()
+        dependency_count, installed_dependency_count = closure_counts(
+            closure_base, installed_keys, entry["package"], closure_cache)
+        packages.append({
+            "name": name,
+            "resources": entry["resources"],
+            "dependency-count": dependency_count,
+            "installed-dependency-count": installed_dependency_count,
+        })
+
+    sys.stdout.write(json.dumps({
+        "not-found": not_found,
+        "packages": packages,
+    }) + "\n")
+    sys.stdout.flush()
+    return EXIT_OK
+
+
+class ContractDownloadProgress(dnf.callback.DownloadProgress):
+    """Maps dnf package downloads to contract `package` events."""
+
+    def __init__(self):
+        self.total = 0
+        self.index = 0
+        self.last = None
+
+    def start(self, total_files, total_size, total_drpms=0):
+        self.total = total_files
+        self.index = 0
+        self.last = None
+
+    def progress(self, payload, done):
+        size = payload.pkg.downloadsize or 0
+        fraction = (done / size) if size else 0.0
+        event = (
+            "package", payload.pkg.name, self.index + 1, self.total,
+            round(min(fraction, 1.0), 4))
+        if event == self.last:
+            return
+        self.last = event
+        emit_event({
+            "type": "package",
+            "name": payload.pkg.name,
+            "current": self.index + 1,
+            "total": self.total,
+            "progress": min(fraction, 1.0),
+        })
+
+    def end(self, payload, status, msg):
+        if status == dnf.callback.STATUS_FAILED:
+            raise HelperError(
+                "failed to download %s: %s" % (payload.pkg.name, msg or "unknown error"),
+                EXIT_DOWNLOAD)
+        self.index += 1
+
+    def message(self, msg):
+        sys.stderr.write("usm-spm-dnf: %s\n" % msg)
+
+
+class ContractTransactionDisplay(dnf.yum.rpmtrans.TransactionDisplay):
+    """Maps the rpm transaction to contract package/complete events."""
+
+    def __init__(self):
+        super(ContractTransactionDisplay, self).__init__()
+        self.installed = 0
+        self.last = None
+
+    def progress(self, package, action, ti_done, ti_total, ts_done, ts_total):
+        if package is None:
+            return
+        fraction = (float(ti_done) / float(ti_total)) if ti_total else 0.0
+        total = ts_total or 1
+        current = min(ts_done + 1, total) if ts_total else 1
+        event = ("package", package.name, current, total,
+                 round(min(fraction, 1.0), 4))
+        if event == self.last:
+            return
+        self.last = event
+        emit_event({
+            "type": "package",
+            "name": package.name,
+            "current": current,
+            "total": total,
+            "progress": min(fraction, 1.0),
+        })
+
+    def filelog(self, package, action):
+        if package is None or action not in INSTALL_ACTIONS:
+            return
+        self.installed += 1
+        emit_event({"type": "package-complete", "name": package.name})
+
+    def scriptout(self, msgs):
+        if msgs:
+            sys.stderr.write(msgs if msgs.endswith("\n") else msgs + "\n")
+
+    def error(self, message):
+        raise HelperError(
+            "transaction failed: %s" % (message or "unknown rpm error"),
+            EXIT_TRANSACTION)
+
+
+def cmd_install(args):
+    base = make_base(load_system_repo=True, test=args.test)
+    for name in args.names:
+        try:
+            base.install(name)
+        except Exception as e:
+            fail("could not mark \"%s\" for install: %s" % (name, e),
+                 EXIT_RESOLVE)
+    try:
+        base.resolve()
+    except Exception as e:
+        fail("dependency resolution failed: %s" % e, EXIT_RESOLVE)
+
+    install_set = base.transaction.install_set
+    emit_event({"type": "begin", "total": len(install_set)})
+
+    progress = ContractDownloadProgress()
+    try:
+        base.download_packages(install_set, progress=progress)
+    except HelperError:
+        raise
+    except Exception as e:
+        fail("failed to download packages: %s" % e, EXIT_DOWNLOAD)
+
+    display = ContractTransactionDisplay()
+    try:
+        base.do_transaction(display=display)
+    except HelperError:
+        raise
+    except Exception as e:
+        fail("transaction failed: %s" % e, EXIT_TRANSACTION)
+
+    emit_event({
+        "type": "complete",
+        "status": "ok",
+        "installed": display.installed,
+    })
+    return EXIT_OK
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        prog="usm-spm-dnf",
+        description="USM system-package-manager helper for DNF")
+    subparsers = parser.add_subparsers(dest="command", required=True)
+
+    query_parser = subparsers.add_parser(
+        "query", help="resolve USM resource refs to system packages")
+    query_parser.add_argument(
+        "refs", nargs="+", metavar="USM-REF",
+        help="resource ref, e.g. bin:valac or lib:libglib-2.0.so.0")
+    query_parser.set_defaults(handler=cmd_query)
+
+    install_parser = subparsers.add_parser(
+        "install", help="install system packages, streaming progress events")
+    install_parser.add_argument(
+        "--test", action="store_true",
+        help="resolve and download only; run the rpm transaction in test mode")
+    install_parser.add_argument(
+        "names", nargs="+", metavar="NAME",
+        help="native system package name")
+    install_parser.set_defaults(handler=cmd_install)
+
+    args = parser.parse_args()
+    try:
+        return args.handler(args)
+    except HelperError as e:
+        fail(str(e), e.exit_code)
+    except SystemExit:
+        raise
+    except Exception as e:
+        fail("unexpected failure: %s" % e, EXIT_FAILURE)
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 21 - 6
src/cli/Cli.vala

@@ -13,7 +13,15 @@ public static int main(string[] args) {
         var arguments = new Invercargill.DataStructures.Vector<string>();
         arguments.add_all(Invercargill.Wrap.array(args));
         var command = arguments.skip(1).where(a => !a.has_prefix("-")).first_or_default();
-    
+
+        // `--verbose` (anywhere) streams package management-script output to
+        // the terminal; USM_VERBOSE is inherited by nested invocations
+        if(arguments.contains("--verbose") || arguments.contains("-v")) {
+            Environment.set_variable("USM_VERBOSE", "1", true);
+            arguments.remove("--verbose");
+            arguments.remove("-v");
+        }
+
         var root_flag_pos = arguments.index_of("--root");
         if(root_flag_pos != null) {
             var root_path = arguments.get_or_default(root_flag_pos+1);
@@ -49,21 +57,28 @@ public static int main(string[] args) {
             arguments.remove(root_path);
         }
     
+        // The argument list with any `--verbose`/`--root` handling applied,
+        // handed to every subcommand (main's own argv cannot be reassigned)
+        var dispatch_args = arguments.to_array();
+
         if(command == "manifest") {
             arguments.remove("manifest");
             return manifest_main(arguments.to_array());
         }
         if(command == "info") {
-            return info(args);
+            return info(dispatch_args);
         }
         if(command == "repository") {
-            return repository_main(args);
+            return repository_main(dispatch_args);
         }
         if(command == "install") {
-            return install_main(args);
+            return install_main(dispatch_args);
+        }
+        if(command == "deploy") {
+            return deploy_main(dispatch_args);
         }
         if(command == "scaffold") {
-            return scaffold_main(args);
+            return scaffold_main(dispatch_args);
         }
         if(command == "genconfig") {
             return genconfig_main();
@@ -79,7 +94,7 @@ public static int main(string[] args) {
 }
 
 private void usage() {
-    printerr("USAGE:\n\tusm manifest\n\tusm info\n\tusm repository\n\tusm install\n\tusm scaffold\n\tusm genconfig\n");
+    printerr("USAGE:\n\tusm manifest\n\tusm info\n\tusm repository\n\tusm install\n\tusm deploy\n\tusm scaffold\n\tusm genconfig\n");
 }
 
 

+ 669 - 0
src/cli/Deploy.vala

@@ -0,0 +1,669 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Usm.Installer {
+
+    /**
+     * Canonical source of the self-contained USM installer script baked into
+     * deploy images by default (the compiled form of `installer/` in the usm
+     * source tree).
+     *
+     * TODO: this is a placeholder pointing at a reserved documentation domain
+     * that will never resolve. Replace it with the real hosted installer URL
+     * once one exists; until then override it per deploy with
+     * `--installer-url` (whose `file://` form is the sanctioned
+     * local-testing path).
+     */
+    public const string CANONICAL_URL = "https://usm.example.org/installer/install-usm.sh";
+}
+
+/** Default container base image, overridable with `--base`. */
+const string DEPLOY_DEFAULT_BASE_IMAGE = "registry.fedoraproject.org/fedora:43";
+
+/** Deploy context directory created inside the packaged project. */
+const string DEPLOY_CONTEXT_DIRECTORY = ".usm-deploy";
+
+/** Where the USM installer tree lives inside images (installer TARGET_DIR). */
+const string DEPLOY_USM_PREFIX = "/opt/usm";
+
+/** In-image location that file:// repository trees are rewritten to. */
+const string DEPLOY_REPO_TREES_PATH = "/usr/share/usm-repos";
+
+
+/**
+ * `usm deploy <package.usmc|directory> [flags…]` — the package-then-deploy
+ * convenience wrapper.
+ *
+ * A directory is deployed directly by running the manifest deploy verb inside
+ * it; a `.usmc` archive is extracted to a temporary directory first, and the
+ * finished image artifact is moved back next to the invocation directory.
+ * Flags pass through to {@link manifest_deploy} unchanged.
+ */
+public int deploy_main(string[] args) {
+    string? target = null;
+    var flags = new Vector<string>();
+    for(int i = 2; i < args.length; i++) {
+        var argument = args[i];
+        if(argument.has_prefix("--")) {
+            flags.add(argument);
+            if(deploy_option_takes_value(argument.split("=", 2)[0])
+                && !argument.contains("=")
+                && i + 1 < args.length) {
+                flags.add(args[++i]);
+            }
+            continue;
+        }
+        if(target != null) {
+            printerr(@"Unexpected argument \"$argument\"\n");
+            return deploy_usage();
+        }
+        target = argument;
+    }
+
+    if(target == null) {
+        return deploy_usage();
+    }
+
+    var invocation_dir = Environment.get_current_dir();
+    var target_path = Path.is_absolute(target) ? target : Path.build_filename(invocation_dir, target);
+
+    // --repository is given relative to the invocation directory, but the
+    // manifest deploy verb runs inside the target project: absolutise before
+    // forwarding so both interpretations agree
+    var forwarded = new string[] { "usm", "deploy" };
+    for(int i = 0; i < flags.length; i++) {
+        var flag = flags[i];
+        if(flag.has_prefix("--repository=") && !Path.is_absolute(flag.split("=", 2)[1])) {
+            forwarded += @"--repository=$(Path.build_filename(invocation_dir, flag.split("=", 2)[1]))";
+        }
+        else if(flag == "--repository" && i + 1 < flags.length && !Path.is_absolute(flags[i + 1])) {
+            forwarded += flag;
+            forwarded += Path.build_filename(invocation_dir, flags[++i]);
+        }
+        else {
+            forwarded += flag;
+        }
+    }
+
+    FileInfo file_info;
+    try {
+        if(!File.new_for_path(target_path).query_exists()) {
+            printerr(@"\"$target\" does not exist\n");
+            return 255;
+        }
+        file_info = File.new_for_path(target_path).query_info("*", FileQueryInfoFlags.NONE);
+    }
+    catch(Error e) {
+        printerr(@"Could not inspect \"$target\": $(e.message)\n");
+        return 255;
+    }
+
+    if(file_info.get_file_type() == FileType.DIRECTORY) {
+        if(!File.new_for_path(Path.build_filename(target_path, "MANIFEST.usm")).query_exists()) {
+            printerr(@"\"$target\" contains no MANIFEST.usm file\n");
+            return 255;
+        }
+        Environment.set_current_dir(target_path);
+        return manifest_main(forwarded);
+    }
+
+    if(!target.has_suffix(".usmc")) {
+        printerr("\"$target\" is neither a directory containing MANIFEST.usm nor a .usmc package\n");
+        return deploy_usage();
+    }
+
+    // Package-then-deploy: extract the archive, deploy from the extracted
+    // tree, then bring the artifact back to the invocation directory
+    Usm.Manifest archive_manifest;
+    var extract_dir = File.new_build_filename("/tmp", @"usm-deploy-$(Uuid.string_random())");
+    try {
+        archive_manifest = new Usm.Manifest.from_package(target_path);
+        extract_dir.make_directory();
+        Usm.Util.unarchive(target_path, extract_dir.get_path());
+    }
+    catch(Error e) {
+        printerr(@"Could not extract \"$target\": $(e.message)\n");
+        return 243;
+    }
+
+    Environment.set_current_dir(extract_dir.get_path());
+    var result = manifest_main(forwarded);
+    if(result == 0) {
+        var artifact = @"$(archive_manifest.name)-$(archive_manifest.version.to_string()).image.tar.xz";
+        try {
+            var produced = File.new_for_path(Path.build_filename(extract_dir.get_path(), artifact));
+            if(produced.query_exists()) {
+                produced.move(File.new_for_path(Path.build_filename(invocation_dir, artifact)), FileCopyFlags.OVERWRITE);
+                printerr(@"Moved image artifact to \"$invocation_dir/$artifact\"\n");
+            }
+        }
+        catch(Error e) {
+            printerr(@"The image was built, but its artifact could not be moved to \"$invocation_dir\": $(e.message)\n");
+            printerr(@"It remains at \"$(extract_dir.get_path())/$artifact\"\n");
+        }
+    }
+    printerr(@"Deploy context kept at \"$(extract_dir.get_path())\"\n");
+    return result;
+}
+
+
+/**
+ * `usm manifest deploy [flags…]` — generate a single-stage container deploy
+ * context for the manifest in the current directory, then (unless
+ * `--no-build`) build it with podman and save an xz-compressed image
+ * archive:
+ *
+ * - `--exec CMD`: the container command, split on whitespace into the
+ *   exec-form ENTRYPOINT. Default: the package's single `bin:` provide
+ *   (an error when there are zero or several).
+ * - `--base IMAGE`: base image (default {@link DEPLOY_DEFAULT_BASE_IMAGE}).
+ * - `--repository FILE`: use exactly the given `.usmr` files (repeatable)
+ *   instead of the machine-configured repositories. `file://` repositories
+ *   have their trees copied into the context and their URIs rewritten to the
+ *   in-image location.
+ * - `--installer-url URL`: override the canonical USM installer source; a
+ *   `file://` URL carries the script inside the context.
+ * - `--no-build`: stop after generating the context.
+ *
+ * Only repository public keys ever enter the context or the image.
+ */
+public int manifest_deploy(string[] args) {
+    string? exec_command = null;
+    string? base_image = null;
+    string? installer_url = null;
+    bool no_build = false;
+    // The top-level --verbose scan strips the flag before this parser runs,
+    // so seed from USM_VERBOSE as well
+    var verbose_env = Environment.get_variable("USM_VERBOSE");
+    bool verbose_deploy = verbose_env != null && verbose_env.length > 0;
+    var repository_overrides = new Vector<string>();
+
+    for(int i = 2; i < args.length; i++) {
+        var argument = args[i];
+        string? inline_value = null;
+        if(argument.has_prefix("--") && argument.contains("=")) {
+            var assignment = argument.split("=", 2);
+            argument = assignment[0];
+            inline_value = assignment[1];
+        }
+        switch(argument) {
+            case "--exec":
+                exec_command = deploy_option_value(args, ref i, argument, inline_value);
+                if(exec_command == null) {
+                    return deploy_usage();
+                }
+                break;
+            case "--base":
+                base_image = deploy_option_value(args, ref i, argument, inline_value);
+                if(base_image == null) {
+                    return deploy_usage();
+                }
+                break;
+            case "--repository":
+                var repository_file = deploy_option_value(args, ref i, argument, inline_value);
+                if(repository_file == null) {
+                    return deploy_usage();
+                }
+                repository_overrides.add(repository_file);
+                break;
+            case "--installer-url":
+                installer_url = deploy_option_value(args, ref i, argument, inline_value);
+                if(installer_url == null) {
+                    return deploy_usage();
+                }
+                break;
+            case "--no-build":
+                no_build = true;
+                break;
+            case "--verbose":
+            case "-v":
+                verbose_deploy = true;
+                break;
+            default:
+                printerr(@"Unknown deploy option \"$argument\"\n");
+                return deploy_usage();
+        }
+    }
+
+    if(manifest.is_data_package) {
+        printerr("Data packages cannot be deployed: they define no build or install machinery for the in-container install.\n");
+        return 246;
+    }
+    if(manifest.executables == null || manifest.executables.build == null) {
+        printerr(@"Package \"$(manifest.name)\" defines no build executable, which the in-container \"usm install\" requires.\n");
+        return 245;
+    }
+
+    var entrypoint = deploy_entrypoint(exec_command);
+    if(entrypoint.length == 0) {
+        return 244;
+    }
+
+    var project_dir = Environment.get_current_dir();
+    var context_dir = Path.build_filename(project_dir, DEPLOY_CONTEXT_DIRECTORY);
+    var version_string = manifest.version.to_string();
+
+    printerr(@"Generating deploy context in \"$context_dir\"...\n");
+    // A stale context or artifact from an earlier run must never leak into
+    // the package this deploy is about to create
+    if(File.new_for_path(context_dir).query_exists()) {
+        try {
+            Usm.Util.delete_tree(context_dir);
+        }
+        catch(Error e) {
+            printerr(@"Could not remove the stale deploy context: $(e.message)\n");
+            return 243;
+        }
+    }
+    foreach(var stale in new string[] { @"$(manifest.name)-$version_string.image.tar", @"$(manifest.name)-$version_string.image.tar.xz" }) {
+        var stale_file = File.new_for_path(Path.build_filename(project_dir, stale));
+        if(stale_file.query_exists()) {
+            try {
+                stale_file.delete();
+            }
+            catch(Error e) {
+                printerr(@"Could not remove the stale artifact \"$stale\": $(e.message)\n");
+                return 243;
+            }
+        }
+    }
+    DirUtils.create_with_parents(context_dir, 0755);
+    DirUtils.create_with_parents(Path.build_filename(context_dir, "repos"), 0755);
+    DirUtils.create_with_parents(Path.build_filename(context_dir, "repo-trees"), 0755);
+    DirUtils.create_with_parents(Path.build_filename(context_dir, "package"), 0755);
+
+    printerr("Packaging the project (usm manifest package)...\n");
+    try {
+        var package_proc = new Subprocess.newv(new string[] { "/proc/self/exe", "manifest", "package" }, SubprocessFlags.INHERIT_FDS);
+        package_proc.wait_check();
+        var produced = Path.build_filename(project_dir, "..", @"$(manifest.name)-$version_string.usmc");
+        File.new_for_path(produced).move(File.new_build_filename(context_dir, "package", "package.usmc"), FileCopyFlags.OVERWRITE);
+    }
+    catch(Error e) {
+        printerr(@"Failed to package the project: $(e.message)\n");
+        return 242;
+    }
+
+    bool file_trees_provisioned = false;
+    var provisioned = deploy_provision_repositories(repository_overrides, context_dir, project_dir, out file_trees_provisioned);
+    if(provisioned != 0) {
+        return provisioned;
+    }
+
+    var effective_installer_url = installer_url ?? Usm.Installer.CANONICAL_URL;
+    var bundled_installer = false;
+    if(effective_installer_url.has_prefix("file://")) {
+        // A file:// installer URL can only usefully refer to the container's
+        // own filesystem, so the script is carried inside the context
+        var installer_path = deploy_file_uri_path(effective_installer_url);
+        if(installer_path == null || !File.new_for_path(installer_path).query_exists()) {
+            printerr(@"--installer-url \"$effective_installer_url\" does not point at an installer script on this machine\n");
+            return 241;
+        }
+        try {
+            var local_dir = Path.build_filename(context_dir, "installer-local");
+            DirUtils.create_with_parents(local_dir, 0755);
+            File.new_for_path(installer_path).copy(File.new_build_filename(local_dir, "install-usm.sh"), FileCopyFlags.OVERWRITE);
+        }
+        catch(Error e) {
+            printerr(@"Could not carry the installer script into the context: $(e.message)\n");
+            return 241;
+        }
+        effective_installer_url = "file:///usm-installer-local/install-usm.sh";
+        bundled_installer = true;
+    }
+
+    try {
+        deploy_write_container_config(context_dir);
+
+        var containerfile = deploy_containerfile(
+            base_image ?? DEPLOY_DEFAULT_BASE_IMAGE,
+            effective_installer_url,
+            bundled_installer,
+            manifest.name,
+            version_string,
+            entrypoint,
+            file_trees_provisioned,
+            verbose_deploy);
+        FileUtils.set_data(Path.build_filename(context_dir, "Containerfile"), containerfile.data);
+    }
+    catch(Error e) {
+        printerr(@"Could not write the deploy context: $(e.message)\n");
+        return 240;
+    }
+
+    var tag = @"$(deploy_tag_chunk(manifest.name)):$(deploy_tag_chunk(version_string))";
+    if(no_build) {
+        printerr(@"Deploy context generated in \"$context_dir\" (--no-build); build it with:\n  podman build -t $tag \"$context_dir\"\n");
+        return 0;
+    }
+
+    printerr(@"Building image \"$tag\" with podman...\n");
+    try {
+        var build_proc = new Subprocess.newv(new string[] { "podman", "build", "-t", tag, context_dir }, SubprocessFlags.INHERIT_FDS);
+        build_proc.wait_check();
+    }
+    catch(Error e) {
+        printerr(@"Image build failed: $(e.message)\n");
+        return 239;
+    }
+
+    var artifact_base = @"$(manifest.name)-$version_string.image.tar";
+    printerr(@"Saving image to \"$artifact_base.xz\"...\n");
+    try {
+        var save_proc = new Subprocess.newv(new string[] { "podman", "save", "-o", artifact_base, tag }, SubprocessFlags.INHERIT_FDS);
+        save_proc.wait_check();
+        var compress_proc = new Subprocess.newv(new string[] { "xz", "-T0", artifact_base }, SubprocessFlags.INHERIT_FDS);
+        compress_proc.wait_check();
+    }
+    catch(Error e) {
+        printerr(@"Saving the image failed: $(e.message)\n");
+        return 238;
+    }
+
+    printerr(@"Built image \"$tag\"; artifact \"$artifact_base.xz\".\n");
+    printerr(@"Load and run it with:\n  podman load -i \"$artifact_base.xz\"\n  podman run --rm $tag\n");
+    return 0;
+}
+
+
+private int deploy_usage() {
+    printerr("USAGE:\n\tusm deploy <package.usmc|directory> [--exec CMD] [--base IMAGE] [--repository FILE]... [--no-build] [--installer-url URL] [--verbose]\n");
+    return 255;
+}
+
+
+/** Whether a deploy option consumes the following argument as its value. */
+private bool deploy_option_takes_value(string option) {
+    return option == "--exec" || option == "--base" || option == "--repository" || option == "--installer-url";
+}
+
+
+/**
+ * The value of a valued option, either the `--option=value` inline form or
+ * the next argument; prints the option name and returns null when no value
+ * follows.
+ */
+private string? deploy_option_value(string[] args, ref int index, string option, string? inline_value) {
+    if(inline_value != null) {
+        return inline_value;
+    }
+    if(index + 1 >= args.length) {
+        printerr(@"Expected a value after \"$option\"\n");
+        return null;
+    }
+    return args[++index];
+}
+
+
+/**
+ * The exec-form entrypoint words: `--exec`'s command split on whitespace, or
+ * by default the package's single `bin:` provide as an absolute /usr/bin
+ * path. Returns an empty array (after reporting why) when `--exec` is given
+ * without words or the default is ambiguous.
+ */
+private string[] deploy_entrypoint(string? exec_command) {
+    if(exec_command != null) {
+        var words = new Vector<string>();
+        foreach(var word in exec_command.split(" ")) {
+            if(word.length > 0) {
+                words.add(word);
+            }
+        }
+        if(words.length == 0) {
+            printerr(@"--exec \"$(exec_command)\" contains no command\n");
+            return new string[0];
+        }
+        return words.to_array();
+    }
+
+    var binaries = new Vector<string>();
+    foreach(var provide in manifest.provides) {
+        if(provide.key.resource_type == Usm.ResourceType.BINARY) {
+            binaries.add(provide.key.resource);
+        }
+    }
+    if(binaries.length != 1) {
+        printerr(@"Cannot pick a default entrypoint: expected exactly one \"bin:\" provide in \"$(manifest.name)\", found $(binaries.length)");
+        foreach(var binary in binaries) {
+            printerr(@"\n  bin:$binary");
+        }
+        printerr("\nPass --exec to choose the container command explicitly.\n");
+        return new string[0];
+    }
+    return new string[] { Path.build_filename("/", "usr", "bin", binaries.first_or_default()) };
+}
+
+
+/**
+ * Fills the context's `repos/` and `repo-trees/` directories from either the
+ * explicit {@link overrides} (exactly those `.usmr` files) or, by default,
+ * the machine-configured repositories (`<config dir>/repos.d`, honouring
+ * USM_CONFIGDIR like every other usm command).
+ *
+ * Every `.usmr` is copied with its embedded public key; a `file://` URI
+ * additionally has its repository tree copied into `repo-trees/<name>/` and
+ * the copied descriptor's URI rewritten to the in-image location so the
+ * container resolves it without the host. Private signing key material is
+ * never copied. Sets {@link file_trees_provisioned} when at least one tree
+ * was carried in.
+ */
+private int deploy_provision_repositories(Vector<string> overrides, string context_dir, string project_dir, out bool file_trees_provisioned) {
+    file_trees_provisioned = false;
+
+    var selected = new Vector<string>();
+    if(overrides.length > 0) {
+        foreach(var path in overrides) {
+            selected.add(Path.is_absolute(path) ? path : Path.build_filename(project_dir, path));
+        }
+    }
+    else {
+        var repos_dir = Path.build_filename(paths.usm_config_dir, "repos.d");
+        if(File.new_for_path(repos_dir).query_exists()) {
+            try {
+                foreach(var file in Iterate.directory(repos_dir)) {
+                    if(file.has_suffix(".usmr")) {
+                        selected.add(Path.build_filename(repos_dir, file));
+                    }
+                }
+            }
+            catch(Error e) {
+                printerr(@"Could not list \"$repos_dir\": $(e.message)\n");
+                return 241;
+            }
+        }
+        else {
+            printerr(@"No configured repositories found in \"$repos_dir\"; the image will resolve dependencies from the system package manager alone. Pass --repository to provision USM repositories into the image.\n");
+        }
+    }
+
+    foreach(var repository_file in selected) {
+        Usm.Repository repository;
+        try {
+            repository = new Usm.Repository.from_file(repository_file);
+        }
+        catch(Error e) {
+            printerr(@"\"$repository_file\" is not a valid repository file: $(e.message)\n");
+            return 241;
+        }
+
+        var basename = Path.get_basename(repository_file);
+        var stem = basename.has_suffix(".usmr") ? basename.substring(0, basename.length - ".usmr".length) : basename;
+        var copied_descriptor = Path.build_filename(context_dir, "repos", basename);
+
+        try {
+            File.new_for_path(repository_file).copy(File.new_for_path(copied_descriptor), FileCopyFlags.OVERWRITE);
+
+            if(repository.url != null && repository.url.has_prefix("file://")) {
+                var tree = deploy_file_uri_path((!)repository.url);
+                if(tree == null) {
+                    printerr(@"\"$repository_file\" has an unreadable file:// URI\n");
+                    return 241;
+                }
+                if(!File.new_for_path(tree).query_exists()) {
+                    printerr(@"The repository tree \"$tree\" referenced by \"$repository_file\" does not exist\n");
+                    return 241;
+                }
+
+                var destination = Path.build_filename(context_dir, "repo-trees", stem);
+                DirUtils.create_with_parents(destination, 0755);
+                deploy_copy_repository_tree(tree, destination);
+
+                var element = new InvercargillJson.JsonElement.from_file(copied_descriptor);
+                element.as<InvercargillJson.JsonObject>().set_native("url", @"file://$(DEPLOY_REPO_TREES_PATH)/$stem");
+                element.write_to_file(copied_descriptor);
+                printerr(@"Provisioned repository \"$stem\": tree copied from \"$tree\", URI rewritten to file://$(DEPLOY_REPO_TREES_PATH)/$stem\n");
+                file_trees_provisioned = true;
+            }
+            else {
+                printerr(@"Provisioned repository \"$stem\" from \"$repository_file\" (URI \"$(repository.url ?? "none")\")\n");
+            }
+        }
+        catch(Error e) {
+            printerr(@"Could not provision repository \"$repository_file\": $(e.message)\n");
+            return 241;
+        }
+    }
+
+    return 0;
+}
+
+
+/**
+ * The local path a `file://` URI points at, or null when it cannot be
+ * determined (GLib handles the percent-decoding).
+ */
+private string? deploy_file_uri_path(string uri) {
+    try {
+        return Filename.from_uri(uri, null);
+    }
+    catch(Error e) {
+        return null;
+    }
+}
+
+
+/**
+ * Recursively copies a repository tree into the context, refusing to carry
+ * anything that looks like signing key material: only public artefacts (the
+ * signed listing, packages, public keys) may ever enter a deploy context.
+ */
+private void deploy_copy_repository_tree(string source, string destination) throws Error {
+    var source_dir = File.new_for_path(source);
+    var destination_dir = File.new_for_path(destination);
+    if(!destination_dir.query_exists()) {
+        destination_dir.make_directory();
+    }
+
+    var enumerator = source_dir.enumerate_children("*", FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+    while(true) {
+        var info = enumerator.next_file();
+        if(info == null) {
+            break;
+        }
+        var name = info.get_name();
+        if(info.get_file_type() == FileType.DIRECTORY) {
+            if(name == "keys" || name == ".git") {
+                continue;
+            }
+            deploy_copy_repository_tree(Path.build_filename(source, name), Path.build_filename(destination, name));
+        }
+        else if(!name.has_prefix("private-key")) {
+            source_dir.get_child(name).copy(destination_dir.get_child(name), FileCopyFlags.OVERWRITE);
+        }
+    }
+}
+
+
+/**
+ * Writes the minimal in-image `usm.config`: managed state under /var/usm and
+ * the system package manager wired to the DNF helper the installer ships, so
+ * in-container resolution asks DNF before USM repositories. Hand-authored on
+ * purpose — the generated image must stay independent of this machine's
+ * configuration.
+ */
+private void deploy_write_container_config(string context_dir) throws Error {
+    var helper = @"$(DEPLOY_USM_PREFIX)/bin/usm-spm-dnf";
+    var config = @"{
+    \"is_managed\": true,
+    \"managed\": {
+        \"state_path\": \"/var/usm\"
+    },
+    \"paths\": {
+        \"lib\": \"lib64\"
+    },
+    \"system_package_manager\": {
+        \"query\": [\"$helper\", \"query\"],
+        \"install\": [\"$helper\", \"install\"]
+    }
+}
+";
+    FileUtils.set_data(Path.build_filename(context_dir, "usm.config"), config.data);
+}
+
+
+/**
+ * Renders the single-stage Containerfile: ARG-before-FROM base image, USM
+ * installed from the installer URL, the minimal configuration, repository
+ * descriptors (plus rewritten `file://` trees), the package pre-seeded into
+ * the USM cache and installed in-container, and the exec-form ENTRYPOINT.
+ */
+private string deploy_containerfile(string base_image, string installer_url, bool bundled_installer,
+        string package_name, string version_string, string[] entrypoint, bool file_trees_provisioned,
+        bool verbose_deploy) {
+    var builder = new StringBuilder();
+    builder.append("# Generated by `usm manifest deploy` — regenerate rather than edit\n\n");
+    builder.append_printf("ARG BASE_IMAGE=%s\n", base_image);
+    builder.append("FROM ${BASE_IMAGE}\n\n");
+    builder.append("# Override at build time with --build-arg USM_INSTALLER_URL=...\n");
+    builder.append_printf("ARG USM_INSTALLER_URL=%s\n\n", installer_url);
+    if(bundled_installer) {
+        builder.append("COPY installer-local/install-usm.sh /usm-installer-local/install-usm.sh\n\n");
+    }
+    builder.append("# DNF4 python bindings for the usm SPM helper (python3 is absent from the base image)\n");
+    builder.append("RUN dnf install -y python3-dnf && dnf clean all\n\n");
+    builder.append("# Install USM. The installer is downloaded to a real file first because it\n");
+    builder.append("# extracts its payload relative to $0, so `curl ... | sh` cannot work.\n");
+    builder.append("RUN curl -fsSL \"${USM_INSTALLER_URL}\" -o /tmp/install-usm.sh \\\n");
+    builder.append(" && sh /tmp/install-usm.sh -y \\\n");
+    builder.append(" && rm -f /tmp/install-usm.sh\n\n");
+    builder.append("COPY usm.config /etc/usm/usm.config\n\n");
+    builder.append("COPY repos/ /etc/usm/repos.d/\n");
+    if(file_trees_provisioned) {
+        builder.append("COPY repo-trees/ /usr/share/usm-repos/\n");
+    }
+    builder.append("\n");
+    builder.append_printf("COPY package/package.usmc /var/usm/packages/%s-%s/package.usmc\n", package_name, version_string);
+    builder.append_printf("RUN mkdir -p /var/usm/lists /var/usm/installed && usm install %s%s\n\n", package_name, verbose_deploy ? " --verbose" : "");
+    builder.append_printf("ENTRYPOINT %s\n", deploy_entrypoint_json(entrypoint));
+    return builder.str;
+}
+
+
+/** The exec-form JSON array for the generated ENTRYPOINT. */
+private string deploy_entrypoint_json(string[] entrypoint) {
+    var words = new Vector<string>();
+    foreach(var word in entrypoint) {
+        words.add("\"" + word.replace("\\", "\\\\").replace("\"", "\\\"") + "\"");
+    }
+    return @"[$(words.to_string(w => w, ", "))]";
+}
+
+
+/**
+ * A podman-safe tag chunk: tag syntax only allows `[A-Za-z0-9_.-]`, so
+ * anything else (usm release suffixes like `+` in versions, for instance)
+ * maps to a dash. Artifact filenames keep the unsanitised form.
+ */
+private string deploy_tag_chunk(string chunk) {
+    var builder = new StringBuilder();
+    for(int index = 0; index < chunk.length; index++) {
+        var character = chunk[index];
+        var valid = (character >= 'a' && character <= 'z')
+            || (character >= 'A' && character <= 'Z')
+            || (character >= '0' && character <= '9')
+            || character == '_' || character == '.' || character == '-';
+        builder.append_unichar(valid ? character : '-');
+    }
+    return builder.str;
+}

+ 1 - 1
src/cli/GenConfig.vala

@@ -22,7 +22,7 @@ public int genconfig_main() {
     var json_element = new JsonElement.from_properties(properties);
     
     // Output pretty-printed JSON to stdout
-    print("%s\n", json_element.stringify(true));
+    print("%s\n", json_element.stringify_pretty());
     
     return 0;
 }

+ 160 - 37
src/cli/Install.vala

@@ -7,53 +7,176 @@ private int install_main(string[] args) {
         return install_usage();
     }
 
-    var state = new Usm.SystemState(paths);
-    
-    printerr("Refreshing repositories...\n");
-    var local_finder = new Usm.ResourceFinder();
-    var resolver = new Usm.Resolver(local_finder);
-    var repos = state.get_repositories();
-    foreach (var repo in repos) {
-        state.refresh_list(repo, (f, c, t) => printerr(@"Refreshing list for $(repo.name): downloading $f $c/$t bytes\r"));
-        var listing = state.get_latest_list(repo);
-        if(listing != null) {
-            resolver.load_listing(repo, listing);
-        }
-        printerr("\n");
+    Usm.SystemState state = null;
+    try {
+        state = new Usm.SystemState(paths);
     }
-
-    var cached_packages = new HashSet<Usm.CachedPackage>();
-    for(int i = 2; i < args.length; i++) {
-        var target = resolver.find_package(args[i]);
-        var client = target.repository.get_client();
-        var path = state.generate_cache_path(target.manifest);
-        File.new_for_path(path).make_directory();
-        var package_path = Path.build_filename(path, "package.usmc");
-        client.download_package(package_path, target.repository_entry, (f, c, t) => printerr(@"Downloading $f $c/$t bytes\r"));
-        client.verify_package(package_path, target.repository_entry, (f, c, t) => printerr(@"Verifying $f $c/$t bytes\r"));
-        cached_packages.add(new Usm.CachedPackage(path));
-        printerr("\n");
+    catch(Error e) {
+        printerr(@"This system is not managed by usm: $(e.message)\n");
+        return 245;
     }
 
-    var transaction = new Usm.Transaction() {
-        paths = paths,
-        resource_finder = new Usm.ResourceFinder(paths),
-        to_remove = new HashSet<Usm.CachedPackage>(),
-        to_install = cached_packages,
-        state = state
-    };
+    try {
+        printerr("Refreshing repositories...\n");
+        var local_finder = new Usm.ResourceFinder();
+        var resolver = new Usm.Resolver(local_finder);
+        var repos = state.get_repositories();
+        foreach (var repo in repos) {
+            state.refresh_list(repo, (f, c, t) => printerr(@"Refreshing list for $(repo.name): downloading $f $c/$t bytes\r"));
+            var listing = state.get_latest_list(repo);
+            if(listing != null) {
+                resolver.load_listing(repo, listing);
+            }
+            printerr("\n");
+        }
+
+        // Cached packages can satisfy dependencies (and act as install targets
+        // a repository no longer lists)
+        resolver.load_cache(paths);
+
+        var roots = new Vector<Usm.AbstractPackage>();
+        for(int i = 2; i < args.length; i++) {
+            var target = resolver.find_package(args[i]);
+            if(target == null) {
+                printerr(@"No package named \"$(args[i])\" found in any repository or the cache\n");
+                return 254;
+            }
+            roots.add(target);
+        }
+
+        // Resolve the full closure first: local → system packages → USM
+        // repositories/cache, with candidate-group selection
+        var spm = new Usm.SystemPackageManager(state.config);
+        Usm.ResolutionResult resolution;
+        try {
+            resolution = resolver.resolve(roots, spm);
+        }
+        catch(Usm.ResolverError e) {
+            printerr("%s\n".printf(e.message));
+            return 240;
+        }
+
+        // Download every repository-backed package in install order; supplied
+        // cache packages are used in place and need no download, so they only
+        // need collecting into the transaction set from their cache directories
+        var cached_packages = new HashSet<Usm.CachedPackage>();
+        foreach(var package in resolution.install_order) {
+            if(package.repository == null) {
+                if(package.package_path != null) {
+                    cached_packages.add(new Usm.CachedPackage(Path.get_dirname((!)package.package_path)));
+                }
+                continue;
+            }
+            var client = package.repository.get_client();
+            var path = state.generate_cache_path(package.manifest);
+            var cache_dir = File.new_for_path(path);
+            if(!cache_dir.query_exists()) {
+                cache_dir.make_directory();
+            }
+            var package_path = Path.build_filename(path, "package.usmc");
+            client.download_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Downloading $f $c/$t bytes\r"));
+            client.verify_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Verifying $f $c/$t bytes\r"));
+            cached_packages.add(new Usm.CachedPackage(path));
+            printerr("\n");
+        }
+
+        // Chosen system packages install first, as one transaction
+        if(resolution.system_packages.any()) {
+            if(!install_system_packages(spm, resolution.system_packages)) {
+                return 239;
+            }
+        }
+
+        var install_order = new Vector<string>();
+        foreach(var package in resolution.install_order) {
+            install_order.add(package.manifest.name);
+        }
+
+        var transaction = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(paths),
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            to_install = cached_packages,
+            install_order = install_order,
+            state = state
+        };
+
+        printerr("\nRunning transaction...\n");
+        transaction.progress_updated.connect(transaction.print_progress);
+        transaction.run();
 
-    printerr("\nRunning transaction...\n");
-    transaction.progress_updated.connect(transaction.print_progress);
-    transaction.run();
 
+        // todo cleanup
 
-    // todo cleanup
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return 238;
+    }
 
     return 0;
 }
 
+/**
+ * Installs the resolution's chosen system packages as one transaction,
+ * surfacing progress through the manager's {@link Usm.InstallProgressDelegate}
+ * events; returns false (after printing why) when the transaction failed.
+ */
+private bool install_system_packages(Usm.SystemPackageManager spm, Vector<Usm.SystemPackageCandidate> packages) {
+    printerr("Installing system packages...\n");
+    var names = new Vector<string>();
+    foreach(var package in packages) {
+        names.add(package.name);
+    }
+
+    int installed = 0;
+    int total = 0;
+    string failure = "";
+    var loop = new MainLoop();
+    spm.install.begin(names, event => {
+        switch(event.event_type) {
+            case Usm.SystemInstallEventType.BEGIN:
+                total = event.total;
+                break;
+            case Usm.SystemInstallEventType.PACKAGE:
+                if(event.name != null) {
+                    printerr(@"\r  installing $(event.name) ($((int)(event.progress * 100))%)");
+                }
+                break;
+            case Usm.SystemInstallEventType.PACKAGE_COMPLETE:
+                installed++;
+                if(event.name != null) {
+                    printerr(@"\r  [$installed/$total] installed system package $(event.name)      \n");
+                }
+                break;
+            case Usm.SystemInstallEventType.COMPLETE:
+                break;
+            case Usm.SystemInstallEventType.ERROR:
+                failure = event.message ?? "unknown error";
+                break;
+        }
+    }, (obj, res) => {
+        try {
+            if(!spm.install.end(res)) {
+                failure = failure.length > 0 ? failure : "the system package manager reported a failure";
+            }
+        }
+        catch(Error e) {
+            failure = e.message;
+        }
+        loop.quit();
+    });
+    loop.run();
+
+    if(failure.length > 0) {
+        printerr(@"System package installation failed: $failure\n");
+        return false;
+    }
+    printerr(@"Installed $installed system package(s).\n");
+    return true;
+}
+
 private int install_usage() {
     printerr("USAGE:\n\tusm install <packages>\n");
     return 255;
-}
+}

+ 209 - 124
src/cli/Manifest.vala

@@ -31,7 +31,12 @@ public static int manifest_main(string[] args) {
                 build_path = args[i];
             }
         }
-    } else if(verb != "remove" && verb != "acquire" && verb != "install" && verb != "package" && verb != "test" && verb != "validate") {
+    } else if(verb == "build") {
+        // Optional for data packages (a no-op); enforced for regular packages in build()
+        if(args.length >= 3) {
+            build_path = args[2];
+        }
+    } else if(verb != "remove" && verb != "acquire" && verb != "install" && verb != "package" && verb != "deploy" && verb != "test" && verb != "validate") {
         if(args.length < 3) {
             manifest_usage();
             return 255;
@@ -47,14 +52,22 @@ public static int manifest_main(string[] args) {
     try {
         var element = new InvercargillJson.JsonElement.from_file("MANIFEST.usm");
         manifest = Usm.Manifest.get_mapper().materialise(element.as<Invercargill.Properties>());
+        manifest.validate();
     }
     catch (Error e) {
         printerr(@"Could not read MANIFEST.usm: $(e.message)\n");
         return 253;
     }
 
+    // Data packages define no build step: nothing is staged, no build directory
+    // or build.tar.xz is ever created for them
+    if(manifest.is_data_package && verb == "build") {
+        printerr(@"Data package \"$(manifest.name)\" has no build step, nothing to build.\n");
+        return 0;
+    }
+
     // Automatically create a temporary directory for these commands if one was not provided.
-    if(verb == "install" || verb == "autoprovides" || verb == "test" || verb == "validate") {
+    if(!manifest.is_data_package && (verb == "install" || verb == "autoprovides" || verb == "test" || verb == "validate")) {
         if(build_path == null) {
             var dir = File.new_build_filename("/tmp", Uuid.string_random());
             try {
@@ -69,7 +82,7 @@ public static int manifest_main(string[] args) {
     }
 
     // Automatically create the specified directory for these commands if it doesn't exist
-    if(verb == "install" || verb == "autoprovides" || verb == "test" || verb == "build" || verb == "validate") {
+    if(!manifest.is_data_package && build_path != null && (verb == "install" || verb == "autoprovides" || verb == "test" || verb == "build" || verb == "validate")) {
         var build_dir = File.new_for_path(build_path);
         if(!build_dir.query_exists()) {
             printerr(@"Creating build directory: $build_path\n");
@@ -103,6 +116,10 @@ public static int manifest_main(string[] args) {
         return package();
     }
 
+    if(verb == "deploy") {
+        return manifest_deploy(args);
+    }
+
     if(verb == "autoprovides") {
         return autoprovides();
     }
@@ -120,28 +137,63 @@ public static int manifest_main(string[] args) {
 }
 
 private void manifest_usage() {
-    printerr("USAGE:\n\tusm manifest build <build path>\n\tusm manifest install <build path>\n\tusm manifest remove\nusm manifest acquire\nusm manifest autoprovides [--replace] [--debug] [build path]\nusm manifest test [build path]\nusm manifest validate [build path]\n");
+    printerr("USAGE:\n\tusm manifest build <build path>\n\tusm manifest install <build path>\n\tusm manifest remove\nusm manifest acquire\nusm manifest package\nusm manifest deploy [--exec CMD] [--base IMAGE] [--repository FILE]... [--no-build] [--installer-url URL]\nusm manifest autoprovides [--replace] [--debug] [build path]\nusm manifest test [build path]\nusm manifest validate [build path]\n");
 }
 
+/**
+ * Checks one dependency phase of the loaded manifest against the resources
+ * {@link finder} can see, printing the same "Missing … dependency" lines as
+ * before for flat phases.
+ *
+ * Grouped phases choose the first group whose members are all present (this
+ * flow installs nothing, so every group costs nothing and manifest order
+ * wins); when no group is viable an itemised per-group report is printed.
+ * Returns false when the phase's requirements are unmet.
+ */
+private bool phase_dependencies_satisfied(Usm.DependencyPhase? phase, string label, Usm.ResourceFinder finder) {
+    if(phase == null) {
+        return true;
+    }
+
+    if(!phase.is_grouped) {
+        var sane = true;
+        foreach(var resource in phase.ordered_required()) {
+            if(!finder.has_resource(resource)) {
+                printerr(@"Missing $label dependency \"$resource\".\n");
+                sane = false;
+            }
+        }
+        return sane;
+    }
 
-private int build() {
-    // Create build directory if it doesn't exist
-    var missing_management_deps = manifest.dependencies.manage.where(d => !resfinder.has_resource(d));
-    var missing_build_deps = manifest.dependencies.build.where(d => !resfinder.has_resource(d));
-
-    var sane = true;
-    if(missing_management_deps.any()) {
-        sane = false;
-        foreach (var item in missing_management_deps) {
-            printerr(@"Missing management dependency \"$item\".\n");
+    var groups = phase.ordered_groups();
+    foreach(var group in groups) {
+        var missing = group.where(r => !finder.has_resource(r)).to_vector();
+        if(missing.length == 0) {
+            return true;
         }
     }
-    if(missing_build_deps.any()) {
-        sane = false;
-        foreach (var item in missing_build_deps) {
-            printerr(@"Missing build dependency \"$item\".\n");
+
+    printerr(@"No viable $label dependency group:\n");
+    for(uint index = 0; index < groups.length; index++) {
+        printerr(@"  Group $(index + 1) ($(groups[index].to_string(r => r.to_string(), ", "))):\n");
+        foreach(var resource in groups[index].where(r => !finder.has_resource(r))) {
+            printerr(@"    Missing $label dependency \"$resource\".\n");
         }
     }
+    return false;
+}
+
+
+private int build() {
+    if(build_path == null) {
+        manifest_usage();
+        return 255;
+    }
+
+    // Create build directory if it doesn't exist
+    var sane = phase_dependencies_satisfied(manifest.dependencies.manage, "management", resfinder) &
+        phase_dependencies_satisfied(manifest.dependencies.build, "build", resfinder);
 
     if(!sane) {
         printerr("Could not build manifest, missing dependencies\n");
@@ -161,6 +213,12 @@ private int build() {
 }
 
 private int install() {
+    // Data packages define no build or install execs; their provides are
+    // copied directly from the source tree
+    if(manifest.is_data_package) {
+        return install_data_package();
+    }
+
     // Create build directory if it doesn't exist
     var build_dir = File.new_for_path(build_path);
     if(!build_dir.query_exists()) {
@@ -174,29 +232,9 @@ private int install() {
         }
     }
 
-    var missing_management_deps = manifest.dependencies.manage.where(d => !resfinder.has_resource(d));
-    var missing_build_deps = manifest.dependencies.build.where(d => !resfinder.has_resource(d));
-    var missing_runtime_deps = manifest.dependencies.runtime.where(d => !destresfinder.has_resource(d));
-
-    var sane = true;
-    if(missing_management_deps.any()) {
-        sane = false;
-        foreach (var item in missing_management_deps) {
-            printerr(@"Missing management dependency \"$item\".\n");
-        }
-    }
-    if(missing_build_deps.any()) {
-        sane = false;
-        foreach (var item in missing_build_deps) {
-            printerr(@"Missing build dependency \"$item\".\n");
-        }
-    }
-    if(missing_runtime_deps.any()) {
-        sane = false;
-        foreach (var item in missing_runtime_deps) {
-            printerr(@"Missing runtime dependency \"$item\".\n");
-        }
-    }
+    var sane = phase_dependencies_satisfied(manifest.dependencies.manage, "management", resfinder) &
+        phase_dependencies_satisfied(manifest.dependencies.build, "build", resfinder) &
+        phase_dependencies_satisfied(manifest.dependencies.runtime, "runtime", destresfinder);
 
     if(!sane) {
         printerr("Could not install manifest, missing dependencies\n");
@@ -262,22 +300,42 @@ private int install() {
 }
 
 
+/**
+ * Installs a data package: every provide is copied directly from the source
+ * tree to its suggested path, with no build or install staging involved.
+ */
+private int install_data_package() {
+    if(!phase_dependencies_satisfied(manifest.dependencies.runtime, "runtime", destresfinder)) {
+        printerr("Could not install manifest, missing dependencies\n");
+        return 252;
+    }
+
+    try {
+        manifest.install_resources(Environment.get_current_dir(), Environment.get_current_dir(), null, paths, (r, c, t, f) => {
+            if(f == 0.0f) {
+                printerr(@"Installing resource $(c+1)/$t: \"$(r)\"\n");
+            }
+        });
+    }
+    catch(Error e) {
+        printerr(@"Error installing manifest resources: $(e.message)\n");
+        return 248;
+    }
+
+    return 0;
+}
+
+
 private int acquire() {
     if(manifest.executables.acquire == null) {
         printerr(@"Manifest does not reference an acquire script\n");
         return 248;
     }
 
-    if(manifest.dependencies.acquire != null) {
-        var missing_acquire_deps = manifest.dependencies.manage.where(d => !resfinder.has_resource(d));
-        if(missing_acquire_deps.any()) {
-            foreach (var item in missing_acquire_deps) {
-                printerr(@"Missing acquire dependency \"$item\".\n");
-            }
-            printerr("Could not build manifest, missing dependencies\n");
-            return 247;
-        }
-
+    // The acquire phase's own refs gate acquisition (it runs on the host)
+    if(!phase_dependencies_satisfied(manifest.dependencies.acquire, "acquire", resfinder)) {
+        printerr("Could not acquire manifest, missing dependencies\n");
+        return 247;
     }
 
     try {
@@ -293,13 +351,7 @@ private int acquire() {
 }
 
 private int uininstall() {
-    var missing_management_deps = manifest.dependencies.manage.where(d => !resfinder.has_resource(d));
-
-    if(missing_management_deps.any()) {
-        foreach (var item in missing_management_deps) {
-            printerr(@"Missing management dependency \"$item\".\n");
-        }
-
+    if(!phase_dependencies_satisfied(manifest.dependencies.manage, "management", resfinder)) {
         printerr("Could not remove manifest, missing dependencies\n");
         return 252;
     }
@@ -365,8 +417,32 @@ private int package() {
     printerr("Writing archive...\n");
     var output = @"../$(manifest.name)-$(manifest.version).usmc";
     try {
-        var proc = new Subprocess.newv(new string[] { "tar", "-cJf", output, "."}, SubprocessFlags.INHERIT_FDS);
-        proc.wait_check();
+        // Collect everything not excluded by the root .usmignore (falling
+        // back to the .git-only default), pruning ignored directories
+        var ignore = new Usm.UsmIgnore.from_root(Environment.get_current_dir());
+        var entries = new Invercargill.DataStructures.Vector<string>();
+        package_collect(".", ignore, entries);
+
+        var list_file = File.new_build_filename("/tmp", Uuid.string_random());
+        try {
+            var stream = new DataOutputStream(list_file.replace(null, false, FileCreateFlags.REPLACE_DESTINATION));
+            foreach(var entry in entries) {
+                stream.put_string(@"./$entry\n");
+            }
+            stream.close();
+
+            var proc = new Subprocess.newv(new string[] { "tar", "-cJf", output, "--no-recursion", "-T", list_file.get_path() }, SubprocessFlags.INHERIT_FDS);
+            proc.wait_check();
+        }
+        finally {
+            try {
+                list_file.delete();
+            }
+            catch(Error e) {
+                // The temporary file list is disposable; leaving it behind is harmless
+            }
+        }
+
         printerr(@"Wrote package to path \"$(output)\"\n");
         return 0;
     }
@@ -376,6 +452,33 @@ private int package() {
     }
 }
 
+/**
+ * Walks the package tree rooted at {@link relative_dir}, collecting the
+ * relative paths of every file and directory not ignored by {@link ignore}.
+ * Ignored directories are pruned: nothing beneath them is visited.
+ */
+private static void package_collect(string relative_dir, Usm.UsmIgnore ignore, Invercargill.DataStructures.Vector<string> entries) throws Error {
+    var folder = File.new_for_path(relative_dir);
+    var enumerator = folder.enumerate_children("*", FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+
+    while(true) {
+        FileInfo? info = enumerator.next_file();
+        if(info == null) {
+            break;
+        }
+
+        var relative_path = relative_dir == "." ? info.get_name() : @"$relative_dir/$(info.get_name())";
+        var is_directory = info.get_file_type() == FileType.DIRECTORY;
+        if(ignore.matches(relative_path, is_directory)) {
+            continue;
+        }
+        entries.add(relative_path);
+        if(is_directory) {
+            package_collect(relative_path, ignore, entries);
+        }
+    }
+}
+
 private int autoprovides() {
     // Create build directory if it doesn't exist
     var build_dir = File.new_for_path(build_path);
@@ -390,34 +493,14 @@ private int autoprovides() {
         }
     }
 
-    var missing_management_deps = manifest.dependencies.manage.where(d => !resfinder.has_resource(d));
-    var missing_build_deps = manifest.dependencies.build.where(d => !resfinder.has_resource(d));
-    var missing_runtime_deps = manifest.dependencies.runtime.where(d => !destresfinder.has_resource(d));
-
     if(manifest.executables.install == null) {
         printerr(@"Autoprovides command only works with manifests that specify an installation executable.\n");
         return 1;
     }
 
-    var sane = true;
-    if(missing_management_deps.any()) {
-        sane = false;
-        foreach (var item in missing_management_deps) {
-            printerr(@"Missing management dependency \"$item\".\n");
-        }
-    }
-    if(missing_build_deps.any()) {
-        sane = false;
-        foreach (var item in missing_build_deps) {
-            printerr(@"Missing build dependency \"$item\".\n");
-        }
-    }
-    if(missing_runtime_deps.any()) {
-        sane = false;
-        foreach (var item in missing_runtime_deps) {
-            printerr(@"Missing runtime dependency \"$item\".\n");
-        }
-    }
+    var sane = phase_dependencies_satisfied(manifest.dependencies.manage, "management", resfinder) &
+        phase_dependencies_satisfied(manifest.dependencies.build, "build", resfinder) &
+        phase_dependencies_satisfied(manifest.dependencies.runtime, "runtime", destresfinder);
 
     if(!sane) {
         printerr("Could not install manifest, missing dependencies\n");
@@ -483,7 +566,7 @@ private int autoprovides() {
             }
         }
         
-        var json_string = json_object.as_element().stringify(true);
+        var json_string = json_object.as_element().stringify_pretty();
 
         if(replace_provides) {
             try {
@@ -494,7 +577,7 @@ private int autoprovides() {
                 var mapper = Usm.Manifest.get_mapper();
                 var properties = mapper.map_from(manifest);
                 var manifest_json_element = new InvercargillJson.JsonElement.from_properties(properties);
-                var manifest_json_string = manifest_json_element.stringify(true);
+                var manifest_json_string = manifest_json_element.stringify_pretty();
                 
                 // Write the updated manifest to file
                 var file = File.new_for_path("MANIFEST.usm");
@@ -706,6 +789,11 @@ private static string get_relative_path_for_type(string path, string type) {
 }
 
 private int test() {
+    if(manifest.is_data_package) {
+        printerr(@"Data packages define no test script.\n");
+        return 248;
+    }
+
     if(build_path == null) {
         var dir = File.new_build_filename("/tmp", Uuid.string_random());
         try {
@@ -731,22 +819,8 @@ private int test() {
         }
     }
 
-    var missing_management_deps = manifest.dependencies.manage.where(d => !resfinder.has_resource(d));
-    var missing_build_deps = manifest.dependencies.build.where(d => !resfinder.has_resource(d));
-
-    var sane = true;
-    if(missing_management_deps.any()) {
-        sane = false;
-        foreach (var item in missing_management_deps) {
-            printerr(@"Missing management dependency \"$item\".\n");
-        }
-    }
-    if(missing_build_deps.any()) {
-        sane = false;
-        foreach (var item in missing_build_deps) {
-            printerr(@"Missing build dependency \"$item\".\n");
-        }
-    }
+    var sane = phase_dependencies_satisfied(manifest.dependencies.manage, "management", resfinder) &
+        phase_dependencies_satisfied(manifest.dependencies.build, "build", resfinder);
 
     if(!sane) {
         printerr("Could not test manifest, missing dependencies\n");
@@ -807,29 +881,9 @@ private int validate() {
         }
     }
 
-    var missing_management_deps = manifest.dependencies.manage.where(d => !resfinder.has_resource(d));
-    var missing_build_deps = manifest.dependencies.build.where(d => !resfinder.has_resource(d));
-    var missing_runtime_deps = manifest.dependencies.runtime.where(d => !destresfinder.has_resource(d));
-
-    var sane = true;
-    if(missing_management_deps.any()) {
-        sane = false;
-        foreach (var item in missing_management_deps) {
-            printerr(@"Missing management dependency \"$item\".\n");
-        }
-    }
-    if(missing_build_deps.any()) {
-        sane = false;
-        foreach (var item in missing_build_deps) {
-            printerr(@"Missing build dependency \"$item\".\n");
-        }
-    }
-    if(missing_runtime_deps.any()) {
-        sane = false;
-        foreach (var item in missing_runtime_deps) {
-            printerr(@"Missing runtime dependency \"$item\".\n");
-        }
-    }
+    var sane = phase_dependencies_satisfied(manifest.dependencies.manage, "management", resfinder) &
+        phase_dependencies_satisfied(manifest.dependencies.build, "build", resfinder) &
+        phase_dependencies_satisfied(manifest.dependencies.runtime, "runtime", destresfinder);
 
     if(!sane) {
         printerr("Could not validate manifest, missing dependencies\n");
@@ -851,6 +905,12 @@ private int validate() {
         printerr("Warning: No URL has been provided in the manifest.\n");
     }
 
+    // Data packages define no build or install execs; their provides resolve
+    // directly from the source tree, so validate them there and stop
+    if(manifest.is_data_package) {
+        return validate_data_package();
+    }
+
     try {
         // Run build
         var build_proc = manifest.run_build(build_path, paths, SubprocessFlags.STDOUT_SILENCE, frac => printerr(@"Building '$(manifest.name)': $((int)(frac*100))%\r"));
@@ -996,6 +1056,31 @@ private int validate() {
         return 247;
     }
 
+    printerr("Manifest validation completed successfully.\n");
+    return 0;
+}
+
+/**
+ * Validates a data package: every provide must exist in the source tree at
+ * its declared path; no build or install staging is involved.
+ */
+private int validate_data_package() {
+    var missing_resources = new Invercargill.DataStructures.Vector<Usm.ResourceRef>();
+    foreach (var expected in manifest.provides) {
+        var expected_path = Path.build_filename(Environment.get_current_dir(), expected.value.path ?? "");
+        if(!File.new_for_path(expected_path).query_exists()) {
+            missing_resources.add(expected.key);
+        }
+    }
+
+    if(missing_resources.any()) {
+        printerr("Error: Expected resources not found in the source tree:\n");
+        foreach (var resource in missing_resources) {
+            printerr(@"  - $(resource.to_string()) (in source directory at \"$(manifest.provides[resource].path ?? "")\")\n");
+        }
+        return 248;
+    }
+
     printerr("Manifest validation completed successfully.\n");
     return 0;
 }

+ 533 - 47
src/cli/Repository.vala

@@ -13,6 +13,30 @@ private int repository_main(string[] args) {
         }
         return repository_new(args[3]);
     }
+    if(args[2] == "init") {
+        if(args.length != 4) {
+            return repository_usage();
+        }
+        return repository_init(args[3]);
+    }
+    if(args[2] == "add") {
+        if(args.length != 4) {
+            return repository_usage();
+        }
+        return repository_add(args[3]);
+    }
+    if(args[2] == "remove") {
+        if(args.length != 4) {
+            return repository_usage();
+        }
+        return repository_remove(args[3]);
+    }
+    if(args[2] == "list") {
+        if(args.length > 4) {
+            return repository_usage();
+        }
+        return repository_list(args.length == 4 ? args[3] : null);
+    }
     if(args[2] == "build-list") {
         return repository_build_list();
     }
@@ -22,33 +46,39 @@ private int repository_main(string[] args) {
         }
         return repository_verify(args[3]);
     }
+    if(args[2] == "publish") {
+        if(args.length != 4) {
+            return repository_usage();
+        }
+        return repository_publish(args[3]);
+    }
 
 
     return repository_usage();
 }
 
 private int repository_usage() {
-    printerr("USAGE:\n\tusm repository new <repository name>\n\tusm repository build-list\n\tusm repository verify repo.usmr\n");
+    printerr("USAGE:\n\tusm repository init <repository name>\n\tusm repository add <package.usmc>\n\tusm repository remove <name|package-filename>\n\tusm repository list [repo.usmr]\n\tusm repository verify repo.usmr\n\tusm repository publish <path>\n\tusm repository new <repository name> (legacy)\n\tusm repository build-list (legacy)\n");
     return 255;
 }
 
 private int repository_new(string name) {
-    var public_key = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
-    var private_key = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
-    Sodium.Asymmetric.Signing.generate_keypair(public_key, private_key);
+    uint8[] public_key;
+    uint8[] private_key;
+    repository_generate_keypair(out public_key, out private_key);
 
     DirUtils.create("public", 0755);
-    
+
     try {
         FileUtils.set_data("public-key", public_key);
         FileUtils.set_data("private-key", private_key);
-    
+
         var repository = new Usm.Repository();
         repository.key = Wrap.byte_array(public_key);
         repository.name = name;
         repository.url = @"file:///$(Environment.get_current_dir())/public";
         repository.summary = "My new repository";
-    
+
         var properties = Usm.Repository.get_mapper().map_from(repository);
         var json = new InvercargillJson.JsonElement.from_properties(properties);
         json.write_to_file(@"$name.usmr");
@@ -62,57 +92,486 @@ private int repository_new(string name) {
     return 0;
 }
 
+private int repository_init(string name) {
+    if(repository_find_repo_file(Environment.get_current_dir()) != null
+        || FileUtils.test(Path.build_filename("keys", "private-key"), FileTest.EXISTS)) {
+        printerr("A repository has already been initialised in this directory\n");
+        return 1;
+    }
+
+    uint8[] public_key;
+    uint8[] private_key;
+    repository_generate_keypair(out public_key, out private_key);
+
+    DirUtils.create("keys", 0700);
+    DirUtils.create("public", 0755);
+
+    try {
+        FileUtils.set_data(Path.build_filename("keys", "public-key"), public_key);
+        FileUtils.set_data(Path.build_filename("keys", "private-key"), private_key);
+        FileUtils.chmod(Path.build_filename("keys", "private-key"), 0600);
+
+        var repository = new Usm.Repository();
+        repository.key = Wrap.byte_array(public_key);
+        repository.name = name;
+        repository.url = @"file://$(Environment.get_current_dir())/public";
+        repository.summary = @"My $name repository";
+
+        var properties = Usm.Repository.get_mapper().map_from(repository);
+        var json = new InvercargillJson.JsonElement.from_properties(properties);
+        json.write_to_file(@"$name.usmr");
+
+        var gitignore = "keys/private*\n";
+        if(FileUtils.test(".gitignore", FileTest.EXISTS)) {
+            string existing;
+            FileUtils.get_contents(".gitignore", out existing);
+            if(!existing.has_suffix("\n")) {
+                existing += "\n";
+            }
+            if(existing.index_of("keys/private*") == -1) {
+                FileUtils.set_contents(".gitignore", existing + gitignore);
+            }
+        }
+        else {
+            FileUtils.set_contents(".gitignore", gitignore);
+        }
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return e.code;
+    }
+
+    printerr("Repository initialised, add packages with `usm repository add <package.usmc>` then publish them with `usm repository publish <path>`\n");
+    return 0;
+}
+
+private int repository_add(string package_path) {
+    var root = repository_find_root();
+    if(root == null) {
+        printerr("No repository root found (a directory containing a *.usmr file, \"keys/\" and \"public/\" at or above the current directory)\n");
+        return 1;
+    }
+
+    uint8[] public_key;
+    uint8[] private_key;
+    if(!repository_load_keys(root, out public_key, out private_key)) {
+        return 1;
+    }
+
+    var filename = Path.get_basename(package_path);
+    if(!filename.has_suffix(".usmc")) {
+        printerr(@"\"$filename\" is not a .usmc package\n");
+        return 1;
+    }
+
+    Usm.Manifest manifest;
+    Invercargill.BinaryData checksum;
+    try {
+        manifest = new Usm.Manifest.from_package(package_path);
+        checksum = calculate_file_checksum(package_path);
+    }
+    catch(Error e) {
+        printerr(@"Invalid package \"$package_path\": $(e.message)\n");
+        return 1;
+    }
+
+    var public_dir = Path.build_filename(root, "public");
+    try {
+        foreach(var file in Iterate.directory(public_dir)) {
+            if(!file.has_suffix(".usmc")) {
+                continue;
+            }
+
+            var existing_path = Path.build_filename(public_dir, file);
+            var existing = new Usm.Manifest.from_package(existing_path);
+            var same_package = existing.name == manifest.name && existing.version.equals(manifest.version);
+            if(!same_package && file != filename) {
+                continue;
+            }
+
+            if(calculate_file_checksum(existing_path).equals(checksum)) {
+                printerr(@"Package \"$(manifest.name)-$(manifest.version)\" is already present as \"$file\", nothing to add\n");
+                return repository_rebuild_listing(root, public_key, private_key);
+            }
+
+            printerr(@"\"$file\" already exists with different content, remove it first with `usm repository remove`\n");
+            return 1;
+        }
+
+        File.new_for_path(package_path).copy(File.new_for_path(Path.build_filename(public_dir, filename)), FileCopyFlags.OVERWRITE);
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return e.code;
+    }
+
+    printerr(@"Added \"$(manifest.name)-$(manifest.version)\" as \"$filename\"\n");
+    return repository_rebuild_listing(root, public_key, private_key);
+}
+
+private int repository_remove(string selector) {
+    var root = repository_find_root();
+    if(root == null) {
+        printerr("No repository root found (a directory containing a *.usmr file, \"keys/\" and \"public/\" at or above the current directory)\n");
+        return 1;
+    }
+
+    uint8[] public_key;
+    uint8[] private_key;
+    if(!repository_load_keys(root, out public_key, out private_key)) {
+        return 1;
+    }
+
+    var public_dir = Path.build_filename(root, "public");
+    var matches = new Vector<string>();
+
+    try {
+        var filename = selector.has_suffix(".usmc") ? selector : @"$selector.usmc";
+        if(FileUtils.test(Path.build_filename(public_dir, filename), FileTest.EXISTS)) {
+            matches.add(filename);
+        }
+        else {
+            foreach(var file in Iterate.directory(public_dir)) {
+                if(file.has_suffix(".usmc") && file.has_prefix(@"$selector-")) {
+                    matches.add(file);
+                }
+            }
+        }
+
+        if(matches.length == 0) {
+            printerr(@"No package matching \"$selector\" found in \"public/\"\n");
+            return 1;
+        }
+        if(matches.length > 1) {
+            printerr(@"\"$selector\" matches multiple packages, specify an exact filename:\n");
+            foreach(var file in matches) {
+                printerr(@"\t$file\n");
+            }
+            return 1;
+        }
+
+        var match = matches.first_or_default();
+        File.new_for_path(Path.build_filename(public_dir, match)).delete();
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return e.code;
+    }
+
+    printerr(@"Removed \"$(matches.first_or_default())\"\n");
+    return repository_rebuild_listing(root, public_key, private_key);
+}
+
+private int repository_list(string? repository_file) {
+    try {
+        Usm.Repository repository;
+        Usm.RepositoryListing listing;
+
+        if(repository_file != null) {
+            repository = new Usm.Repository.from_file(repository_file);
+            var stream = File.new_for_uri(repository.url + "/PACKAGES.usml").read();
+            listing = new Usm.RepositoryListing.from_stream(new DataInputStream(stream));
+        }
+        else {
+            var root = repository_find_root();
+            if(root == null) {
+                printerr("No repository root found (a directory containing a *.usmr file, \"keys/\" and \"public/\" at or above the current directory)\n");
+                return 1;
+            }
+
+            var repo_file = repository_find_repo_file(root);
+            if(repo_file == null) {
+                printerr("No *.usmr file found in the repository root\n");
+                return 1;
+            }
+            repository = new Usm.Repository.from_file(repo_file);
+
+            var listing_path = Path.build_filename(root, "public", "PACKAGES.usml");
+            if(!FileUtils.test(listing_path, FileTest.EXISTS)) {
+                printerr("No PACKAGES.usml in \"public/\", add a package with `usm repository add` or run `usm repository build-list` first\n");
+                return 1;
+            }
+            listing = new Usm.RepositoryListing.from_stream(new DataInputStream(File.new_for_path(listing_path).read()));
+        }
+
+        if(!listing.valid_signature(repository.key)) {
+            printerr(@"Package listing signature verification failed for \"$(repository.name)\"\n");
+            return 1;
+        }
+
+        foreach(var entry in listing.entries) {
+            print(@"$(entry.manifest.name) $(entry.manifest.version.to_string()) $(entry.manifest.summary)\n");
+        }
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return e.code;
+    }
+
+    return 0;
+}
+
+private int repository_publish(string destination_path) {
+    var root = repository_find_root();
+    if(root == null) {
+        printerr("No repository root found (a directory containing a *.usmr file, \"keys/\" and \"public/\" at or above the current directory)\n");
+        return 1;
+    }
+
+    var repo_file = repository_find_repo_file(root);
+    if(repo_file == null) {
+        printerr("No *.usmr file found in the repository root\n");
+        return 1;
+    }
+
+    var public_dir = Path.build_filename(root, "public");
+    var listing_path = Path.build_filename(public_dir, "PACKAGES.usml");
+    if(!FileUtils.test(listing_path, FileTest.EXISTS)) {
+        printerr("No PACKAGES.usml in \"public/\", add a package with `usm repository add` or run `usm repository build-list` first\n");
+        return 1;
+    }
+
+    try {
+        var destination = File.new_for_path(destination_path);
+        if(!destination.query_exists()) {
+            destination.make_directory_with_parents();
+        }
+
+        File.new_for_path(repo_file).copy(destination.get_child(Path.get_basename(repo_file)), FileCopyFlags.OVERWRITE);
+        printerr(@"Published \"$(Path.get_basename(repo_file))\"\n");
+
+        File.new_for_path(listing_path).copy(destination.get_child("PACKAGES.usml"), FileCopyFlags.OVERWRITE);
+        printerr("Published \"PACKAGES.usml\"\n");
+
+        foreach(var file in Iterate.directory(public_dir)) {
+            if(!file.has_suffix(".usmc")) {
+                continue;
+            }
+
+            var source_path = Path.build_filename(public_dir, file);
+            var destination_file = destination.get_child(file);
+            if(destination_file.query_exists()
+                && repository_modification_time(destination_file.get_path()) >= repository_modification_time(source_path)) {
+                printerr(@"Skipped \"$file\" (up to date)\n");
+                continue;
+            }
+
+            File.new_for_path(source_path).copy(destination_file, FileCopyFlags.OVERWRITE);
+            printerr(@"Published \"$file\"\n");
+        }
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return e.code;
+    }
+
+    return 0;
+}
+
 private int repository_build_list() {
+    var root = repository_find_root();
+    if(root == null) {
+        root = Environment.get_current_dir();
+    }
+
     uint8[] public_key;
     uint8[] private_key;
-    FileUtils.get_data("public-key", out public_key);
-    FileUtils.get_data("private-key", out private_key);
+    if(!repository_load_keys(root, out public_key, out private_key)) {
+        return 1;
+    }
 
-    var output_stream = new DataOutputStream(File.new_build_filename("public", "PACKAGES.usml").replace(null, false, FileCreateFlags.REPLACE_DESTINATION));
-    var checksum = new Checksum(ChecksumType.SHA512);
-    var mapper = Usm.RepositoryListingEntry.get_mapper();
+    return repository_rebuild_listing(root, public_key, private_key);
+}
 
-    foreach(var file in Iterate.directory("public")) {
-        var path = Path.build_filename("public", file);
-        if(!path.has_suffix(".usmc")) {
-            continue;
+/**
+ * Serialises `element` as compact JSON (no indentation), suitable for the
+ * one-line-per-entry PACKAGES.usml format.
+ */
+private string compact_json(InvercargillJson.JsonElement? element) {
+    if(element == null) {
+        return "";
+    }
+    var output = new MemoryOutputStream(null);
+    try {
+        ((!)element).write_to_stream(output);
+        output.close();
+    }
+    catch(Error e) {
+        return "";
+    }
+    return (string)((!)((MemoryOutputStream)((!)output)).steal_as_bytes().get_data());
+}
+
+
+/**
+ * Locates the nearest repository root: the first directory at or above the
+ * current directory holding a `*.usmr` file, a `keys/` directory and a
+ * `public/` directory.
+ */
+private string? repository_find_root() {
+    var directory = Environment.get_current_dir();
+    while(true) {
+        if(repository_find_repo_file(directory) != null
+            && FileUtils.test(Path.build_filename(directory, "keys"), FileTest.IS_DIR)
+            && FileUtils.test(Path.build_filename(directory, "public"), FileTest.IS_DIR)) {
+            return directory;
         }
 
-        var manifest = new Usm.Manifest.from_package(path);
+        var parent = Path.get_dirname(directory);
+        if(parent == directory) {
+            return null;
+        }
+        directory = parent;
+    }
+}
 
-        var entry = new Usm.RepositoryListingEntry() {
-            path = file,
-            manifest = manifest,
-            sha512sum = calculate_file_checksum(path)
-        };
+/**
+ * Finds the first `*.usmr` file directly inside `root`, or null when there is
+ * none.
+ */
+private string? repository_find_repo_file(string root) {
+    try {
+        foreach(var file in Iterate.directory(root)) {
+            if(file.has_suffix(".usmr")) {
+                return Path.build_filename(root, file);
+            }
+        }
+    }
+    catch(Error e) {
+        return null;
+    }
+    return null;
+}
+
+/**
+ * Loads the signing keys from `keys/public-key` and `keys/private-key` in the
+ * repository root, falling back to the legacy loose `public-key` and
+ * `private-key` files there.
+ */
+private bool repository_load_keys(string root, out uint8[] public_key, out uint8[] private_key) {
+    public_key = new uint8[0];
+    private_key = new uint8[0];
 
-        var entry_properties = new InvercargillJson.JsonElement.from_properties(mapper.map_from(entry)).as<InvercargillJson.JsonObject>();
-        entry_properties.set("type", new InvercargillJson.JsonElement.from_element(new NativeElement<string>("usmc")));
-        var serialised = entry_properties.as_element().stringify(false);
-        checksum.update(serialised.data, serialised.data.length);
-        output_stream.put_string(@"$serialised\n");
+    var key_dir = Path.build_filename(root, "keys");
+    if(repository_read_key(Path.build_filename(key_dir, "public-key"), out public_key)
+        && repository_read_key(Path.build_filename(key_dir, "private-key"), out private_key)) {
+        return true;
     }
 
-    var buffer = new uint8[ChecksumType.SHA512.get_length()];
-    size_t buffer_length = buffer.length;
-    checksum.get_digest(buffer, ref buffer_length);
-    var signed = Sodium.Asymmetric.Signing.sign(buffer, private_key);
+    if(repository_read_key(Path.build_filename(root, "public-key"), out public_key)
+        && repository_read_key(Path.build_filename(root, "private-key"), out private_key)) {
+        return true;
+    }
+
+    printerr("No signing keys found in \"keys/\" (or as legacy \"public-key\"/\"private-key\") within the repository root\n");
+    return false;
+}
 
-    var signature = new Usm.RepositoryListingSignature() {
-        key = Wrap.byte_array(public_key),
-        signature = Wrap.byte_array(signed)
-    };
-    var signature_entry = new PropertyDictionary();
-    signature_entry.set("type", new NativeElement<string>("signatures"));
-    signature_entry.set("signatures", new NativeElement<Enumerable<Element>>(Iterate.single(Usm.RepositoryListingSignature.get_mapper().map_from(signature)).to_elements()));
+private bool repository_read_key(string path, out uint8[] key) {
+    key = new uint8[0];
+    if(!FileUtils.test(path, FileTest.EXISTS)) {
+        return false;
+    }
+    try {
+        FileUtils.get_data(path, out key);
+        return true;
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return false;
+    }
+}
+
+private void repository_generate_keypair(out uint8[] public_key, out uint8[] private_key) {
+    public_key = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
+    private_key = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
+    Sodium.Asymmetric.Signing.generate_keypair(public_key, private_key);
+}
+
+/**
+ * Rebuilds and re-signs `public/PACKAGES.usml` for the repository rooted at
+ * `root`, the shared implementation behind `add`, `remove` and `build-list`.
+ */
+private int repository_rebuild_listing(string root, uint8[] public_key, uint8[] private_key) {
+    try {
+        var public_dir = Path.build_filename(root, "public");
+        var output_stream = new DataOutputStream(File.new_build_filename(public_dir, "PACKAGES.usml").replace(null, false, FileCreateFlags.REPLACE_DESTINATION));
+        var checksum = new Checksum(ChecksumType.SHA512);
+        var mapper = Usm.RepositoryListingEntry.get_mapper();
+
+        foreach(var file in Iterate.directory(public_dir)) {
+            var path = Path.build_filename(public_dir, file);
+            if(!path.has_suffix(".usmc")) {
+                continue;
+            }
+
+            var manifest = new Usm.Manifest.from_package(path);
+
+            var entry = new Usm.RepositoryListingEntry() {
+                path = file,
+                manifest = manifest,
+                sha512sum = calculate_file_checksum(path)
+            };
+
+            var entry_properties = new InvercargillJson.JsonElement.from_properties(mapper.map_from(entry)).as<InvercargillJson.JsonObject>();
+            entry_properties.set("type", new InvercargillJson.JsonElement.from_element(new NativeElement<string>("usmc")));
+            var serialised = compact_json(entry_properties.as_element());
+            checksum.update(serialised.data, serialised.data.length);
+            output_stream.put_string(@"$serialised\n");
+        }
 
-    var serialised = new InvercargillJson.JsonElement.from_properties(signature_entry).stringify(false);
-    output_stream.put_string(serialised);
+        var buffer = new uint8[ChecksumType.SHA512.get_length()];
+        size_t buffer_length = buffer.length;
+        checksum.get_digest(buffer, ref buffer_length);
+        var signed = Sodium.Asymmetric.Signing.sign(buffer, private_key);
 
-    output_stream.close();
+        var signature = new Usm.RepositoryListingSignature() {
+            key = Wrap.byte_array(public_key),
+            signature = Wrap.byte_array(signed)
+        };
+        var signature_entry = new PropertyDictionary();
+        signature_entry.set("type", new NativeElement<string>("signatures"));
+        signature_entry.set("signatures", new NativeElement<Enumerable<Element>>(Iterate.single(Usm.RepositoryListingSignature.get_mapper().map_from(signature)).to_elements()));
+
+        var serialised = compact_json(new InvercargillJson.JsonElement.from_properties(signature_entry));
+        output_stream.put_string(serialised);
+
+        output_stream.close();
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return e.code;
+    }
     return 0;
 }
 
+private uint64 repository_modification_time(string path) throws Error {
+    return File.new_for_path(path).query_info(FileAttribute.TIME_MODIFIED, FileQueryInfoFlags.NONE).get_attribute_uint64(FileAttribute.TIME_MODIFIED);
+}
+
+/**
+ * Calculates the SHA-512 checksum of an open stream, used to deep-verify
+ * packages fetched from a repository URI.
+ */
+private Invercargill.BinaryData repository_stream_checksum(InputStream stream) throws Error {
+    var checksum = new Checksum(ChecksumType.SHA512);
+
+    var buffer = new uint8[64 * 1024];
+    while(true) {
+        var read = stream.read(buffer);
+        if(read <= 0) {
+            break;
+        }
+        checksum.update(buffer, read);
+    }
+
+    var checksum_bytes = new uint8[ChecksumType.SHA512.get_length()];
+    size_t size = checksum_bytes.length;
+    checksum.get_digest(checksum_bytes, ref size);
+
+    return Invercargill.Wrap.byte_array(checksum_bytes);
+}
+
 
 private int repository_verify(string repository_file) {
     var json = new InvercargillJson.JsonElement.from_file(repository_file);
@@ -121,10 +580,37 @@ private int repository_verify(string repository_file) {
     var stream = File.new_for_uri(repository.url + "/PACKAGES.usml").read();
     var listing = new Usm.RepositoryListing.from_stream(new DataInputStream(stream));
 
-    if(listing.valid_signature(repository.key)) {
-        printerr(@"Valid signature for \"$(repository.name)\" package listing.\n");
-        return 0;
+    if(!listing.valid_signature(repository.key)) {
+        printerr("Invalid signature.\n");
+        return 1;
     }
-    printerr("Invalid signature.\n");
-    return 1;
-}
+    printerr(@"Valid signature for \"$(repository.name)\" package listing.\n");
+
+    var verified = 0;
+    var failures = 0;
+    foreach(var entry in listing.entries) {
+        var package_uri = repository.url.has_suffix("/") ? repository.url + entry.path : repository.url + "/" + entry.path;
+        try {
+            var package_stream = File.new_for_uri(package_uri).read();
+            if(repository_stream_checksum(package_stream).equals(entry.sha512sum)) {
+                printerr(@"Verified \"$(entry.path)\"\n");
+                verified++;
+            }
+            else {
+                printerr(@"Checksum mismatch for \"$(entry.path)\"\n");
+                failures++;
+            }
+        }
+        catch(Error e) {
+            printerr(@"Could not verify \"$(entry.path)\": $(e.message)\n");
+            failures++;
+        }
+    }
+
+    if(failures > 0) {
+        printerr(@"$failures of $(verified + failures) package(s) failed verification\n");
+        return 1;
+    }
+    printerr(@"Deep-verified $verified package(s) in \"$(repository.name)\"\n");
+    return 0;
+}

+ 27 - 30
src/cli/Scaffold.vala

@@ -59,63 +59,60 @@ private Usm.Manifest create_manifest(string name, string template, HashSet<strin
     
     // Dependencies
     manifest.dependencies = new Usm.Dependencies();
-    manifest.dependencies.runtime = new HashSet<Usm.ResourceRef>();
-    manifest.dependencies.build = new HashSet<Usm.ResourceRef>();
-    manifest.dependencies.manage = new HashSet<Usm.ResourceRef>();
-    
+
     // Executables
     manifest.executables = new Usm.Executables();
     manifest.flags = new HashSet<Usm.ManifestFlag>();
-    
+
     // Template-specific configurations
     switch(template) {
         case "basic":
             manifest.executables.build = "usm-scripts/build.sh";
             break;
-            
+
         case "makefile":
             manifest.executables.build = "usm-scripts/build.sh";
             manifest.executables.install = "usm-scripts/install.sh";
-            manifest.dependencies.manage.add(new Usm.ResourceRef("bin:bash"));
-            manifest.dependencies.build.add(new Usm.ResourceRef("bin:make"));
+            manifest.dependencies.manage.required.add(new Usm.ResourceRef("bin:bash"));
+            manifest.dependencies.build.required.add(new Usm.ResourceRef("bin:make"));
             break;
-            
+
         case "meson":
             manifest.executables.build = "usm-scripts/build.sh";
             manifest.executables.install = "usm-scripts/install.sh";
-            manifest.dependencies.manage.add(new Usm.ResourceRef("bin:bash"));
-            manifest.dependencies.build.add(new Usm.ResourceRef("bin:meson"));
-            manifest.dependencies.build.add(new Usm.ResourceRef("bin:ninja"));
+            manifest.dependencies.manage.required.add(new Usm.ResourceRef("bin:bash"));
+            manifest.dependencies.build.required.add(new Usm.ResourceRef("bin:meson"));
+            manifest.dependencies.build.required.add(new Usm.ResourceRef("bin:ninja"));
             manifest.flags.add(Usm.ManifestFlag.NINJA_STYLE_PROGRESS);
             break;
-            
+
         default:
             throw new Error(Quark.from_string("scaffold"), 1, @"Unknown template '$template'");
     }
-    
+
     // Apply attributes
     if(attributes.contains("vala")) {
-        manifest.dependencies.build.add(new Usm.ResourceRef("bin:valac"));
-        manifest.dependencies.build.add(new Usm.ResourceRef("pc:glib-2.0.pc"));
-        manifest.dependencies.build.add(new Usm.ResourceRef("pc:gobject-2.0.pc"));
-        manifest.dependencies.runtime.add(new Usm.ResourceRef("lib:libc.so.6"));
-        manifest.dependencies.runtime.add(new Usm.ResourceRef("lib:libglib-2.0.so"));
-        manifest.dependencies.runtime.add(new Usm.ResourceRef("lib:libgobject-2.0.so"));
+        manifest.dependencies.build.required.add(new Usm.ResourceRef("bin:valac"));
+        manifest.dependencies.build.required.add(new Usm.ResourceRef("pc:glib-2.0.pc"));
+        manifest.dependencies.build.required.add(new Usm.ResourceRef("pc:gobject-2.0.pc"));
+        manifest.dependencies.runtime.required.add(new Usm.ResourceRef("lib:libc.so.6"));
+        manifest.dependencies.runtime.required.add(new Usm.ResourceRef("lib:libglib-2.0.so"));
+        manifest.dependencies.runtime.required.add(new Usm.ResourceRef("lib:libgobject-2.0.so"));
     }
-    
+
     if(attributes.contains("c")) {
-        manifest.dependencies.build.add(new Usm.ResourceRef("bin:gcc"));
-        manifest.dependencies.runtime.add(new Usm.ResourceRef("lib:libc.so.6"));
+        manifest.dependencies.build.required.add(new Usm.ResourceRef("bin:gcc"));
+        manifest.dependencies.runtime.required.add(new Usm.ResourceRef("lib:libc.so.6"));
     }
-    
+
     if(attributes.contains("acquire")) {
-        manifest.dependencies.acquire = new HashSet<Usm.ResourceRef>();
-        manifest.dependencies.acquire.add(new Usm.ResourceRef("bin:bash"));
-        manifest.dependencies.acquire.add(new Usm.ResourceRef("bin:wget"));
-        manifest.dependencies.acquire.add(new Usm.ResourceRef("bin:tar"));
+        manifest.dependencies.acquire = new Usm.DependencyPhase();
+        manifest.dependencies.acquire.required.add(new Usm.ResourceRef("bin:bash"));
+        manifest.dependencies.acquire.required.add(new Usm.ResourceRef("bin:wget"));
+        manifest.dependencies.acquire.required.add(new Usm.ResourceRef("bin:tar"));
         manifest.executables.acquire = "usm-scripts/acquire.sh";
     }
-    
+
     return manifest;
 }
 
@@ -125,7 +122,7 @@ private void write_manifest_file(Usm.Manifest manifest) throws Error {
     var json_element = new InvercargillJson.JsonElement.from_properties(properties);
     
     // Create pretty JSON string
-    var json_string = json_element.stringify(true);
+    var json_string = json_element.stringify_pretty();
     
     // Write to file manually
     var file = File.new_for_path("MANIFEST.usm");

+ 1 - 0
src/cli/meson.build

@@ -3,6 +3,7 @@ sources = files('Cli.vala')
 sources += files('Manifest.vala')
 sources += files('Repository.vala')
 sources += files('Install.vala')
+sources += files('Deploy.vala')
 sources += files('Scaffold.vala')
 sources += files('GenConfig.vala')
 

+ 55 - 3
src/lib/Configuration.vala

@@ -1,13 +1,25 @@
 using Invercargill;
+using Invercargill.DataStructures;
 using Invercargill.Mapping;
 
 namespace Usm {
 
+    /**
+     * Errors raised while reading a {@link Configuration}.
+     */
+    public errordomain ConfigurationError {
+        /**
+         * The "system_package_manager" section defined only one of its required argv arrays.
+         */
+        INCOMPLETE_SYSTEM_PACKAGE_MANAGER
+    }
+
     public class Configuration {
 
         public bool is_managed { get; set; }
         public ManagedConfiguration? managed_config { get; set; }
         public Paths? paths { get; set; }
+        public SystemPackageManagerConfig? system_package_manager { get; set; }
 
         public static PropertyMapper<Configuration> get_mapper() {
             return PropertyMapper.build_for<Configuration>(cfg => {
@@ -18,6 +30,9 @@ namespace Usm {
                 cfg.map_properties_with<Paths>("paths", o => o.paths, (o, v) => o.paths = v, Paths.get_mapper())
                     .undefined_when(o => o.paths == null)
                     .when_undefined(o => o.paths = new Paths.usm_environ());
+                cfg.map_properties_with<SystemPackageManagerConfig>("system_package_manager", o => o.system_package_manager, (o, v) => o.system_package_manager = v, SystemPackageManagerConfig.get_mapper())
+                    .undefined_when(o => o.system_package_manager == null)
+                    .when_undefined(o => o.system_package_manager = null);
                 cfg.set_constructor(() => new Configuration());
             });
         }
@@ -25,6 +40,20 @@ namespace Usm {
         public Configuration.from_paths(Paths paths) throws Error {
             var element = new InvercargillJson.JsonElement.from_file(Path.build_filename(paths.usm_config_dir, "usm.config"));
             get_mapper().map_into(this, element.as<Invercargill.Properties>());
+            validate();
+        }
+
+        private void validate() throws Error {
+            if(system_package_manager == null) {
+                return;
+            }
+            var query_defined = system_package_manager.query != null;
+            var install_defined = system_package_manager.install != null;
+            if(query_defined != install_defined) {
+                throw new ConfigurationError.INCOMPLETE_SYSTEM_PACKAGE_MANAGER(
+                    @"The \"system_package_manager\" section of usm.config must define both \"query\" and \"install\" argv arrays, but only \"$(query_defined ? "query" : "install")\" was found."
+                );
+            }
         }
 
         public static bool check_managed(Paths paths) {
@@ -53,9 +82,32 @@ namespace Usm {
                 cfg.map<string>("state_path", o => o.state_path, (o, v) => o.state_path = v);
                 cfg.set_constructor(() => new ManagedConfiguration());
             });
-        }     
+        }
     }
 
-    
+    /**
+     * argv arrays used to invoke the configured system package manager helper.
+     *
+     * Values are plain argv arrays (no shell parsing); see {@link SystemPackageManager}
+     * for the helper contracts. Both {@link query} and {@link install} must be defined
+     * together or omitted entirely, which {@link Configuration} validates on load.
+     */
+    public class SystemPackageManagerConfig {
+
+        public Vector<string>? query { get; set; }
+        public Vector<string>? install { get; set; }
+
+        public static PropertyMapper<SystemPackageManagerConfig> get_mapper() {
+            return PropertyMapper.build_for<SystemPackageManagerConfig>(cfg => {
+                cfg.map_many<string>("query", o => o.query ?? new Vector<string>(), (o, v) => o.query = v.to_vector())
+                    .undefined_when(o => o.query == null)
+                    .when_undefined(o => o.query = null);
+                cfg.map_many<string>("install", o => o.install ?? new Vector<string>(), (o, v) => o.install = v.to_vector())
+                    .undefined_when(o => o.install == null)
+                    .when_undefined(o => o.install = null);
+                cfg.set_constructor(() => new SystemPackageManagerConfig());
+            });
+        }
+    }
 
-}
+}

+ 209 - 9
src/lib/Dependencies.vala

@@ -1,24 +1,224 @@
 using Invercargill;
+using Invercargill.DataStructures;
 using Invercargill.Mapping;
 
 namespace Usm {
 
+    /**
+     * One phase of a manifest's "depends" section.
+     *
+     * A phase holds EITHER a flat set of required refs (the legacy form: every
+     * ref is required) OR an ordered vector of candidate groups (the nested
+     * form: groups are exhaustive, self-contained listings and exactly one is
+     * chosen at resolution time; common refs repeat across groups). The two
+     * forms cannot be mixed within one phase — {@link DependencyPhase.from_element}
+     * rejects that with a {@link ManifestError} naming the phase.
+     *
+     * ```json
+     * { "runtime": ["bin:bash"] }
+     * { "runtime": [["bin:python3", "pc:python3.pc"], ["bin:python", "pc:python.pc"]] }
+     * ```
+     */
+    public class DependencyPhase : Object {
+
+        /** The required refs of the flat form; empty when this phase holds candidate groups. */
+        public Set<ResourceRef> required { get; set; }
+
+        /** The candidate groups of the nested form in manifest order; null for the flat form. */
+        public Vector<Set<ResourceRef>>? candidate_groups { get; set; }
+
+        construct {
+            required = new HashSet<ResourceRef>();
+        }
+
+        public DependencyPhase() {}
+
+        /** Whether this phase holds candidate groups rather than a flat required set. */
+        public bool is_grouped {
+            get {
+                return candidate_groups != null;
+            }
+        }
+
+        /**
+         * Every ref mentioned by this phase: {@link required} for the flat
+         * form, or the union across all candidate groups.
+         */
+        public Enumerable<ResourceRef> all_refs() {
+            if(candidate_groups == null) {
+                return required;
+            }
+            var all = new HashSet<ResourceRef>();
+            foreach(var group in candidate_groups) {
+                all.union_with(group);
+            }
+            return all;
+        }
+
+        /** The flat-form refs deterministically ordered by their string form. */
+        public Vector<ResourceRef> ordered_required() {
+            return required.sort((a, b) => a.to_string().collate(b.to_string())).to_vector();
+        }
+
+        /** The candidate groups in manifest order, each member deterministically ordered by its string form. */
+        public Vector<Vector<ResourceRef>> ordered_groups() {
+            var ordered = new Vector<Vector<ResourceRef>>();
+            foreach(var group in candidate_groups ?? new Vector<Set<ResourceRef>>()) {
+                ordered.add(group.sort((a, b) => a.to_string().collate(b.to_string())).to_vector());
+            }
+            return ordered;
+        }
+
+        /** Every ref mentioned by this phase, deterministically ordered by its string form. */
+        public Vector<ResourceRef> ordered_all_refs() {
+            return all_refs().sort((a, b) => a.to_string().collate(b.to_string())).to_vector();
+        }
+
+        /**
+         * Whether the phase's requirements hold: every flat ref passes
+         * {@link satisfied}, or — for candidate groups — at least one group
+         * has every member passing it.
+         */
+        public bool is_satisfied(PredicateDelegate<ResourceRef> satisfied) {
+            if(candidate_groups == null) {
+                return required.all(satisfied);
+            }
+            foreach(var group in candidate_groups) {
+                if(group.all(satisfied)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /**
+         * Serialises the phase to a JSON-ready element: an array of ref
+         * strings for the flat form, an array of arrays for candidate groups.
+         */
+        public Element to_element() {
+            if(candidate_groups == null) {
+                return new NativeElement<Elements>(ordered_required().select<string>(r => r.to_string()).to_elements());
+            }
+            var groups = new Series<Element>();
+            foreach(var group in ordered_groups()) {
+                groups.add(new NativeElement<Elements>(group.select<string>(r => r.to_string()).to_elements()));
+            }
+            return new NativeElement<Elements>(groups.to_elements());
+        }
+
+        /**
+         * Parses one phase value. Accepts a flat array of resource refs or an
+         * array of non-empty candidate groups; rejects mixed forms, nesting
+         * deeper than one level and non-string entries with a
+         * {@link ManifestError.INVALID_DEPENDENCIES} naming {@link phase_name}.
+         * An empty array parses as a flat phase with no requirements.
+         */
+        public static DependencyPhase from_element(Element element, string phase_name) throws Error {
+            if(element.is_null() || !element.assignable_to<Elements>()) {
+                throw new ManifestError.INVALID_DEPENDENCIES(
+                    @"The \"$phase_name\" dependency phase must be an array of resource refs or an array of candidate groups."
+                );
+            }
+
+            var phase = new DependencyPhase();
+            var saw_flat = false;
+            var saw_group = false;
+
+            foreach(var item in element.as<Elements>()) {
+                if(item.is_null()) {
+                    throw new ManifestError.INVALID_DEPENDENCIES(
+                        @"The \"$phase_name\" dependency phase contains a null entry; expected a resource ref or a candidate group."
+                    );
+                }
+                if(item.assignable_to<Elements>()) {
+                    saw_group = true;
+                    var group = new HashSet<ResourceRef>();
+                    foreach(var inner in item.as<Elements>()) {
+                        if(inner.assignable_to<Elements>()) {
+                            throw new ManifestError.INVALID_DEPENDENCIES(
+                                @"The \"$phase_name\" dependency phase nests arrays more than one level deep; candidate groups must hold resource refs only."
+                            );
+                        }
+                        group.add(parse_ref(inner, phase_name));
+                    }
+                    if(group.count() == 0) {
+                        throw new ManifestError.EMPTY_DEPENDENCY_GROUP(
+                            @"The \"$phase_name\" dependency phase contains an empty candidate group; groups must list at least one resource ref."
+                        );
+                    }
+                    if(phase.candidate_groups == null) {
+                        phase.candidate_groups = new Vector<Set<ResourceRef>>();
+                    }
+                    phase.candidate_groups.add(group);
+                }
+                else if(item.assignable_to<string>()) {
+                    saw_flat = true;
+                    phase.required.add(parse_ref(item, phase_name));
+                }
+                else {
+                    throw new ManifestError.INVALID_DEPENDENCIES(
+                        @"The \"$phase_name\" dependency phase must contain only resource refs or arrays of resource refs."
+                    );
+                }
+            }
+
+            if(saw_flat && saw_group) {
+                throw new ManifestError.INVALID_DEPENDENCIES(
+                    @"The \"$phase_name\" dependency phase mixes flat resource refs with candidate groups; use one form or the other."
+                );
+            }
+
+            return phase;
+        }
+
+        private static ResourceRef parse_ref(Element item, string phase_name) throws Error {
+            string text = null;
+            try {
+                text = item.as<string>();
+            }
+            catch(Error e) {
+                throw new ManifestError.INVALID_DEPENDENCIES(
+                    @"The \"$phase_name\" dependency phase must contain only resource refs or arrays of resource refs."
+                );
+            }
+            try {
+                return new ResourceRef(text);
+            }
+            catch(Error e) {
+                throw new ManifestError.INVALID_DEPENDENCIES(
+                    @"Invalid resource ref \"$text\" in the \"$phase_name\" dependency phase: $(e.message)"
+                );
+            }
+        }
+    }
+
+    /**
+     * The "depends" section of a manifest: one {@link DependencyPhase} per
+     * lifecycle phase. Every phase accepts the flat or the candidate-group
+     * form; validation errors name the offending phase.
+     */
     public class Dependencies {
-        public Set<ResourceRef> runtime { get; set; }
-        public Set<ResourceRef> build { get; set; }
-        public Set<ResourceRef> manage { get; set; }
-        public Set<ResourceRef>? acquire { get; set; }
+        public DependencyPhase runtime { get; set; }
+        public DependencyPhase build { get; set; }
+        public DependencyPhase manage { get; set; }
+        public DependencyPhase? acquire { get; set; }
+
+        public Dependencies() {
+            runtime = new DependencyPhase();
+            build = new DependencyPhase();
+            manage = new DependencyPhase();
+        }
 
         public static PropertyMapper<Dependencies> get_mapper() {
             return PropertyMapper.build_for<Dependencies>(cfg => {
-                cfg.map_many<string>("runtime", o => o.runtime.select<string>(i => i.to_string()), (o, v) => o.runtime = v.attempt_select<ResourceRef>(i => new ResourceRef(i)).to_set());
-                cfg.map_many<string>("build", o => o.build.select<string>(i => i.to_string()), (o, v) => o.build = v.attempt_select<ResourceRef>(i => new ResourceRef(i)).to_set());
-                cfg.map_many<string>("manage", o => o.manage.select<string>(i => i.to_string()), (o, v) => o.manage = v.attempt_select<ResourceRef>(i => new ResourceRef(i)).to_set());
-                cfg.map_many<string>("acquire", o => o.manage.select<string>(i => i.to_string()), (o, v) => o.manage = v.attempt_select<ResourceRef>(i => new ResourceRef(i)).to_set())
+                cfg.map<Element>("runtime", o => o.runtime.to_element(), (o, v) => o.runtime = DependencyPhase.from_element(v, "runtime"));
+                cfg.map<Element>("build", o => o.build.to_element(), (o, v) => o.build = DependencyPhase.from_element(v, "build"));
+                cfg.map<Element>("manage", o => o.manage.to_element(), (o, v) => o.manage = DependencyPhase.from_element(v, "manage"));
+                cfg.map<Element>("acquire", o => o.acquire.to_element(), (o, v) => o.acquire = DependencyPhase.from_element(v, "acquire"))
                     .undefined_when(o => o.acquire == null)
                     .when_undefined(o => o.acquire = null);
                 cfg.set_constructor(() => new Dependencies());
             });
         }
     }
-}
+}

+ 4 - 2
src/lib/Exectuables.vala

@@ -4,7 +4,7 @@ using Invercargill.Mapping;
 namespace Usm {
 
     public class Executables {
-        public string build { get; set; }
+        public string? build { get; set; }
         public string? install { get; set; }
         public string? remove { get; set; }
         public string? rebuild { get; set; }
@@ -14,7 +14,9 @@ namespace Usm {
 
         public static PropertyMapper<Executables> get_mapper() {
             return PropertyMapper.build_for<Executables>(cfg => {
-                cfg.map<string>("build", o => o.build, (o, v) => o.build = v);
+                cfg.map<string?>("build", o => o.build, (o, v) => o.build = v)
+                    .undefined_when(o => o.build == null)
+                    .when_undefined(o => o.build = null);
                 cfg.map<string?>("rebuild", o => o.rebuild, (o, v) => o.rebuild = v)
                     .undefined_when(o => o.rebuild == null)
                     .when_undefined(o => o.rebuild = null);

+ 191 - 0
src/lib/Ignore/UsmIgnore.vala

@@ -0,0 +1,191 @@
+using Invercargill.DataStructures;
+
+namespace Usm {
+
+    /**
+     * Matcher for a package root's `.usmignore` file, consulted by
+     * `usm manifest package` to prune files and directories from the
+     * produced archive.
+     *
+     * Semantics (there is no negation: `!` is a literal pattern character,
+     * it cannot re-include a previously ignored path):
+     *
+     * - Blank lines and lines starting with `#` are skipped.
+     * - A pattern containing `/` matches the FULL path relative to the
+     *   package root; a pattern without `/` matches any path suffix
+     *   (equivalently, the basename at any depth).
+     * - A trailing `/` marks a directory-only pattern: it matches the
+     *   directory itself and everything beneath it.
+     * - `*` and `?` are wildcards and never match `/`.
+     * - The root `.usmignore` and `MANIFEST.usm` files can never be ignored.
+     * - The `.git` directory is always ignored, with or without a
+     *   `.usmignore` file; when no `.usmignore` exists it is the only
+     *   default ignore.
+     *
+     * ```
+     * # .usmignore
+     * builddir/
+     * *.sqlite
+     * docs/generated/
+     * ```
+     */
+    public class UsmIgnore : Object {
+
+        private Vector<UsmIgnore.Pattern> patterns = new Vector<UsmIgnore.Pattern>();
+
+        /**
+         * Creates the default matcher: only the `.git` directory (and
+         * everything beneath it) is ignored.
+         */
+        public UsmIgnore() {
+            patterns.add(new UsmIgnore.Pattern(".git", true));
+        }
+
+        /**
+         * Loads the `.usmignore` file at the root of {@link root_path},
+         * falling back to the {@link UsmIgnore} defaults when the file is
+         * absent.
+         *
+         * Throws when the file exists but cannot be read.
+         */
+        public UsmIgnore.from_root(string root_path) throws Error {
+            this();
+
+            var path = Path.build_filename(root_path, ".usmignore");
+            if(!FileUtils.test(path, FileTest.EXISTS)) {
+                return;
+            }
+
+            string contents;
+            FileUtils.get_contents(path, out contents);
+            foreach(var line in contents.split("\n")) {
+                add_pattern(line);
+            }
+        }
+
+        /**
+         * Adds one pattern line; blank lines and `#` comments are skipped.
+         */
+        public void add_pattern(string line) {
+            var trimmed = line.strip();
+            if(trimmed.length == 0 || trimmed.has_prefix("#")) {
+                return;
+            }
+
+            var directory_only = trimmed.has_suffix("/");
+            var source = directory_only ? trimmed.substring(0, trimmed.length - 1) : trimmed;
+            if(source.length == 0) {
+                return;
+            }
+            patterns.add(new UsmIgnore.Pattern(source, directory_only));
+        }
+
+        /**
+         * Whether the file or directory at {@link relative_path} (relative
+         * to the package root, without a leading `./` or `/`) is ignored.
+         */
+        public bool matches(string relative_path, bool is_directory) {
+            var path = relative_path.has_prefix("./") ? relative_path.substring(2) : relative_path;
+            if(path == ".usmignore" || path == "MANIFEST.usm") {
+                return false;
+            }
+            foreach(var pattern in patterns) {
+                if(pattern.matches(path, is_directory)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /**
+         * Classic two-pointer wildcard match where `*` and `?` never match
+         * the path separator.
+         */
+        private static bool glob_matches(string pattern, string text) {
+            int pattern_index = 0;
+            int text_index = 0;
+            int star_index = -1;
+            int backtrack_index = 0;
+
+            while(text_index < text.length) {
+                char pattern_char = pattern_index < pattern.length ? pattern[pattern_index] : '\0';
+                char text_char = text[text_index];
+                if((pattern_char == '?' && text_char != '/') || pattern_char == text_char) {
+                    pattern_index++;
+                    text_index++;
+                }
+                else if(pattern_char == '*') {
+                    star_index = pattern_index++;
+                    backtrack_index = text_index;
+                }
+                else if(star_index >= 0) {
+                    pattern_index = star_index + 1;
+                    text_index = ++backtrack_index;
+                }
+                else {
+                    return false;
+                }
+            }
+
+            while(pattern_index < pattern.length && pattern[pattern_index] == '*') {
+                pattern_index++;
+            }
+            return pattern_index == pattern.length;
+        }
+
+        /**
+         * One compiled `.usmignore` line. A pattern whose source contains
+         * `/` is anchored to the full relative path, otherwise it matches
+         * any single path segment suffix.
+         */
+        private class Pattern : Object {
+
+            public string source;
+            public bool directory_only;
+            public bool anchored {
+                get {
+                    return source.contains("/");
+                }
+            }
+
+            public Pattern(string source, bool directory_only) {
+                this.source = source;
+                this.directory_only = directory_only;
+            }
+
+            public bool matches(string path, bool is_directory) {
+                if(directory_only) {
+                    if(is_directory && segment_matches(path)) {
+                        return true;
+                    }
+                    return matches_beneath(path);
+                }
+                if(anchored) {
+                    return UsmIgnore.glob_matches(source, path);
+                }
+                return UsmIgnore.glob_matches(source, Path.get_basename(path));
+            }
+
+            private bool segment_matches(string path) {
+                return anchored ? UsmIgnore.glob_matches(source, path)
+                    : UsmIgnore.glob_matches(source, Path.get_basename(path));
+            }
+
+            /**
+             * Everything beneath a directory matching a directory-only
+             * pattern is ignored, whether or not the walker pruned the
+             * directory itself.
+             */
+            private bool matches_beneath(string path) {
+                var separator = path.index_of("/");
+                while(separator != -1) {
+                    if(segment_matches(path.substring(0, separator))) {
+                        return true;
+                    }
+                    separator = path.index_of("/", separator + 1);
+                }
+                return false;
+            }
+        }
+    }
+}

+ 144 - 34
src/lib/Manifest.vala

@@ -15,7 +15,13 @@ namespace Usm {
         INVALID_PACKAGE,
         INVALID_PATH_BASE,
         INVALID_FILE_PATH,
-        INVALID_FLAG
+        INVALID_FLAG,
+        /** A dataPackage manifest defines a lifecycle executable (only acquire is permitted) */
+        DATA_PACKAGE_WITH_EXECUTABLE,
+        /** A "depends" phase is neither a flat array of refs nor an array of candidate groups (mixed forms, over-nested arrays or non-string entries) */
+        INVALID_DEPENDENCIES,
+        /** A candidate group in a "depends" phase is empty */
+        EMPTY_DEPENDENCY_GROUP
     }
 
     public class Manifest {
@@ -36,6 +42,17 @@ namespace Usm {
         public Git? git { get; set; }
         public Properties? extra_properties { get; set; }
 
+        /**
+         * Whether the manifest declares {@link ManifestFlag.DATA_PACKAGE}:
+         * a package with no lifecycle executables whose {@link provides}
+         * are copied directly from the source tree at install time.
+         */
+        public bool is_data_package {
+            get {
+                return flags != null && flags.contains(ManifestFlag.DATA_PACKAGE);
+            }
+        }
+
         public static PropertyMapper<Manifest> get_mapper() {
             return PropertyMapper.build_for<Manifest>(cfg => {
                 cfg.map<string>("name", o => o.name, (o, v) => o.name = v);
@@ -44,7 +61,9 @@ namespace Usm {
                 cfg.map_property_groups_with<Licence>("licences", o => o.licences, (o, v) => o.licences = v.to_vector(), Licence.get_mapper());
                 cfg.map<Properties>("provides", o => o.map_from_provides_dict(), (o, v) => o.build_provides_dict(v));
                 cfg.map_properties_with<Dependencies>("depends", o => o.dependencies, (o, v) => o.dependencies = v, Dependencies.get_mapper());
-                cfg.map_properties_with<Executables>("execs", o => o.executables, (o, v) => o.executables = v, Executables.get_mapper());
+                cfg.map_properties_with<Executables>("execs", o => o.executables, (o, v) => o.executables = v, Executables.get_mapper())
+                    .undefined_when(o => o.executables == null)
+                    .when_undefined(o => o.executables = new Executables());
                 cfg.map_many<string>("flags", o => o.flags.select<string>(f => f.to_string()), (o, v) => o.flags = v.attempt_select<ManifestFlag>(f => ManifestFlag.from_string(f)).to_set());
                 cfg.map<string>("md", o => o.markdown_path, (o, v) => o.markdown_path = v)
                     .undefined_when(o => o.markdown_path == null)
@@ -72,6 +91,7 @@ namespace Usm {
         public Manifest.from_file(string path) throws Error {
             var element = new InvercargillJson.JsonElement.from_file(path);
             Manifest.get_mapper().map_into(this, element.as<Invercargill.Properties>());
+            validate();
         }
 
         public Manifest.from_package(string path) throws Error {
@@ -99,12 +119,54 @@ namespace Usm {
 
                 var element = new InvercargillJson.JsonElement.from_string(manifest_blob.to_raw_string());
                 Manifest.get_mapper().map_into(this, element.as<Invercargill.Properties>());
+                validate();
                 return;
             }
 
             throw new ManifestError.INVALID_PACKAGE("MANIFEST.usm not found within archive");
         }
 
+        /**
+         * Validates cross-field constraints that parsing alone cannot catch.
+         *
+         * Currently enforces the {@link ManifestFlag.DATA_PACKAGE} contract:
+         * a data package must not define any lifecycle executable (build,
+         * install, rebuild, test, remove or postInstall); the acquire
+         * executable remains honoured. Throws
+         * {@link ManifestError.DATA_PACKAGE_WITH_EXECUTABLE} naming every
+         * offending executable otherwise.
+         */
+        public void validate() throws Error {
+            if(!is_data_package) {
+                return;
+            }
+
+            var defined = new Vector<string>();
+            if(executables.build != null) {
+                defined.add("build");
+            }
+            if(executables.install != null) {
+                defined.add("install");
+            }
+            if(executables.rebuild != null) {
+                defined.add("rebuild");
+            }
+            if(executables.test != null) {
+                defined.add("test");
+            }
+            if(executables.remove != null) {
+                defined.add("remove");
+            }
+            if(executables.post_install != null) {
+                defined.add("postInstall");
+            }
+            if(defined.any()) {
+                throw new ManifestError.DATA_PACKAGE_WITH_EXECUTABLE(
+                    @"Manifest \"$name\" declares the dataPackage flag, but data packages cannot define lifecycle executables (found: $(string.joinv(", ", defined.to_array()))). Only the acquire executable is permitted."
+                );
+            }
+        }
+
         private void build_provides_dict(Properties obj) throws Error {
             provides = new Dictionary<ResourceRef, ManifestFile>();
             var mapper = ManifestFile.get_mapper();
@@ -145,7 +207,43 @@ namespace Usm {
         }
 
         
+        /**
+         * Whether package management-script output should stream to the
+         * terminal instead of being silenced, requested via the USM_VERBOSE
+         * environment variable (set by `usm --verbose`, inherited by nested
+         * invocations and spawned scripts).
+         */
+        private static bool verbose_requested() {
+            var value = Environment.get_variable("USM_VERBOSE");
+            return value != null && value.length > 0;
+        }
+
+        /**
+         * Strips the output-silencing and -piping flags when verbose output
+         * was requested, so script output inherits the terminal unchanged.
+         */
+        private static SubprocessFlags verbose_flags(SubprocessFlags flags) {
+            if(!verbose_requested()) {
+                return flags;
+            }
+            var result = flags;
+            if((result & SubprocessFlags.STDOUT_SILENCE) != 0) {
+                result = result & ~SubprocessFlags.STDOUT_SILENCE;
+            }
+            if((result & SubprocessFlags.STDOUT_PIPE) != 0) {
+                result = result & ~SubprocessFlags.STDOUT_PIPE;
+            }
+            if((result & SubprocessFlags.STDERR_SILENCE) != 0) {
+                result = result & ~SubprocessFlags.STDERR_SILENCE;
+            }
+            return result;
+        }
+
         public Subprocess run_build(string build_path, Paths paths, SubprocessFlags flags, ProgressDelegate? progress_delegate = null) throws Error {
+            if(executables.build == null) {
+                throw new ManifestError.MISSING_FIELD(@"Manifest \"$name\" defines no build executable");
+            }
+
             // Handle SIMPLE_BUILD_ENVIRONMENT flag
             string original_working_dir = Environment.get_current_dir();
             string working_dir = original_working_dir;
@@ -170,7 +268,7 @@ namespace Usm {
             paths.set_envs();
             
             // Check if NINJA_STYLE_PROGRESS flag is set and progress delegate is provided
-            if (this.flags.contains(ManifestFlag.NINJA_STYLE_PROGRESS) && progress_delegate != null) {
+            if (this.flags.contains(ManifestFlag.NINJA_STYLE_PROGRESS) && progress_delegate != null && !verbose_requested()) {
                 // Set up subprocess to capture STDOUT for progress parsing
                 var modified_flags = flags;
                 // Ensure STDOUT is not silenced when we need to parse progress
@@ -240,7 +338,7 @@ namespace Usm {
                 
                 return proc;
             } else {
-                var proc = new Subprocess.newv(new string[] { path, Paths.ensure_trailing_slash(effective_build_path) }, flags);
+                var proc = new Subprocess.newv(new string[] { path, Paths.ensure_trailing_slash(effective_build_path) }, verbose_flags(flags));
                 
                 // Restore original working directory after subprocess completes
                 Environment.set_current_dir(original_working_dir);
@@ -262,7 +360,7 @@ namespace Usm {
             
             // Change to the working directory for subprocess execution
             Environment.set_current_dir(working_dir);
-            var proc = new Subprocess.newv(new string[] { path, Paths.ensure_trailing_slash(build_path) }, flags);
+            var proc = new Subprocess.newv(new string[] { path, Paths.ensure_trailing_slash(build_path) }, verbose_flags(flags));
             
             // Restore original working directory after subprocess completes
             Environment.set_current_dir(original_working_dir);
@@ -277,7 +375,7 @@ namespace Usm {
             string working_dir = Environment.get_current_dir();
             // Note: acquire doesn't have a build_path parameter, so it can't use SIMPLE_BUILD_ENVIRONMENT
             var path = Path.build_filename(working_dir, executables.acquire);
-            var proc = new Subprocess.newv(new string[] { path }, flags);
+            var proc = new Subprocess.newv(new string[] { path }, verbose_flags(flags));
             return proc;
         }
 
@@ -299,7 +397,7 @@ namespace Usm {
             // Change to the working directory for subprocess execution
             Environment.set_current_dir(working_dir);
             new_paths.set_envs();
-            var proc = new Subprocess.newv(new string[] { path, Paths.ensure_trailing_slash(build_path), Paths.ensure_trailing_slash(install_path), type.to_string() }, flags);
+            var proc = new Subprocess.newv(new string[] { path, Paths.ensure_trailing_slash(build_path), Paths.ensure_trailing_slash(install_path), type.to_string() }, verbose_flags(flags));
             
             // Restore original working directory after subprocess completes
             Environment.set_current_dir(original_working_dir);
@@ -320,7 +418,7 @@ namespace Usm {
             
             // Change to the working directory for subprocess execution
             Environment.set_current_dir(working_dir);
-            var proc = new Subprocess.newv(new string[] { path, Paths.ensure_trailing_slash(build_path), type.to_string() }, flags);
+            var proc = new Subprocess.newv(new string[] { path, Paths.ensure_trailing_slash(build_path), type.to_string() }, verbose_flags(flags));
             
             // Restore original working directory after subprocess completes
             Environment.set_current_dir(original_working_dir);
@@ -335,7 +433,7 @@ namespace Usm {
             string working_dir = Environment.get_current_dir();
             // Note: remove doesn't have a build_path parameter, so it can't use SIMPLE_BUILD_ENVIRONMENT
             var path = Path.build_filename(working_dir, executables.remove);
-            var proc = new Subprocess.newv(new string[] { path, type.to_string() }, flags);
+            var proc = new Subprocess.newv(new string[] { path, type.to_string() }, verbose_flags(flags));
             return proc;
         }
 
@@ -352,7 +450,7 @@ namespace Usm {
             
             // Change to the working directory for subprocess execution
             Environment.set_current_dir(working_dir);
-            var proc = new Subprocess.newv(new string[] { path, Paths.ensure_trailing_slash(build_path) }, flags);
+            var proc = new Subprocess.newv(new string[] { path, Paths.ensure_trailing_slash(build_path) }, verbose_flags(flags));
             
             // Restore original working directory after subprocess completes
             Environment.set_current_dir(original_working_dir);
@@ -382,29 +480,36 @@ namespace Usm {
 
                 if(resource.value.file_type == Usm.ManifestFileType.REGULAR) {
                     var base_path = "";
-                    switch (resource.value.path_base) {
-                        case ManifestFilePathBase.BUILD:
-                            base_path = build_path;
-                            break;
-                        case ManifestFilePathBase.SOURCE:
-                            base_path = source_path;
-                            break;
-                        case ManifestFilePathBase.INSTALL:
-                            if(install_path == null) {
-                                throw new ManifestError.INVALID_FILE_PATH("Install path was not provided");
-                            }
-                            base_path = install_path;
-                            break;
-                        case ManifestFilePathBase.AS_EXPECTED:
-                            if(install_path == null) {
-                                throw new ManifestError.INVALID_FILE_PATH("Install path was not provided");
-                            }
-                            var install_paths = paths.clone();
-                            install_paths.destination = install_path;
-                            base_path = install_paths.get_suggested_path_for_resource(resource.key);
-                            break;
-                        default:
-                            assert_not_reached();
+                    if(is_data_package) {
+                        // Data packages ship every provide directly from the source
+                        // (or unpacked package) tree, regardless of declared path base
+                        base_path = source_path;
+                    }
+                    else {
+                        switch (resource.value.path_base) {
+                            case ManifestFilePathBase.BUILD:
+                                base_path = build_path;
+                                break;
+                            case ManifestFilePathBase.SOURCE:
+                                base_path = source_path;
+                                break;
+                            case ManifestFilePathBase.INSTALL:
+                                if(install_path == null) {
+                                    throw new ManifestError.INVALID_FILE_PATH("Install path was not provided");
+                                }
+                                base_path = install_path;
+                                break;
+                            case ManifestFilePathBase.AS_EXPECTED:
+                                if(install_path == null) {
+                                    throw new ManifestError.INVALID_FILE_PATH("Install path was not provided");
+                                }
+                                var install_paths = paths.clone();
+                                install_paths.destination = install_path;
+                                base_path = install_paths.get_suggested_path_for_resource(resource.key);
+                                break;
+                            default:
+                                assert_not_reached();
+                        }
                     }
                     var src = File.new_build_filename(base_path, resource.value.path ?? "");
                     var dest = File.new_for_path(path);
@@ -583,7 +688,8 @@ namespace Usm {
         BUILD_IN_SOURCE_TREE,
         SET_MANIFEST_PROPERTY_ENVS,
         NINJA_STYLE_PROGRESS,
-        SIMPLE_BUILD_ENVIRONMENT;
+        SIMPLE_BUILD_ENVIRONMENT,
+        DATA_PACKAGE;
 
         public string to_string() {
             switch (this) {
@@ -595,6 +701,8 @@ namespace Usm {
                     return "ninjaStyleProgress";
                 case ManifestFlag.SIMPLE_BUILD_ENVIRONMENT:
                     return "simpleBuildEnvironment";
+                case ManifestFlag.DATA_PACKAGE:
+                    return "dataPackage";
                 default:
                     assert_not_reached();
             }
@@ -610,6 +718,8 @@ namespace Usm {
                     return ManifestFlag.NINJA_STYLE_PROGRESS;
                 case "simpleBuildEnvironment":
                     return ManifestFlag.SIMPLE_BUILD_ENVIRONMENT;
+                case "dataPackage":
+                    return ManifestFlag.DATA_PACKAGE;
                 default:
                     throw new ManifestError.INVALID_FLAG(@"Unknown flag \"$str\".");
             }

+ 764 - 36
src/lib/Resolver.vala

@@ -3,73 +3,774 @@ using Invercargill.DataStructures;
 
 namespace Usm {
 
+    /**
+     * Errors thrown while resolving a package set.
+     *
+     * Every member's message is a fully itemised report safe to print
+     * verbatim by a CLI.
+     */
+    public errordomain ResolverError {
+        /** A required resource ref could not be satisfied; the message itemises every failing ref and why. */
+        UNSATISFIABLE,
+        /** No candidate group in a grouped dependency phase is satisfiable; the message carries a per-group itemised report. */
+        NO_VIABLE_GROUP,
+        /** The resolved package graph contains a dependency cycle; the message names the packages in the cycle. */
+        CYCLE,
+        /** The configured system package manager query failed. */
+        SYSTEM_QUERY_FAILED
+    }
+
+    /**
+     * Why one {@link ResourceRef} could not be satisfied during resolution:
+     * it was neither present locally nor provided by any system or USM
+     * package candidate.
+     */
+    public class RefFailure : Object {
+        /** The ref that could not be satisfied. */
+        public ResourceRef resource { get; set; }
+        /** The name of the package whose dependency phase required it. */
+        public string origin { get; set; }
+        /** Whether a system package manager was consulted for this ref. */
+        public bool spm_configured { get; set; }
+
+        /**
+         * One-line description used in itemised reports, e.g.
+         * `bin:foo (required by "app") — not present locally; no system package provides it; no USM package provides it`.
+         */
+        public string describe(string reported_for) {
+            var origin_note = origin != reported_for ? @" (required by \"$origin\")" : "";
+            var spm_reason = spm_configured ? "no system package provides it" : "no system package manager is configured";
+            return @"$(resource.to_string())$origin_note — not present locally; $spm_reason; no USM package provides it";
+        }
+    }
+
+    /**
+     * One candidate group that could not be satisfied during group selection,
+     * carrying the failure for each unresolvable member.
+     */
+    public class GroupFailure : Object {
+        /** The 1-based position of the group within its phase. */
+        public uint group_index { get; set; }
+        /** The members of the group, deterministically ordered. */
+        public Vector<ResourceRef> members { get; set; }
+        /** Why each unresolvable member failed. */
+        public Vector<RefFailure> failures { get; set; }
+
+        /** Indented multi-line description used in itemised reports. */
+        public string describe(string reported_for) {
+            var builder = new StringBuilder();
+            builder.append_printf("  Group %u (%s):\n", group_index, members.to_string(r => r.to_string(), ", "));
+            foreach(var failure in failures) {
+                builder.append_printf("    %s\n", failure.describe(reported_for));
+            }
+            return builder.str;
+        }
+    }
+
+    /**
+     * The outcome of a successful {@link Resolver.resolve}: the resolved
+     * package set, the chosen system packages and the topological
+     * install/removal orders derived from the resolved package graph.
+     */
+    public class ResolutionResult : Object {
+        /** Every chosen USM package: the roots plus their transitive provider closure. */
+        public PackageSet packages { get; set; }
+        /** System packages chosen to satisfy missing resources, ordered by name; install them as one transaction before any USM package. */
+        public Vector<SystemPackageCandidate> system_packages { get; set; }
+        /** The chosen USM packages ordered dependencies-before-dependents (Kahn's algorithm, package-name tie-break). */
+        public Vector<AbstractPackage> install_order { get; set; }
+        /** The exact reverse of {@link install_order}: dependents are removed before their providers. */
+        public Vector<AbstractPackage> removal_order { get; set; }
+    }
+
+    /**
+     * Mutable working state for one resolution run.
+     *
+     * Snapshots are deep (packages, processed markers, chosen system
+     * packages and per-package chosen refs are all copied) so a group
+     * evaluation can be rolled back by restoring a snapshot without sharing
+     * any mutable collection with the state it was taken from.
+     */
+    internal class ResolutionState {
+        public PackageSet chosen = new PackageSet();
+        public HashSet<AbstractPackage> processed = new HashSet<AbstractPackage>();
+        public Dictionary<string, SystemPackageCandidate> spm_chosen = new Dictionary<string, SystemPackageCandidate>();
+        public Dictionary<string, Set<ResourceRef>> refs_by_package = new Dictionary<string, Set<ResourceRef>>();
+
+        public ResolutionState snapshot() {
+            var copy = new ResolutionState();
+            copy.chosen.union_with(chosen);
+            copy.processed.union_with(processed);
+            foreach(var pair in spm_chosen) {
+                copy.spm_chosen.set(pair.key, pair.value);
+            }
+            foreach(var pair in refs_by_package) {
+                var refs = new HashSet<ResourceRef>();
+                refs.union_with(pair.value);
+                copy.refs_by_package.set(pair.key, refs);
+            }
+            return copy;
+        }
+    }
+
     public class Resolver {
 
         private Dictionary<Repository, RepositoryListing> listings = new Dictionary<Repository, RepositoryListing>();
         private Set<AbstractPackage> supplied = new HashSet<AbstractPackage>();
         private ResourceFinder resource_finder;
+        private Vector<AbstractPackage>? catalog_cache = null;
+
+        // Per-resolution state, reset at the start of every {@link resolve} call
+        private ResolutionState state = new ResolutionState();
+        private Dictionary<string, Vector<SystemPackageCandidate>> spm_index = new Dictionary<string, Vector<SystemPackageCandidate>>();
+        private Dictionary<string, bool> local_presence = new Dictionary<string, bool>();
+        private bool spm_configured = false;
+
+        /** The lifecycle phases resolution considers, in processing order. */
+        private const string[] PHASES = { "manage", "build", "runtime" };
 
         public Resolver(ResourceFinder local_resource_finder) {
             this.resource_finder = local_resource_finder;
         }
 
-        private Enumerable<AbstractPackage> available_packages() {
-            return 
-                supplied.concat(listings
-                    .select_many<Pair<Repository, RepositoryListingEntry>>(l => l.value.entries.select_pairs<Repository, RepositoryListingEntry>(e => l.key, e => e))
-                    .select<AbstractPackage>(p => new AbstractPackage.from_repository(p.value1, p.value2)));
-        }
-
         public void load_listing(Repository repo, RepositoryListing listing) {
             listings.set(repo, listing);
+            catalog_cache = null;
         }
 
         public void supply_package(string path) throws Error {
             supplied.add(new AbstractPackage.from_package(path));
+            catalog_cache = null;
         }
 
+        /**
+         * Adds every readable cached package as a supplied package so the
+         * cache can satisfy resources during resolution. Unreadable cache
+         * entries (for example a half-finished download) are skipped with a
+         * warning.
+         */
         public void load_cache(Paths paths) throws Error {
-            var state = new SystemState(paths);
-            foreach (var package in state.get_cached_packages()) {
-                supply_package(package.package_path);
+            var cache_state = new SystemState(paths);
+            foreach (var package in cache_state.get_cached_packages()) {
+                if(!File.new_for_path(package.package_path).query_exists()) {
+                    continue;
+                }
+                try {
+                    supply_package(package.package_path);
+                }
+                catch(Error e) {
+                    warning(@"[Usm] Skipping unreadable cached package \"$(package.package_name)\": $(e.message)");
+                }
             }
         }
 
+        /**
+         * Finds a package by exact name, preferring repository packages over
+         * supplied (cached) ones so installs pull a fresh copy. Deterministic:
+         * repository packages are preferred, then the highest version, then
+         * repository name.
+         */
         public AbstractPackage? find_package(string search) {
-            return available_packages()
-                .first_or_default(p => p.manifest.name == search);
+            AbstractPackage? match = null;
+            foreach(var package in catalog()) {
+                if(package.manifest.name != search) {
+                    continue;
+                }
+                if(package.repository != null) {
+                    match = package;
+                }
+                else if(match == null) {
+                    match = package;
+                }
+            }
+            return match;
         }
 
+        /**
+         * Finds a package providing the given resource, deterministically
+         * choosing the alphabetically-first package name (then version,
+         * repository name, package path).
+         */
         public AbstractPackage? find_resource(ResourceRef resource) {
-            return available_packages()
-                .first_or_default(p => p.manifest.provides.any(r => resource.satisfied_by(r.key)));
+            foreach(var package in catalog()) {
+                if(package.manifest.provides.any(r => resource.satisfied_by(r.key))) {
+                    return package;
+                }
+            }
+            return null;
         }
 
-        public void solve_dependencies_for(PackageSet package_set) {
-            var queue = new Fifo<AbstractPackage>();
-            package_set.iterate(i => queue.push(i));
+        /**
+         * Resolves the full dependency closure for the given root packages.
+         *
+         * A missing resource resolves in order: (a) already present locally,
+         * (b) system packages — via ONE batched query covering every ref any
+         * candidate group could need, choosing per ref the candidate
+         * minimising new installs (dependency-count − installed-dependency-count),
+         * deduplicating packages chosen for multiple resources — then (c) USM
+         * packages from repositories, the cache or supplied packages. Without
+         * a configured manager (b) is skipped. Grouped phases select, in
+         * manifest order, the viable group minimising total new installs
+         * (USM packages count with their transitive closure; resources
+         * already satisfied cost 0); ties keep manifest order.
+         *
+         * Throws {@link ResolverError} with an itemised report when a flat
+         * ref is unsatisfiable, when no group is viable, or when the resolved
+         * graph contains a cycle.
+         */
+        public ResolutionResult resolve(Lot<AbstractPackage> roots, SystemPackageManager? spm = null) throws Error {
+            state = new ResolutionState();
+            spm_index = new Dictionary<string, Vector<SystemPackageCandidate>>();
+            local_presence = new Dictionary<string, bool>();
+            spm_configured = spm != null && spm.enabled;
 
-            foreach (var item in queue) {
-                var dependencies = item.manifest.dependencies.build
-                    .concat(item.manifest.dependencies.manage)
-                    .concat(item.manifest.dependencies.runtime);
+            var ordered_roots = roots.sort(compare_packages).to_vector();
+            foreach(var root in ordered_roots) {
+                state.chosen.add(root);
+            }
 
-                foreach (var dep in dependencies) {
-                    if(package_set.provides(dep) || resource_finder.has_resource(dep)) {
-                        continue;
+            if(spm_configured) {
+                batch_query(roots, (!)spm);
+            }
+
+            foreach(var root in ordered_roots) {
+                process_package(root);
+            }
+
+            var install_order = topological_order(state.chosen, state.refs_by_package);
+            var removal_order = new Vector<AbstractPackage>();
+            for(uint index = install_order.length; index > 0; index--) {
+                removal_order.add(install_order[index - 1]);
+            }
+
+            var system_packages = new Vector<SystemPackageCandidate>();
+            foreach(var pair in state.spm_chosen) {
+                system_packages.add(pair.value);
+            }
+            system_packages = system_packages.sort((a, b) => a.name.collate(b.name)).to_vector();
+
+            var packages = new PackageSet();
+            packages.union_with(state.chosen);
+
+            return new ResolutionResult() {
+                packages = packages,
+                system_packages = system_packages,
+                install_order = install_order,
+                removal_order = removal_order
+            };
+        }
+
+        /**
+         * Orders packages dependencies-before-dependents with Kahn's
+         * algorithm and a deterministic package-name tie-break; a cycle is a
+         * hard error naming the packages in it.
+         *
+         * {@link chosen_refs} maps a package name to the refs chosen for it
+         * during resolution (flat refs plus the chosen group's members); when
+         * null the requirements are derived from each manifest's flat
+         * phases plus every candidate group's refs. Only refs provided by
+         * another package in the set create ordering edges.
+         */
+        public static Vector<AbstractPackage> topological_order(Enumerable<AbstractPackage> packages, ReadOnlyAssociative<string, Set<ResourceRef>>? chosen_refs = null) throws ResolverError {
+            var nodes = packages.sort(compare_packages).to_vector();
+
+            var by_name = new Dictionary<string, Vector<AbstractPackage>>();
+            foreach(var node in nodes) {
+                var name = node.manifest.name;
+                Vector<AbstractPackage> named;
+                if(!by_name.try_get(name, out named)) {
+                    named = new Vector<AbstractPackage>();
+                    by_name.set(name, named);
+                }
+                named.add(node);
+            }
+
+            var required = new Dictionary<string, Vector<ResourceRef>>();
+            foreach(var node in nodes) {
+                var refs = new Vector<ResourceRef>();
+                Set<ResourceRef>? chosen = null;
+                if(chosen_refs != null && chosen_refs.try_get(node.manifest.name, out chosen)) {
+                    foreach(var resource in chosen) {
+                        refs.add(resource);
+                    }
+                }
+                else {
+                    foreach(var phase in resolution_phases(node.manifest)) {
+                        foreach(var resource in phase.ordered_all_refs()) {
+                            refs.add(resource);
+                        }
                     }
+                }
+                required.set(node.manifest.name, refs.sort((a, b) => a.to_string().collate(b.to_string())).to_vector());
+            }
 
-                    var provider = find_resource(dep);
-                    if(provider == null) {
-                        error(@"Could not solve dependency $dep");
+            // Edges provider → dependent; one edge per (provider, dependent) pair
+            var dependents = new Dictionary<string, Vector<string>>();
+            var indegree = new Dictionary<string, uint>();
+            foreach(var node in nodes) {
+                indegree.set(node.manifest.name, 0);
+            }
+            foreach(var dependent in nodes) {
+                var dependent_name = dependent.manifest.name;
+                var linked_providers = new HashSet<string>();
+                Vector<ResourceRef> dependent_refs;
+                if(!required.try_get(dependent_name, out dependent_refs)) {
+                    continue;
+                }
+                foreach(var resource in dependent_refs) {
+                    foreach(var provider in nodes) {
+                        var provider_name = provider.manifest.name;
+                        if(provider_name == dependent_name || linked_providers.has(provider_name)) {
+                            continue;
+                        }
+                        if(provider.manifest.provides.any(p => resource.satisfied_by(p.key))) {
+                            linked_providers.add(provider_name);
+                            Vector<string> follower_names;
+                            if(!dependents.try_get(provider_name, out follower_names)) {
+                                follower_names = new Vector<string>();
+                                dependents.set(provider_name, follower_names);
+                            }
+                            follower_names.add(dependent_name);
+                            uint degree;
+                            indegree.try_get(dependent_name, out degree);
+                            indegree.set(dependent_name, degree + 1);
+                        }
                     }
+                }
+            }
 
-                    package_set.add(provider);
-                    queue.push(provider);
+            var order = new Vector<AbstractPackage>();
+            var remaining = new HashSet<string>();
+            foreach(var node in nodes) {
+                remaining.add(node.manifest.name);
+            }
+            while(remaining.any()) {
+                string? next = null;
+                foreach(var name in remaining) {
+                    uint degree;
+                    if(indegree.try_get(name, out degree) && degree == 0) {
+                        if(next == null || name.collate(next) < 0) {
+                            next = name;
+                        }
+                    }
+                }
+                if(next == null) {
+                    throw new ResolverError.CYCLE(
+                        @"The resolved package graph contains a dependency cycle: $(describe_cycle(remaining, required, nodes))"
+                    );
+                }
+                remaining.remove(next);
+                Vector<AbstractPackage> named;
+                if(by_name.try_get(next, out named)) {
+                    foreach(var package in named) {
+                        order.add(package);
+                    }
                 }
+                Vector<string> follower_names;
+                if(dependents.try_get(next, out follower_names)) {
+                    foreach(var follower in follower_names) {
+                        if(remaining.has(follower)) {
+                            uint degree;
+                            indegree.try_get(follower, out degree);
+                            indegree.set(follower, degree - 1);
+                        }
+                    }
+                }
+            }
+
+            return order;
+        }
+
+        /** Deterministic catalog order: (name, version, repository name, package path). */
+        private static int compare_packages(AbstractPackage a, AbstractPackage b) {
+            var by_name = a.manifest.name.collate(b.manifest.name);
+            if(by_name != 0) {
+                return by_name;
+            }
+            var by_version = a.manifest.version.compare(b.manifest.version);
+            if(by_version != 0) {
+                return by_version;
+            }
+            var by_repository = (a.repository?.name ?? "").collate(b.repository?.name ?? "");
+            if(by_repository != 0) {
+                return by_repository;
             }
+            return (a.package_path ?? "").collate(b.package_path ?? "");
         }
 
+        /** The lifecycle phases resolution considers, in processing order. */
+        private static Vector<DependencyPhase> resolution_phases(Manifest manifest) {
+            var phases = new Vector<DependencyPhase>();
+            phases.add(manifest.dependencies.manage);
+            phases.add(manifest.dependencies.build);
+            phases.add(manifest.dependencies.runtime);
+            return phases;
+        }
+
+        private static Vector<AbstractPackage> catalog_of(Set<AbstractPackage> supplied, Dictionary<Repository, RepositoryListing> listings) {
+            return supplied.concat(
+                    listings.select_many<Pair<Repository, RepositoryListingEntry>>(l => l.value.entries.select_pairs<Repository, RepositoryListingEntry>(e => l.key, e => e))
+                    .select<AbstractPackage>(p => new AbstractPackage.from_repository(p.value1, p.value2)))
+                .sort(compare_packages)
+                .to_vector();
+        }
+
+        private Vector<AbstractPackage> catalog() {
+            if(catalog_cache == null) {
+                catalog_cache = catalog_of(supplied, listings);
+            }
+            return catalog_cache;
+        }
+
+        /**
+         * Collects every ref resolution could possibly consult — all refs of
+         * all candidate groups of every package reachable through USM
+         * providers — then asks the system package manager about the locally
+         * missing ones in ONE batched query.
+         */
+        private void batch_query(Lot<AbstractPackage> roots, SystemPackageManager spm) throws Error {
+            var refs = new HashSet<ResourceRef>();
+            var visited = new HashSet<AbstractPackage>();
+            var pending = new Series<AbstractPackage>();
+            foreach(var root in roots) {
+                pending.add(root);
+            }
+            while(pending.length > 0) {
+                var package = pending.pop_start();
+                if(visited.has(package)) {
+                    continue;
+                }
+                visited.add(package);
+                foreach(var phase in resolution_phases(package.manifest)) {
+                    foreach(var resource in phase.ordered_all_refs()) {
+                        refs.add(resource);
+                        if(!has_local(resource)) {
+                            var provider = find_resource(resource);
+                            if(provider != null) {
+                                pending.add(provider);
+                            }
+                        }
+                    }
+                }
+            }
+
+            var missing = new Vector<ResourceRef>();
+            foreach(var resource in refs.sort((a, b) => a.to_string().collate(b.to_string()))) {
+                if(!has_local(resource)) {
+                    missing.add(resource);
+                }
+            }
+            if(missing.length == 0) {
+                return;
+            }
+
+            SystemQueryResult? result = null;
+            try {
+                result = spm.query_sync(missing);
+            }
+            catch(Error e) {
+                throw new ResolverError.SYSTEM_QUERY_FAILED(@"$(e.message)");
+            }
+            if(result == null) {
+                spm_configured = false;
+                return;
+            }
+
+            foreach(var candidate in result.packages) {
+                foreach(var resource in candidate.resources) {
+                    var key = resource.to_string();
+                    Vector<SystemPackageCandidate> candidates;
+                    if(!spm_index.try_get(key, out candidates)) {
+                        candidates = new Vector<SystemPackageCandidate>();
+                        spm_index.set(key, candidates);
+                    }
+                    candidates.add(candidate);
+                }
+            }
+        }
+
+        private bool has_local(ResourceRef resource) {
+            var key = resource.to_string();
+            bool present;
+            if(local_presence.try_get(key, out present)) {
+                return present;
+            }
+            present = resource_finder.has_resource(resource);
+            local_presence.set(key, present);
+            return present;
+        }
+
+        /** Cost ordering for system package candidates: new installs first, then name. */
+        private static int compare_spm_candidates(SystemPackageCandidate a, SystemPackageCandidate b) {
+            var by_cost = (a.dependency_count - a.installed_dependency_count) - (b.dependency_count - b.installed_dependency_count);
+            if(by_cost != 0) {
+                return by_cost;
+            }
+            return a.name.collate(b.name);
+        }
+
+        /**
+         * The best candidate providing the resource: one already chosen for
+         * another resource (cost 0, smallest name) when possible, otherwise
+         * the cheapest by (new installs, name).
+         */
+        private SystemPackageCandidate? best_spm_candidate(ResourceRef resource) {
+            Vector<SystemPackageCandidate> candidates;
+            if(!spm_index.try_get(resource.to_string(), out candidates)) {
+                return null;
+            }
+            SystemPackageCandidate? already_chosen = null;
+            SystemPackageCandidate? best = null;
+            foreach(var candidate in candidates) {
+                if(state.spm_chosen.has(candidate.name)) {
+                    if(already_chosen == null || candidate.name.collate(already_chosen.name) < 0) {
+                        already_chosen = candidate;
+                    }
+                }
+                else if(best == null || compare_spm_candidates(candidate, best) < 0) {
+                    best = candidate;
+                }
+            }
+            return already_chosen ?? best;
+        }
+
+        /**
+         * Resolves one resource ref for {@link origin} under the
+         * local → system-package → USM-package precedence, mutating the
+         * current {@link state}; returns the failure when unsatisfiable.
+         */
+        private RefFailure? resolve_ref(AbstractPackage origin, ResourceRef resource) throws Error {
+            if(state.chosen.provides(resource) || has_local(resource)) {
+                return null;
+            }
+            if(spm_configured) {
+                var candidate = best_spm_candidate(resource);
+                if(candidate != null) {
+                    state.spm_chosen.set(candidate.name, candidate);
+                    return null;
+                }
+            }
+            var provider = find_resource(resource);
+            if(provider != null) {
+                if(!state.chosen.has(provider)) {
+                    state.chosen.add(provider);
+                }
+                process_package(provider);
+                return null;
+            }
+            return new RefFailure() {
+                resource = resource,
+                origin = origin.manifest.name,
+                spm_configured = spm_configured
+            };
+        }
+
+        private void process_package(AbstractPackage package) throws Error {
+            if(state.processed.has(package)) {
+                return;
+            }
+            state.processed.add(package);
+
+            var manifest = package.manifest;
+            for(int phase_index = 0; phase_index < PHASES.length; phase_index++) {
+                var phase_name = PHASES[phase_index];
+                var phase = manifest_phase(manifest, phase_name);
+                if(phase.is_grouped) {
+                    select_group(package, phase_name, phase);
+                }
+                else {
+                    var failures = new Vector<RefFailure>();
+                    foreach(var resource in phase.ordered_required()) {
+                        var failure = resolve_ref(package, resource);
+                        if(failure != null) {
+                            failures.add(failure);
+                        }
+                    }
+                    if(failures.any()) {
+                        var builder = new StringBuilder();
+                        builder.append_printf(
+                            "Could not resolve package \"%s\" (phase \"%s\"): %u unresolvable dependenc%s:\n",
+                            manifest.name, phase_name, failures.length, failures.length == 1 ? "y" : "ies"
+                        );
+                        foreach(var failure in failures) {
+                            builder.append_printf("  %s\n", failure.describe(manifest.name));
+                        }
+                        throw new ResolverError.UNSATISFIABLE(builder.str);
+                    }
+                    record_refs(package, phase.ordered_required());
+                }
+            }
+        }
+
+        private static DependencyPhase manifest_phase(Manifest manifest, string phase_name) {
+            switch(phase_name) {
+                case "manage":
+                    return manifest.dependencies.manage;
+                case "build":
+                    return manifest.dependencies.build;
+                case "runtime":
+                    return manifest.dependencies.runtime;
+                default:
+                    assert_not_reached();
+            }
+        }
+
+        /**
+         * Evaluates the phase's candidate groups in manifest order on a
+         * snapshot of the choices already made: a group is viable when every
+         * member resolves. The viable group minimising total new installs
+         * (USM packages with their transitive closure plus system packages by
+         * their cost fields) is committed; ties keep manifest order. When no
+         * group is viable an itemised per-group report is thrown.
+         */
+        private void select_group(AbstractPackage package, string phase_name, DependencyPhase phase) throws Error {
+            var base_state = state.snapshot();
+            var groups = phase.ordered_groups();
+            var group_failures = new Vector<GroupFailure>();
+
+            ResolutionState? best_state = null;
+            uint best_index = 0;
+            int best_cost = 0;
+
+            for(uint index = 0; index < groups.length; index++) {
+                // A fresh copy per evaluation: state must never alias
+                // base_state, or every group's additions would look
+                // pre-existing and cost nothing
+                state = base_state.snapshot();
+                var failures = new Vector<RefFailure>();
+                foreach(var resource in groups[index]) {
+                    var failure = resolve_ref(package, resource);
+                    if(failure != null) {
+                        failures.add(failure);
+                    }
+                }
+                if(failures.any()) {
+                    group_failures.add(new GroupFailure() {
+                        group_index = index + 1,
+                        members = groups[index],
+                        failures = failures
+                    });
+                    continue;
+                }
+                var cost = new_install_cost(base_state);
+                if(best_state == null || cost < best_cost) {
+                    best_cost = cost;
+                    best_index = index;
+                    best_state = state.snapshot();
+                }
+            }
+            if(best_state == null) {
+                var builder = new StringBuilder();
+                builder.append_printf(
+                    "Could not resolve package \"%s\" (phase \"%s\"): no candidate dependency group is satisfiable.\n",
+                    package.manifest.name, phase_name
+                );
+                foreach(var failure in group_failures) {
+                    builder.append(failure.describe(package.manifest.name));
+                }
+                throw new ResolverError.NO_VIABLE_GROUP(builder.str);
+            }
+
+            state = best_state;
+            record_refs(package, groups[best_index]);
+        }
+
+        /** Total new installs the current state adds over {@link base_state}: new USM packages plus system-package new-install costs. */
+        private int new_install_cost(ResolutionState base_state) {
+            var cost = (int)(state.chosen.count() - base_state.chosen.count());
+            foreach(var pair in state.spm_chosen) {
+                if(!base_state.spm_chosen.has(pair.key)) {
+                    cost += pair.value.dependency_count - pair.value.installed_dependency_count;
+                }
+            }
+            return cost;
+        }
+
+        private void record_refs(AbstractPackage package, Vector<ResourceRef> refs) {
+            Set<ResourceRef> recorded;
+            if(!state.refs_by_package.try_get(package.manifest.name, out recorded)) {
+                recorded = new HashSet<ResourceRef>();
+                state.refs_by_package.set(package.manifest.name, recorded);
+            }
+            foreach(var resource in refs) {
+                recorded.add(resource);
+            }
+        }
+
+        /** Walks provider edges among {@link remaining} from its smallest name until a node repeats, naming the cycle found. */
+        private static string describe_cycle(HashSet<string> remaining, Dictionary<string, Vector<ResourceRef>> required, Vector<AbstractPackage> nodes) {
+            var providers_of = new Dictionary<string, Vector<string>>();
+            foreach(var dependent in nodes) {
+                var dependent_name = dependent.manifest.name;
+                foreach(var provider in nodes) {
+                    var provider_name = provider.manifest.name;
+                    if(provider_name == dependent_name || !remaining.has(provider_name)) {
+                        continue;
+                    }
+                    Vector<ResourceRef> refs;
+                    if(!required.try_get(dependent_name, out refs)) {
+                        continue;
+                    }
+                    var provided = false;
+                    foreach(var resource in refs) {
+                        if(provider.manifest.provides.any(p => resource.satisfied_by(p.key))) {
+                            provided = true;
+                            break;
+                        }
+                    }
+                    if(provided) {
+                        Vector<string> providers;
+                        if(!providers_of.try_get(dependent_name, out providers)) {
+                            providers = new Vector<string>();
+                            providers_of.set(dependent_name, providers);
+                        }
+                        providers.add(provider_name);
+                    }
+                }
+            }
+
+            string? smallest = null;
+            foreach(var name in remaining) {
+                if(smallest == null || name.collate(smallest) < 0) {
+                    smallest = name;
+                }
+            }
+
+            var path = new Series<string>();
+            var seen = new HashSet<string>();
+            var current = smallest;
+            while(current != null && !seen.has(current)) {
+                seen.add(current);
+                path.add(current);
+                string? next = null;
+                Vector<string> providers;
+                if(providers_of.try_get(current, out providers)) {
+                    foreach(var provider in providers) {
+                        if(next == null || provider.collate(next) < 0) {
+                            next = provider;
+                        }
+                    }
+                }
+                current = next;
+            }
+
+            if(current == null) {
+                return remaining.to_string(n => n, " -> ");
+            }
+
+            var cycle = new Series<string>();
+            var started = false;
+            foreach(var name in path) {
+                if(name == current) {
+                    started = true;
+                }
+                if(started) {
+                    cycle.add(name);
+                }
+            }
+            cycle.add(current);
+            return cycle.to_string(n => n, " -> ");
+        }
     }
 
     public class PackageSet : HashSet<AbstractPackage> {
@@ -82,18 +783,40 @@ namespace Usm {
             add(new AbstractPackage.from_repository(repository, entry));
         }
 
+        /**
+         * Whether every package's required phases hold: flat phases need all
+         * refs present (provided by the set or locally), grouped phases need
+         * one fully-satisfied group.
+         */
         public bool is_satisfied(ResourceFinder local_resource_finder) {
-            return all(package => 
-                package.manifest.dependencies.build.all(d => provides(d) || local_resource_finder.has_resource(d)) &&
-                package.manifest.dependencies.manage.all(d => provides(d) || local_resource_finder.has_resource(d)) &&
-                package.manifest.dependencies.runtime.all(d => provides(d) || local_resource_finder.has_resource(d))
-            );
+            return all(package => {
+                foreach(var phase_name in new string[] { "manage", "build", "runtime" }) {
+                    var phase = manifest_phase_of(package.manifest, phase_name);
+                    if(!phase.is_satisfied(d => provides(d) || local_resource_finder.has_resource(d))) {
+                        return false;
+                    }
+                }
+                return true;
+            });
         }
 
         public bool provides(ResourceRef resource) {
             return any(p => p.manifest.provides.any(r => resource.satisfied_by(r.key)));
         }
 
+        private static DependencyPhase manifest_phase_of(Manifest manifest, string phase_name) {
+            switch(phase_name) {
+                case "manage":
+                    return manifest.dependencies.manage;
+                case "build":
+                    return manifest.dependencies.build;
+                case "runtime":
+                    return manifest.dependencies.runtime;
+                default:
+                    assert_not_reached();
+            }
+        }
+
     }
 
     public class AbstractPackage {
@@ -106,7 +829,7 @@ namespace Usm {
 
         public AbstractPackage.from_package(string path) throws Error {
             package_path = path;
-            manifest = new Manifest.from_file(path);
+            manifest = new Manifest.from_package(path);
         }
 
         public AbstractPackage.from_repository(Repository repository, RepositoryListingEntry entry) {
@@ -115,6 +838,11 @@ namespace Usm {
             this.manifest = entry.manifest;
         }
 
+        /** Wraps an already-parsed {@link Manifest} with no repository or package source behind it; used by tooling and tests. */
+        public AbstractPackage.from_manifest(Manifest manifest) {
+            this.manifest = manifest;
+        }
+
     }
 
-}
+}

+ 4 - 1
src/lib/ResourceRef.vala

@@ -156,7 +156,10 @@ namespace Usm {
         }
     }
 
-    public class ResourceRef : Hashable, Equatable<ResourceRef> {
+    // The explicit Object base matters: without it GObject rejects the
+    // Hashable/Equatable interface registrations, and every equals()/hash_code()
+    // dispatch (set membership, satisfied_by) dereferences a missing vtable
+    public class ResourceRef : Object, Hashable, Equatable<ResourceRef> {
         public string resource { get; set; }
         public ResourceType resource_type { get; set; }
 

+ 22 - 6
src/lib/State/State.vala

@@ -45,13 +45,29 @@ namespace Usm {
                 return null;
             }
 
-            var latest = Iterate.directory(list_path)
-                .where(f => f.has_suffix(".usml"))
-                .contextualised_select<DateTime>(f => new DateTime.from_iso8601(f.replace(".usml", ""), null))
-                .sort((a, b) => b.result.compare(a.result))
-                .first_or_default();
+            // A plain loop rather than a sorted generic chain: the listing
+            // names parse to boxed DateTimes, and Invercargill's lazy sort
+            // corrupts boxed generic values
+            string? latest_name = null;
+            DateTime? latest_time = null;
+            foreach(var file in Iterate.directory(list_path)) {
+                if(!file.has_suffix(".usml")) {
+                    continue;
+                }
+                var time = new DateTime.from_iso8601(file.replace(".usml", ""), null);
+                if(time == null) {
+                    continue;
+                }
+                if(latest_time == null || time.compare(latest_time) > 0) {
+                    latest_time = time;
+                    latest_name = file;
+                }
+            }
+            if(latest_name == null) {
+                return null;
+            }
 
-            var stream = new DataInputStream(File.new_build_filename(list_path, latest.origin).read());
+            var stream = new DataInputStream(File.new_build_filename(list_path, latest_name).read());
             return new RepositoryListing.from_stream(stream);
         }
 

+ 316 - 0
src/lib/SystemPackageManager.vala

@@ -0,0 +1,316 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Mapping;
+using InvercargillJson;
+
+namespace Usm {
+
+    /**
+     * Kind of a streamed system-package-manager install event, mirroring the
+     * helper contract's "type" values.
+     */
+    public enum SystemInstallEventType {
+        /** "begin": the transaction is starting; {@link SystemInstallEvent.total} packages will be installed. */
+        BEGIN,
+        /** "package": progress on one package, as a 0-1 fraction plus current/total positions. */
+        PACKAGE,
+        /** "package-complete": the named package finished installing. */
+        PACKAGE_COMPLETE,
+        /** "complete": the transaction finished; {@link SystemInstallEvent.installed} packages were installed. */
+        COMPLETE,
+        /** "error": terminal failure; no further events arrive. */
+        ERROR
+    }
+
+    /**
+     * A single event streamed by a system package manager install helper,
+     * parsed from one JSONL line of the contract documented in README.md.
+     *
+     * Only the fields relevant to a {@link SystemInstallEventType} are
+     * populated; for example only ERROR events carry a message.
+     */
+    public class SystemInstallEvent : Object {
+        /** The kind of event this instance carries. */
+        public SystemInstallEventType event_type { get; set; }
+        /** The package this event is about, when applicable. */
+        public string? name { get; set; }
+        /** Zero-based position of the in-flight package within the transaction. */
+        public int current { get; set; }
+        /** Total packages in the transaction. */
+        public int total { get; set; }
+        /** Progress of the in-flight package as a 0-1 fraction. */
+        public double progress { get; set; }
+        /** Number of packages installed, carried by COMPLETE events. */
+        public int installed { get; set; }
+        /** Failure description, carried by ERROR events. */
+        public string? message { get; set; }
+    }
+
+    /**
+     * Receives install progress while {@link SystemPackageManager.install} runs.
+     */
+    public delegate void InstallProgressDelegate(SystemInstallEvent event);
+
+    /**
+     * Result of a system package manager query: the requested refs no candidate
+     * provides, plus one {@link SystemPackageCandidate} per package that
+     * provides at least one requested ref.
+     */
+    public class SystemQueryResult {
+
+        public Vector<string> not_found { get; set; }
+        public Vector<SystemPackageCandidate> packages { get; set; }
+
+        public static PropertyMapper<SystemQueryResult> get_mapper() {
+            return PropertyMapper.build_for<SystemQueryResult>(cfg => {
+                cfg.map_many<string>("not-found", o => o.not_found, (o, v) => o.not_found = v.to_vector());
+                cfg.map_property_groups_with<SystemPackageCandidate>("packages", o => o.packages, (o, v) => o.packages = v.to_vector(), SystemPackageCandidate.get_mapper());
+                cfg.set_constructor(() => new SystemQueryResult());
+            });
+        }
+    }
+
+    /**
+     * A system package that provides at least one queried resource ref.
+     *
+     * Counts follow the helper contract: dependency_count is the total number
+     * of packages a solo install of this package would pull including itself,
+     * and installed_dependency_count is the portion of those already present
+     * on the system.
+     */
+    public class SystemPackageCandidate {
+
+        public string name { get; set; }
+        public Vector<ResourceRef> resources { get; set; }
+        public int dependency_count { get; set; }
+        public int installed_dependency_count { get; set; }
+
+        public static PropertyMapper<SystemPackageCandidate> get_mapper() {
+            return PropertyMapper.build_for<SystemPackageCandidate>(cfg => {
+                cfg.map<string>("name", o => o.name, (o, v) => o.name = v);
+                cfg.map_many<string>("resources", o => o.resources.select<string>(r => r.to_string()), (o, v) => o.resources = v.attempt_select<ResourceRef>(i => new ResourceRef(i)).to_vector());
+                cfg.map<int>("dependency-count", o => o.dependency_count, (o, v) => o.dependency_count = v);
+                cfg.map<int>("installed-dependency-count", o => o.installed_dependency_count, (o, v) => o.installed_dependency_count = v);
+                cfg.set_constructor(() => new SystemPackageCandidate());
+            });
+        }
+    }
+
+    /**
+     * Client for the system package manager configured in usm.config's
+     * "system_package_manager" section ({@link SystemPackageManagerConfig}).
+     *
+     * The configured argv arrays are spawned directly with the ref or package
+     * names appended; no shell parsing happens. When the configuration omits
+     * the section the manager is disabled: {@link query} returns null and
+     * {@link install} returns false, so callers can fall back to USM
+     * repositories alone. Both contracts (query JSON object, install JSONL
+     * events) are reproduced verbatim in README.md.
+     */
+    public class SystemPackageManager {
+
+        private string[] query_argv = null;
+        private string[] install_argv = null;
+
+        /**
+         * Creates a manager for the given {@link Configuration}; a configuration
+         * without a "system_package_manager" section yields a disabled manager.
+         */
+        public SystemPackageManager(Configuration config) {
+            var spm_config = config.system_package_manager;
+            if(spm_config != null && spm_config.query != null && spm_config.install != null) {
+                query_argv = spm_config.query.to_array();
+                install_argv = spm_config.install.to_array();
+            }
+        }
+
+        /** Whether a system package manager is configured. */
+        public bool enabled {
+            get {
+                return query_argv != null && query_argv.length > 0;
+            }
+        }
+
+        /**
+         * Queries the system package manager for packages providing the given
+         * resource refs.
+         *
+         * Returns null when no manager is configured (the expected miss for a
+         * disabled manager). Throws with the helper's stderr output when the
+         * query process itself fails; refs no candidate provides are reported
+         * through {@link SystemQueryResult.not_found} instead.
+         */
+        public async SystemQueryResult? query(Vector<ResourceRef> resources) throws Error {
+            if(!enabled) {
+                return null;
+            }
+            var refs = new Vector<string>();
+            foreach(var resource in resources) {
+                refs.add(resource.to_string());
+            }
+            var process = new Subprocess.newv(build_argv(query_argv, refs), SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
+            string stdout_buffer = null;
+            string stderr_buffer = null;
+            yield process.communicate_utf8_async(null, null, out stdout_buffer, out stderr_buffer);
+            if(process.get_exit_status() != 0) {
+                var detail = stderr_buffer != null ? stderr_buffer.chomp() : "";
+                throw new IOError.FAILED(@"System package manager query failed with exit status $(process.get_exit_status())$(detail.length > 0 ? ": " + detail : "")");
+            }
+            var element = new JsonElement.from_string(stdout_buffer ?? "");
+            return SystemQueryResult.get_mapper().materialise(element.as<Invercargill.Properties>());
+        }
+
+        /**
+         * Synchronous twin of {@link query} for callers without a main loop
+         * (the resolver batches exactly one query per resolution); the
+         * contract and error behaviour are identical.
+         */
+        public SystemQueryResult? query_sync(Vector<ResourceRef> resources) throws Error {
+            if(!enabled) {
+                return null;
+            }
+            var refs = new Vector<string>();
+            foreach(var resource in resources) {
+                refs.add(resource.to_string());
+            }
+            var process = new Subprocess.newv(build_argv(query_argv, refs), SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
+            string stdout_buffer = null;
+            string stderr_buffer = null;
+            process.communicate_utf8(null, null, out stdout_buffer, out stderr_buffer);
+            if(process.get_exit_status() != 0) {
+                var detail = stderr_buffer != null ? stderr_buffer.chomp() : "";
+                throw new IOError.FAILED(@"System package manager query failed with exit status $(process.get_exit_status())$(detail.length > 0 ? ": " + detail : "")");
+            }
+            var element = new JsonElement.from_string(stdout_buffer ?? "");
+            return SystemQueryResult.get_mapper().materialise(element.as<Invercargill.Properties>());
+        }
+
+        /**
+         * Installs the named system packages as one transaction, reporting
+         * progress through {@link progress} as contract events arrive.
+         *
+         * Returns true when the helper reported a successful transaction, and
+         * false when it streamed a terminal error event or exited non-zero
+         * without one (in which case a synthesised ERROR event is delivered to
+         * {@link progress} carrying the exit code, and any stderr output).
+         */
+        public async bool install(Vector<string> native_names, InstallProgressDelegate? progress = null) throws Error {
+            if(!enabled) {
+                warning("[Usm] System package manager install requested but no system package manager is configured");
+                return false;
+            }
+            var process = new Subprocess.newv(build_argv(install_argv, native_names), SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
+
+            // Stderr is drained on a reader thread so a chatty helper can never
+            // block on it while the event loop below waits on stdout (mirrors
+            // Manifest.run_build's progress thread).
+            Thread<string>? stderr_reader = null;
+            try {
+                stderr_reader = new Thread<string>.try(null, () => drain_pipe_sync(process.get_stderr_pipe()));
+            }
+            catch(Error e) {
+                warning(@"[Usm] Could not start system package manager output reader: $(e.message)");
+            }
+
+            bool error_seen = false;
+            var events = new DataInputStream(process.get_stdout_pipe());
+            while(true) {
+                var line = yield events.read_line_async(Priority.DEFAULT);
+                if(line == null) {
+                    break;
+                }
+                if(line.strip().length == 0) {
+                    continue;
+                }
+                var event = parse_event(line);
+                if(event == null) {
+                    continue;
+                }
+                error_seen = error_seen || event.event_type == SystemInstallEventType.ERROR;
+                if(progress != null) {
+                    progress(event);
+                }
+            }
+
+            yield process.wait_async(null);
+            var drained_stderr = stderr_reader != null ? stderr_reader.join() : "";
+            if(process.get_exit_status() != 0 && !error_seen) {
+                var detail = drained_stderr.chomp();
+                var event = new SystemInstallEvent() {
+                    event_type = SystemInstallEventType.ERROR,
+                    message = @"System package manager install failed with exit status $(process.get_exit_status())$(detail.length > 0 ? ": " + detail : "")"
+                };
+                error_seen = true;
+                if(progress != null) {
+                    progress(event);
+                }
+            }
+            return !error_seen;
+        }
+
+        private SystemInstallEvent? parse_event(string line) throws Error {
+            var json = new JsonElement.from_string(line).as<JsonObject>();
+            var type = json.has("type") ? json.get_string("type") : null;
+            switch(type) {
+                case "begin":
+                    return new SystemInstallEvent() {
+                        event_type = SystemInstallEventType.BEGIN,
+                        total = (int)(json.has("total") ? json.get_integer("total") : 0)
+                    };
+                case "package":
+                    return new SystemInstallEvent() {
+                        event_type = SystemInstallEventType.PACKAGE,
+                        name = json.has("name") ? json.get_string("name") : null,
+                        current = (int)(json.has("current") ? json.get_integer("current") : 0),
+                        total = (int)(json.has("total") ? json.get_integer("total") : 0),
+                        progress = json.has("progress") ? json.get_double("progress") : 0.0
+                    };
+                case "package-complete":
+                    return new SystemInstallEvent() {
+                        event_type = SystemInstallEventType.PACKAGE_COMPLETE,
+                        name = json.has("name") ? json.get_string("name") : null
+                    };
+                case "complete":
+                    return new SystemInstallEvent() {
+                        event_type = SystemInstallEventType.COMPLETE,
+                        installed = (int)(json.has("installed") ? json.get_integer("installed") : 0)
+                    };
+                case "error":
+                    return new SystemInstallEvent() {
+                        event_type = SystemInstallEventType.ERROR,
+                        message = json.has("message") ? json.get_string("message") : null
+                    };
+                default:
+                    warning(@"[Usm] Unknown system package manager event type \"$type\", ignoring");
+                    return null;
+            }
+        }
+
+        private string drain_pipe_sync(InputStream pipe) {
+            var builder = new StringBuilder();
+            try {
+                var lines = new DataInputStream(pipe);
+                string line = null;
+                while((line = lines.read_line()) != null) {
+                    builder.append(line).append("\n");
+                }
+            }
+            catch(Error e) {
+                warning(@"[Usm] Error draining system package manager output: $(e.message)");
+            }
+            return builder.str;
+        }
+
+        private static string[] build_argv(string[] helper_argv, Vector<string> arguments) {
+            var argv = new string[helper_argv.length + (int)arguments.count()];
+            for(int i = 0; i < helper_argv.length; i++) {
+                argv[i] = helper_argv[i];
+            }
+            var index = helper_argv.length;
+            foreach(var argument in arguments) {
+                argv[index++] = argument;
+            }
+            return argv;
+        }
+    }
+}

+ 126 - 39
src/lib/Transaction.vala

@@ -11,13 +11,37 @@ namespace Usm {
         public Set<CachedPackage> to_install { get; set; }
         public Set<CachedPackage> to_remove { get; set; }
 
+        /**
+         * Optional install order (package names) taken from a
+         * {@link ResolutionResult}'s {@link ResolutionResult.install_order};
+         * when set, packages build and install in exactly this order.
+         */
+        public Vector<string>? install_order { get; set; }
+
+        /**
+         * Optional removal order (package names) — the reverse of a
+         * {@link ResolutionResult}'s install order; when set, removals follow
+         * it exactly (dependents before their providers).
+         */
+        public Vector<string>? remove_order { get; set; }
+
         public signal void progress_updated(TransactionTask task_type, string subject, uint current_task, uint total_tasks, float task_progress);
 
         private uint task_count = 0;
         private uint current_task = 0;
         private string current_subject = "transaction";
-        private Vector<Vector<CachedPackage>> install_lots;
-        private Vector<CachedPackage> remove_order;
+
+        /**
+         * Lots computed by {@link strategise}: each lot builds, tests and
+         * installs together, and lots run in dependency order. A package
+         * joins a lot only when every BUILD-phase ref is already present or
+         * provided by an earlier lot, so downstream builds always see the
+         * upstream package's installed `pc:`/`vapi:` artifacts.
+         */
+        public Vector<Vector<CachedPackage>> install_lots { get; private set; }
+
+        /** Removal order computed by {@link strategise}: dependents before their providers. */
+        public Vector<CachedPackage> removal_order { get; private set; }
 
         public void run() throws TransactionError {
 
@@ -30,7 +54,7 @@ namespace Usm {
             do_for(all_packages, unpack_package, TransactionTask.UNPACKING);
 
             // 3. Remove packages
-            do_for(remove_order, remove_package, TransactionTask.REMOVING);
+            do_for(removal_order, remove_package, TransactionTask.REMOVING);
 
             foreach (var lot in install_lots) {
                 // 3. Build packages
@@ -74,29 +98,44 @@ namespace Usm {
             uint strategise_current_task = 0;
 
             report_progress(TransactionTask.STRATEGISING, 0.0f);
-            
+
             // Installation strategy
             install_lots = new Vector<Vector<CachedPackage>>();
             var touched = new HashSet<CachedPackage>();
-            var available_resources = new HashSet<ResourceRef>();
+            var installed_by_earlier_lots = new HashSet<ResourceRef>();
+            var ordered_install = ordered_by_names(to_install, install_order);
             var round = 0;
             while(true) {
                 strategise_current_task = round * to_install.count();
                 var lot = new Vector<CachedPackage>();
-                var remaining = to_install.exclude(touched);
+                var installed_by_this_lot = new HashSet<ResourceRef>();
+                var remaining = ordered_install.exclude(touched);
                 if(remaining.count() == 0) {
                     break;
                 }
 
-                foreach (var package in to_install.exclude(touched)) {
+                foreach (var package in ordered_install.exclude(touched)) {
                     report_progress(TransactionTask.STRATEGISING, (float)strategise_current_task / (float)strategise_worst_case_task_count);
                     try {
                         var manifest = package.get_manifest();
-                        var installtime_dependencies = manifest.dependencies.manage.concat(manifest.dependencies.build);
-                        if(installtime_dependencies.all(d => resource_finder.has_resource(d) || available_resources.any(r => d.satisfied_by(r)))) {
+                        // Build-phase refs must be satisfied by resources
+                        // already present or installed by an EARLIER lot: a
+                        // same-lot provide is not installed yet when this
+                        // package builds, so a build dependency on it defers
+                        // the package to the next lot
+                        PredicateDelegate<ResourceRef> buildtime_satisfied = d => resource_finder.has_resource(d)
+                            || installed_by_earlier_lots.any(r => d.satisfied_by(r));
+                        // Manage-phase executables run at install time, when
+                        // everything this lot has already admitted (in
+                        // topological order) is installed
+                        PredicateDelegate<ResourceRef> installtime_satisfied = d => resource_finder.has_resource(d)
+                            || installed_by_earlier_lots.any(r => d.satisfied_by(r))
+                            || installed_by_this_lot.any(r => d.satisfied_by(r));
+                        if(manifest.dependencies.manage.is_satisfied(installtime_satisfied) &&
+                                manifest.dependencies.build.is_satisfied(buildtime_satisfied)) {
                             lot.add(package);
                             touched.add(package);
-                            available_resources.union_with(manifest.provides.select<ResourceRef>(p => p.key));
+                            installed_by_this_lot.union_with(manifest.provides.select<ResourceRef>(p => p.key));
                         }
                         strategise_current_task++;
                     }
@@ -106,10 +145,11 @@ namespace Usm {
                 }
 
                 if(lot.count() == 0) {
-                    var packages = to_install.exclude(touched).to_string(p => p.package_name, ", ");
+                    var packages = ordered_install.exclude(touched).to_string(p => p.package_name, ", ");
                     throw new TransactionError.INVALID_TRANSACTION(@"Could not build a transaction strategy, packages $(packages) have unmet or cyclical dependencies");
                 }
 
+                installed_by_earlier_lots.union_with(installed_by_this_lot);
                 install_lots.add(lot);
                 round++;
             }
@@ -118,41 +158,48 @@ namespace Usm {
             report_progress(TransactionTask.STRATEGISING, (float)strategise_current_task / (float)strategise_worst_case_task_count);
 
             // Removal strategy
-            remove_order = new Vector<CachedPackage>();
-            touched = new HashSet<CachedPackage>();
-            Set<CachedPackageManifest> remaining_to_remove;
-            try {
-                remaining_to_remove = to_remove
-                    .attempt_select<CachedPackageManifest>(p => new CachedPackageManifest(p))
-                    .to_set();
-
+            removal_order = new Vector<CachedPackage>();
+            if(remove_order != null) {
+                removal_order = ordered_by_names(to_remove, remove_order);
             }
-            catch(Error e) {
-                throw new TransactionError.UNKNOWN_ERROR(@"Failed to read manifest: $(e.message)");
-            }  
+            else {
+                Set<CachedPackageManifest> remaining_to_remove;
+                try {
+                    remaining_to_remove = to_remove
+                        .attempt_select<CachedPackageManifest>(p => new CachedPackageManifest(p))
+                        .to_set();
 
-            round = 0;
-            while(true) {
-                strategise_current_task = current_task_baseline + (round * to_remove.count());
-                if(remaining_to_remove.count() == 0) {
-                    break;
+                }
+                catch(Error e) {
+                    throw new TransactionError.UNKNOWN_ERROR(@"Failed to read manifest: $(e.message)");
                 }
 
-                foreach (var package in remaining_to_remove) {
-                    report_progress(TransactionTask.STRATEGISING, (float)strategise_current_task / (float)strategise_worst_case_task_count);
-                    if(remaining_to_remove.no(p => p.manifest.dependencies.manage.any(d => package.manifest.provides.any(r => d.satisfied_by(r.key))))) {
-                        remove_order.add(package.package);
-                        touched.add(package.package);
-                        remaining_to_remove.remove(package);
-                        strategise_current_task++;
-                        round++;
+                round = 0;
+                while(true) {
+                    strategise_current_task = current_task_baseline + (round * to_remove.count());
+                    if(remaining_to_remove.count() == 0) {
                         break;
                     }
-                    strategise_current_task++;
-                }
 
-                var packages = remaining_to_remove.to_string(p => p.package.package_name, ", ");
-                throw new TransactionError.INVALID_TRANSACTION(@"Could not build a transaction strategy, packages $(packages) have unmet or cyclical dependencies");
+                    var removed_this_round = false;
+                    foreach (var package in remaining_to_remove.sort((a, b) => a.package.package_name.collate(b.package.package_name))) {
+                        report_progress(TransactionTask.STRATEGISING, (float)strategise_current_task / (float)strategise_worst_case_task_count);
+                        if(remaining_to_remove.no(p => p.manifest.dependencies.manage.all_refs().any(d => package.manifest.provides.any(r => d.satisfied_by(r.key))))) {
+                            removal_order.add(package.package);
+                            remaining_to_remove.remove(package);
+                            strategise_current_task++;
+                            round++;
+                            removed_this_round = true;
+                            break;
+                        }
+                        strategise_current_task++;
+                    }
+
+                    if(!removed_this_round) {
+                        var packages = remaining_to_remove.to_string(p => p.package.package_name, ", ");
+                        throw new TransactionError.INVALID_TRANSACTION(@"Could not build a transaction strategy, packages $(packages) have unmet or cyclical dependencies");
+                    }
+                }
             }
 
 
@@ -160,6 +207,46 @@ namespace Usm {
             current_task++;
         }
 
+        /**
+         * Orders a transaction package set by the given manifest-name order
+         * (from a {@link ResolutionResult}); names with no package in the set
+         * are skipped (for example supplied cache packages filtered out by
+         * the caller). Without an order the set is sorted by name for
+         * determinism. Throws when an order covers none of a package's set
+         * entries — every package must be covered.
+         */
+        private Vector<CachedPackage> ordered_by_names(Set<CachedPackage> packages, Vector<string>? names) throws TransactionError {
+            if(names == null) {
+                return packages.sort((a, b) => a.package_name.collate(b.package_name)).to_vector();
+            }
+
+            var by_name = new Dictionary<string, CachedPackage>();
+            foreach(var package in packages) {
+                try {
+                    by_name.set(package.get_manifest().name, package);
+                }
+                catch(Error e) {
+                    throw new TransactionError.UNKNOWN_ERROR(@"Failed to read manifest for package \"$(package.package_name)\": $(e.message)");
+                }
+            }
+
+            var ordered = new Vector<CachedPackage>();
+            var covered = new HashSet<CachedPackage>();
+            foreach(var name in names) {
+                CachedPackage package;
+                if(by_name.try_get(name, out package)) {
+                    ordered.add(package);
+                    covered.add(package);
+                }
+            }
+
+            var uncovered_names = packages.exclude(covered).to_string(p => p.package_name, ", ");
+            if(uncovered_names.length > 0) {
+                throw new TransactionError.INVALID_TRANSACTION(@"The supplied package order does not cover: $(uncovered_names)");
+            }
+            return ordered;
+        }
+
         private delegate void PackageDelegate(CachedPackage package) throws Error;
         private void do_for(Enumerable<CachedPackage> packages, PackageDelegate func, TransactionTask task_type) throws TransactionError {
             foreach (var package in packages) {

+ 2 - 0
src/lib/meson.build

@@ -15,6 +15,8 @@ sources += files('Resolver.vala')
 sources += files('Util.vala')
 sources += files('Transaction.vala')
 sources += files('Configuration.vala')
+sources += files('SystemPackageManager.vala')
+sources += files('Ignore/UsmIgnore.vala')
 sources += files('TransactionSummary.vala')
 sources += files('Repository/Repository.vala')
 sources += files('Repository/RepositoryListing.vala')

+ 14 - 1
src/meson.build

@@ -1,5 +1,18 @@
 project('Universal Source Manifest', 'vala', 'c', version: '0.1')
 vapi_dir = meson.current_source_dir() / 'vapi'
 
+# The DNF SPM helper lives outside the source root; copying it in keeps the
+# install declarative and preserves the executable bit via install_mode
+spm_dnf_helper = configure_file(
+    input: '../spm/dnf/usm-spm-dnf',
+    output: 'usm-spm-dnf',
+    copy: true,
+)
+install_data(spm_dnf_helper,
+    install_dir: get_option('bindir'),
+    install_mode: 'rwxr-xr-x',
+)
+
 subdir('lib')
-subdir('cli')
+subdir('cli')
+subdir('tests')

+ 910 - 0
src/tests/TestMain.vala

@@ -0,0 +1,910 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillJson;
+
+namespace Usm.Tests {
+
+    int failures = 0;
+    int passes = 0;
+
+    void check(bool condition, string label) {
+        if (condition) {
+            passes++;
+            print("PASS %s\n", label);
+        } else {
+            failures++;
+            print("FAIL %s\n", label);
+        }
+    }
+
+    /**
+     * Materialises a manifest from a JSON string the same way the CLI does,
+     * so validation runs against exactly what `usm manifest` would load.
+     */
+    Usm.Manifest manifest_from_json(string json) throws Error {
+        var element = new JsonElement.from_string(json);
+        return Usm.Manifest.get_mapper().materialise(element.as<Invercargill.Properties>());
+    }
+
+    /** Minimal valid data-package manifest, with execs injected per test. */
+    const string DATA_MANIFEST = """
+    {
+      "name": "fonts-example",
+      "version": "1.0.0",
+      "summary": "Example fonts",
+      "licences": [],
+      "flags": ["dataPackage"],
+      "provides": { "res:fonts/Example.ttf": "source:fonts/Example.ttf" },
+      "depends": { "runtime": [], "build": [], "manage": ["bin:bash"] },
+      %s
+    }
+    """;
+
+    void test_default_ignore() {
+        var ignore = new Usm.UsmIgnore();
+        check(ignore.matches(".git", true), "default ignores the .git directory");
+        check(ignore.matches(".git/config", false), "default ignores everything beneath .git");
+        check(!ignore.matches(".git", false), "default does not ignore a file named .git");
+        check(!ignore.matches("src/main.vala", false), "default keeps ordinary files");
+        check(!ignore.matches(".usmignore", false), ".usmignore can never be ignored");
+        check(!ignore.matches("MANIFEST.usm", false), "MANIFEST.usm can never be ignored");
+    }
+
+    void test_ignore_patterns() {
+        var ignore = new Usm.UsmIgnore();
+        ignore.add_pattern("# a comment");
+        ignore.add_pattern("   ");
+        ignore.add_pattern("builddir/");
+        ignore.add_pattern("*.sqlite");
+        ignore.add_pattern("docs/generated/");
+        ignore.add_pattern("src/secret.*");
+        ignore.add_pattern("notes/?draft.md");
+        ignore.add_pattern("!keep.sqlite");
+
+        check(!ignore.matches("# a comment", false), "comments are skipped");
+        check(ignore.matches("db.sqlite", false), "suffix pattern matches the basename");
+        check(ignore.matches("data/deep/db.sqlite", false), "suffix pattern matches at any depth");
+        check(!ignore.matches("db.sqlite3", false), "suffix pattern anchors the basename end");
+
+        check(ignore.matches("builddir", true), "directory-only pattern matches the directory itself");
+        check(!ignore.matches("builddir", false), "directory-only pattern ignores no plain file");
+        check(ignore.matches("builddir/meson", false), "directory-only pattern matches everything beneath");
+        check(ignore.matches("src/builddir", true), "unanchored directory-only pattern matches at any depth");
+        check(ignore.matches("src/builddir/x", false), "unanchored directory-only pattern covers beneath at any depth");
+        check(!ignore.matches("builddirs", true), "directory-only pattern does not over-match names");
+
+        check(ignore.matches("docs/generated", true), "anchored directory-only pattern matches its path");
+        check(ignore.matches("docs/generated/a.md", false), "anchored directory-only pattern covers beneath");
+        check(!ignore.matches("other/docs/generated", true), "anchored directory-only pattern does not match deeper roots");
+
+        check(ignore.matches("src/secret.key", false), "anchored pattern matches the full relative path");
+        check(!ignore.matches("src/sub/secret.key", false), "star never crosses a path separator");
+        check(!ignore.matches("include/secret.key", false), "anchored pattern does not match other roots");
+
+        check(ignore.matches("notes/1draft.md", false), "question mark matches one character");
+        check(!ignore.matches("notes/12draft.md", false), "question mark matches exactly one character");
+
+        check(ignore.matches("keep.sqlite", false), "exclamation mark has no negation power");
+        check(ignore.matches("!keep.sqlite", false), "exclamation mark is a literal pattern character");
+
+        check(ignore.matches(".git", true), ".git stays ignored alongside explicit patterns");
+    }
+
+    void test_ignore_always_included() {
+        var ignore = new Usm.UsmIgnore();
+        ignore.add_pattern("*");
+        ignore.add_pattern(".usmignore");
+        ignore.add_pattern("MANIFEST.usm");
+        check(ignore.matches("everything", false), "catch-all pattern matches ordinary files");
+        check(!ignore.matches(".usmignore", false), "catch-all cannot ignore .usmignore");
+        check(!ignore.matches("MANIFEST.usm", false), "catch-all cannot ignore MANIFEST.usm");
+        check(ignore.matches("sub/MANIFEST.usm", false), "nested manifests follow normal rules");
+    }
+
+    void test_ignore_from_root() throws Error {
+        var root = File.new_build_filename("/tmp", Uuid.string_random());
+        root.make_directory();
+
+        var without = new Usm.UsmIgnore.from_root(root.get_path());
+        check(without.matches(".git", true), "absent .usmignore falls back to the .git default");
+        check(!without.matches("db.sqlite", false), "absent .usmignore ignores nothing else");
+
+        FileUtils.set_contents(Path.build_filename(root.get_path(), ".usmignore"), "*.sqlite\n");
+        var with = new Usm.UsmIgnore.from_root(root.get_path());
+        check(with.matches("db.sqlite", false), ".usmignore is loaded from the package root");
+        check(!with.matches("MANIFEST.usm", false), "loaded .usmignore keeps MANIFEST.usm included");
+
+        Usm.Util.delete_tree(root.get_path());
+    }
+
+    void test_data_package_flag() {
+        check(Usm.ManifestFlag.DATA_PACKAGE.to_string() == "dataPackage", "dataPackage flag serialises");
+        check(Usm.ManifestFlag.from_string("dataPackage") == Usm.ManifestFlag.DATA_PACKAGE, "dataPackage flag parses");
+    }
+
+    void test_data_package_validation() throws Error {
+        var clean = manifest_from_json(DATA_MANIFEST.printf("\"execs\": { \"acquire\": \"fetch.sh\" }"));
+        clean.validate();
+        check(clean.is_data_package, "dataPackage flag marks a data package");
+        check(clean.executables.acquire != null, "acquire executable is honoured on data packages");
+        check(clean.executables.build == null, "data package without build parses");
+
+        var no_execs = manifest_from_json(DATA_MANIFEST.printf("\"execs\": { }"));
+        no_execs.validate();
+        check(no_execs.executables.build == null, "empty execs parse without a build executable");
+
+        var omitted = manifest_from_json(DATA_MANIFEST.printf("\"extras\": { }"));
+        omitted.validate();
+        check(omitted.executables != null, "omitting execs entirely parses");
+
+        var rejects = 0;
+        string[] offending = { "build", "install", "rebuild", "test", "remove", "postInstall" };
+        foreach(var executable in offending) {
+            try {
+                var manifest = manifest_from_json(DATA_MANIFEST.printf("\"execs\": { \"%s\": \"run.sh\" }".printf(executable)));
+                manifest.validate();
+                print("FAIL dataPackage + %s should not validate\n", executable);
+                failures++;
+            }
+            catch(Usm.ManifestError e) {
+                rejects++;
+            }
+        }
+        check(rejects == offending.length, "dataPackage + any lifecycle executable is rejected");
+
+        try {
+            var manifest = manifest_from_json(DATA_MANIFEST.printf("\"execs\": { \"install\": \"run.sh\" }"));
+            manifest.validate();
+            check(false, "rejection message mentions the offending executable");
+        }
+        catch(Usm.ManifestError e) {
+            check(e.message.contains("dataPackage") && e.message.contains("install"), "rejection message mentions the offending executable");
+        }
+    }
+
+    void test_data_package_from_file() throws Error {
+        var root = File.new_build_filename("/tmp", Uuid.string_random());
+        root.make_directory();
+
+        var manifest_path = Path.build_filename(root.get_path(), "MANIFEST.usm");
+        FileUtils.set_contents(manifest_path, DATA_MANIFEST.printf("\"execs\": { \"install\": \"install.sh\" }"));
+        try {
+            new Usm.Manifest.from_file(manifest_path);
+            check(false, "Manifest.from_file enforces the data-package rule");
+        }
+        catch(Usm.ManifestError e) {
+            check(true, "Manifest.from_file enforces the data-package rule");
+        }
+
+        Usm.Util.delete_tree(root.get_path());
+    }
+
+    void test_configuration_system_package_manager() throws Error {
+        var config_dir = File.new_build_filename("/tmp", Uuid.string_random());
+        config_dir.make_directory();
+
+        var paths = new Usm.Paths.defaults();
+        paths.usm_config_dir = config_dir.get_path();
+
+        FileUtils.set_contents(Path.build_filename(config_dir.get_path(), "usm.config"),
+            "{\"is_managed\": false, \"system_package_manager\": { \"query\": [\"/bin/usm-spm-dnf\", \"query\"], \"install\": [\"/bin/usm-spm-dnf\", \"install\"] }}");
+        var complete = new Usm.Configuration.from_paths(paths);
+        check(complete.system_package_manager != null && complete.system_package_manager.query != null,
+            "usm.config system_package_manager section parses");
+
+        FileUtils.set_contents(Path.build_filename(config_dir.get_path(), "usm.config"),
+            "{\"is_managed\": false, \"system_package_manager\": { \"query\": [\"/bin/usm-spm-dnf\", \"query\"] }}");
+        try {
+            new Usm.Configuration.from_paths(paths);
+            check(false, "query without install is rejected");
+        }
+        catch(Usm.ConfigurationError e) {
+            check(true, "query without install is rejected");
+        }
+
+        Usm.Util.delete_tree(config_dir.get_path());
+    }
+
+    // ---- Dependency-phase model -------------------------------------------------
+
+    /** Minimal manifest with the given "depends" section verbatim. */
+    const string PHASE_MANIFEST = """
+    {
+      "name": "phased-app",
+      "version": "1.0.0",
+      "summary": "phases",
+      "licences": [],
+      "flags": [],
+      "provides": { "bin:phased-app": "as-expected" },
+      "depends": %s,
+      "execs": {}
+    }
+    """;
+
+    /** Manifest named <name> providing bin:<name> with the given "depends" section. */
+    const string APP_MANIFEST = """
+    {
+      "name": "%s",
+      "version": "1.0.0",
+      "summary": "app",
+      "licences": [],
+      "flags": [],
+      "provides": { "bin:%s": "as-expected" },
+      "depends": %s,
+      "execs": {}
+    }
+    """;
+
+    /** A "depends" section with {@link value} in {@link phase} and empty flat arrays elsewhere. */
+    string depends_with(string phase, string value) {
+        var parts = new StringBuilder();
+        foreach(var other in new string[] { "runtime", "build", "manage", "acquire" }) {
+            if(parts.len > 0) {
+                parts.append(", ");
+            }
+            parts.append_printf("\"%s\": %s", other, other == phase ? value : "[]");
+        }
+        return "{ " + parts.str + " }";
+    }
+
+    void test_dependencies_flat_back_compat() throws Error {
+        var manifest = manifest_from_json(PHASE_MANIFEST.printf(
+            "{ \"runtime\": [\"bin:bash\", \"bin:coreutils\"], \"build\": [], \"manage\": [], \"acquire\": [\"bin:wget\"] }"));
+
+        check(!manifest.dependencies.runtime.is_grouped, "flat runtime phase stays flat");
+        check(manifest.dependencies.runtime.required.count() == 2, "flat runtime phase holds every ref");
+        check(manifest.dependencies.runtime.ordered_required().first().to_string() == "bin:bash", "flat phase refs are ordered by string");
+        check(!manifest.dependencies.build.is_grouped && manifest.dependencies.build.required.count() == 0, "empty flat phase parses");
+        check(!manifest.dependencies.manage.is_grouped, "manage phase stays flat");
+        check(manifest.dependencies.acquire != null && manifest.dependencies.acquire.required.count() == 1, "flat acquire phase parses");
+        check(manifest.dependencies.acquire.ordered_required().first().to_string() == "bin:wget", "acquire refs land in the acquire phase, not manage");
+
+        var omitted = manifest_from_json(PHASE_MANIFEST.printf("{ \"runtime\": [], \"build\": [], \"manage\": [] }"));
+        check(omitted.dependencies.acquire == null, "omitted acquire phase stays null");
+
+        var usm_shape = manifest_from_json(PHASE_MANIFEST.printf(
+            "{ \"runtime\": [\"lib:libc.so.6\"], \"build\": [\"bin:valac\"], \"manage\": [\"bin:bash\"] }"));
+        check(usm_shape.dependencies.build.required.any(r => r.to_string() == "bin:valac"), "usm's own flat manifest shape still parses");
+    }
+
+    void test_dependencies_nested() throws Error {
+        var manifest = manifest_from_json(PHASE_MANIFEST.printf(
+            "{ \"runtime\": [[\"bin:python3\", \"pc:python3.pc\"], [\"bin:python\", \"pc:python.pc\"]], \"build\": [], \"manage\": [] }"));
+
+        check(manifest.dependencies.runtime.is_grouped, "nested runtime phase is grouped");
+        check(!manifest.dependencies.build.is_grouped, "other phases stay flat");
+        var groups = manifest.dependencies.runtime.ordered_groups();
+        check(groups.length == 2, "both candidate groups parse");
+        check(groups[0].to_string(r => r.to_string(), ",") == "bin:python3,pc:python3.pc", "group order follows manifest order");
+        check(groups[1].length == 2, "second group members parse");
+        check(manifest.dependencies.runtime.ordered_all_refs().length == 4, "all_refs unions across groups");
+        check(manifest.dependencies.runtime.required.count() == 0, "grouped phase keeps the flat required set empty");
+    }
+
+    void test_dependencies_mixed_rejected() throws Error {
+        string[] phases = { "runtime", "build", "manage", "acquire" };
+        var rejected = 0;
+        foreach(var phase in phases) {
+            try {
+                manifest_from_json(PHASE_MANIFEST.printf(depends_with(phase, "[\"bin:bash\", [\"bin:other\"]]")));
+                print("FAIL mixed %s phase should not parse\n", phase);
+                failures++;
+            }
+            catch(Usm.ManifestError e) {
+                check(e.message.contains(phase), @"mixed-form rejection names the phase ($phase)");
+                rejected++;
+            }
+        }
+        check(rejected == phases.length, "every phase rejects mixed flat and nested forms");
+    }
+
+    void test_dependencies_empty_group() throws Error {
+        try {
+            manifest_from_json(PHASE_MANIFEST.printf("{ \"runtime\": [[]], \"build\": [], \"manage\": [] }"));
+            check(false, "empty candidate group is rejected");
+        }
+        catch(Usm.ManifestError e) {
+            check(e.message.contains("runtime"), "empty-group rejection names the phase");
+        }
+
+        try {
+            manifest_from_json(PHASE_MANIFEST.printf("{ \"runtime\": [[\"bin:bash\"], []], \"build\": [], \"manage\": [] }"));
+            check(false, "empty candidate group among groups is rejected");
+        }
+        catch(Usm.ManifestError e) {
+            check(e.message.contains("empty"), "empty-group rejection explains itself");
+        }
+
+        try {
+            manifest_from_json(PHASE_MANIFEST.printf("{ \"runtime\": [[[\"bin:bash\"]]], \"build\": [], \"manage\": [] }"));
+            check(false, "nesting deeper than groups is rejected");
+        }
+        catch(Usm.ManifestError e) {
+            check(true, "nesting deeper than groups is rejected");
+        }
+
+        try {
+            manifest_from_json(PHASE_MANIFEST.printf("{ \"runtime\": \"bin:bash\", \"build\": [], \"manage\": [] }"));
+            check(false, "non-array phase value is rejected");
+        }
+        catch(Usm.ManifestError e) {
+            check(e.message.contains("runtime"), "non-array phase rejection names the phase");
+        }
+
+        var empty_outer = manifest_from_json(PHASE_MANIFEST.printf("{ \"runtime\": [], \"build\": [], \"manage\": [] }"));
+        check(!empty_outer.dependencies.runtime.is_grouped && empty_outer.dependencies.runtime.required.count() == 0,
+            "empty array parses as a flat phase with no requirements");
+    }
+
+    void test_dependencies_roundtrip() throws Error {
+        var nested = manifest_from_json(PHASE_MANIFEST.printf(
+            "{ \"runtime\": [[\"bin:python3\", \"pc:python3.pc\"], [\"bin:python\"]], \"build\": [\"bin:make\"], \"manage\": [] }"));
+        var properties = Usm.Manifest.get_mapper().map_from(nested);
+        var serialised = ((!)new JsonElement.from_properties(properties)).stringify_pretty();
+        var reparsed = manifest_from_json(serialised);
+
+        check(reparsed.dependencies.runtime.is_grouped, "grouped phase survives serialisation");
+        check(reparsed.dependencies.runtime.ordered_groups().length == 2, "group count survives serialisation");
+        check(reparsed.dependencies.runtime.ordered_groups()[0].to_string(r => r.to_string(), ",") == "bin:python3,pc:python3.pc",
+            "group members survive serialisation");
+        check(reparsed.dependencies.build.ordered_required().first().to_string() == "bin:make", "flat phase survives serialisation");
+        check(!reparsed.dependencies.runtime.is_grouped == false, "grouped marker consistent after roundtrip");
+
+        var flat = manifest_from_json(PHASE_MANIFEST.printf("{ \"runtime\": [\"bin:bash\"], \"build\": [], \"manage\": [] }"));
+        var flat_serialised = ((!)new JsonElement.from_properties(Usm.Manifest.get_mapper().map_from(flat))).stringify_pretty();
+        var flat_reparsed = manifest_from_json(flat_serialised);
+        check(!flat_reparsed.dependencies.runtime.is_grouped && flat_reparsed.dependencies.runtime.required.count() == 1,
+            "flat phase roundtrips as flat");
+    }
+
+    // ---- Topological ordering ----------------------------------------------------
+
+    /** A package manifest named pkg-<name> providing bin:pkg-<name> with the given runtime refs. */
+    Usm.AbstractPackage topo_package(string name, string[] runtime_refs) throws Error {
+        var refs = new StringBuilder();
+        foreach(var resource_ref in runtime_refs) {
+            if(refs.len > 0) {
+                refs.append(",");
+            }
+            refs.append_printf("\"%s\"", resource_ref);
+        }
+        var json = """
+        {
+          "name": "pkg-%s",
+          "version": "1.0.0",
+          "summary": "topo",
+          "licences": [],
+          "flags": [],
+          "provides": { "bin:pkg-%s": "as-expected" },
+          "depends": { "runtime": [%s], "build": [], "manage": [] },
+          "execs": {}
+        }
+        """.printf(name, name, refs.str);
+        return new Usm.AbstractPackage.from_manifest(manifest_from_json(json));
+    }
+
+    void test_topological_order_linear() throws Error {
+        var a = topo_package("a", {});
+        var b = topo_package("b", { "bin:pkg-a" });
+        var c = topo_package("c", { "bin:pkg-b" });
+        var order = Usm.Resolver.topological_order(Iterate.these<Usm.AbstractPackage>(a, b, c));
+        check(order.to_string(p => p.manifest.name, ",") == "pkg-a,pkg-b,pkg-c", "linear chain orders deps first");
+    }
+
+    void test_topological_order_diamond() throws Error {
+        var a = topo_package("a", {});
+        var b = topo_package("b", { "bin:pkg-a" });
+        var c = topo_package("c", { "bin:pkg-a" });
+        var d = topo_package("d", { "bin:pkg-b", "bin:pkg-c" });
+        var order = Usm.Resolver.topological_order(Iterate.these<Usm.AbstractPackage>(d, c, b, a));
+        check(order.to_string(p => p.manifest.name, ",") == "pkg-a,pkg-b,pkg-c,pkg-d", "diamond orders the shared dep first and the dependent last");
+    }
+
+    void test_topological_order_name_tiebreak() throws Error {
+        var z = topo_package("z", { "bin:pkg-c", "bin:pkg-b" });
+        var c = topo_package("c", {});
+        var b = topo_package("b", {});
+        var order = Usm.Resolver.topological_order(Iterate.these<Usm.AbstractPackage>(z, c, b));
+        check(order.to_string(p => p.manifest.name, ",") == "pkg-b,pkg-c,pkg-z", "independent packages tie-break by name");
+    }
+
+    void test_topological_order_cycle() throws Error {
+        var x = topo_package("x", { "bin:pkg-y" });
+        var y = topo_package("y", { "bin:pkg-x" });
+        try {
+            Usm.Resolver.topological_order(Iterate.these<Usm.AbstractPackage>(x, y));
+            check(false, "cycle is a hard error");
+        }
+        catch(Usm.ResolverError e) {
+            check(e.message.contains("pkg-x") && e.message.contains("pkg-y"), "cycle error names the packages in the cycle");
+            check(e.message.contains("->"), "cycle error shows the cycle path");
+        }
+    }
+
+    // ---- Resolver: USM-only resolution, SPM precedence, group selection ----------
+
+    /** A tiny fake system package manager helper honouring the query/install contracts. */
+    const string SPM_STUB_SCRIPT = """#!/bin/bash
+mode="$1"; shift
+echo "$mode $*" >> "$USM_SPM_LOG"
+case "$mode" in
+  query)
+    python3 - "$USM_SPM_FIXTURE" "$@" <<'PY'
+import json, sys
+with open(sys.argv[1]) as handle:
+    fixture = json.load(handle)
+refs = sys.argv[2:]
+packages = fixture.get("packages", [])
+known = [p for p in packages if any(r in p["resources"] for r in refs)]
+provided = set()
+for package in known:
+    provided.update(package["resources"])
+print(json.dumps({"not-found": [r for r in refs if r not in provided], "packages": known}))
+PY
+    ;;
+  install)
+    total=$#
+    echo "{\"type\":\"begin\",\"total\":$total}"
+    n=0
+    for name in "$@"; do
+      n=$((n+1))
+      echo "$name" >> "$USM_SPM_INSTALL_LOG"
+      echo "{\"type\":\"package\",\"name\":\"$name\",\"current\":$n,\"total\":$total,\"progress\":0.5}"
+      echo "{\"type\":\"package-complete\",\"name\":\"$name\"}"
+    done
+    echo "{\"type\":\"complete\",\"status\":\"ok\",\"installed\":$total}"
+    ;;
+  *)
+    exit 2
+    ;;
+esac
+""";
+
+    string make_scratch() throws Error {
+        var dir = File.new_build_filename("/tmp", Uuid.string_random());
+        dir.make_directory();
+        return dir.get_path();
+    }
+
+    /** One candidate entry in the fake fixture's package table. */
+    string spm_candidate(string name, string resource, int deps, int installed) {
+        return "{\"name\": \"%s\", \"resources\": [\"%s\"], \"dependency-count\": %d, \"installed-dependency-count\": %d}".printf(name, resource, deps, installed);
+    }
+
+    Usm.SystemPackageManager stub_manager(string scratch, string candidates_json) throws Error {
+        var stub_path = Path.build_filename(scratch, "spm-stub.sh");
+        FileUtils.set_contents(stub_path, SPM_STUB_SCRIPT);
+        FileUtils.chmod(stub_path, 0755);
+
+        var fixture_path = Path.build_filename(scratch, "fixture.json");
+        FileUtils.set_contents(fixture_path, @"{ \"packages\": [$candidates_json] }");
+        Environment.set_variable("USM_SPM_FIXTURE", fixture_path, true);
+        Environment.set_variable("USM_SPM_LOG", Path.build_filename(scratch, "query.log"), true);
+        Environment.set_variable("USM_SPM_INSTALL_LOG", Path.build_filename(scratch, "install.log"), true);
+
+        var config_dir = Path.build_filename(scratch, "config");
+        DirUtils.create(config_dir, 0700);
+        FileUtils.set_contents(Path.build_filename(config_dir, "usm.config"),
+            "{\"is_managed\": false, \"system_package_manager\": { \"query\": [\"%s\", \"query\"], \"install\": [\"%s\", \"install\"] }}".printf(stub_path, stub_path));
+
+        var paths = new Usm.Paths.defaults();
+        paths.usm_config_dir = config_dir;
+        return new Usm.SystemPackageManager(new Usm.Configuration.from_paths(paths));
+    }
+
+    /** Builds a .usmc archive for pkg-<name> providing bin:pkg-<name> with the given runtime refs. */
+    string make_package_archive(string scratch, string name, string[] runtime_refs) throws Error {
+        var source = Path.build_filename(scratch, @"src-$name");
+        DirUtils.create(source, 0755);
+
+        var refs = new StringBuilder();
+        foreach(var resource_ref in runtime_refs) {
+            if(refs.len > 0) {
+                refs.append(",");
+            }
+            refs.append_printf("\"%s\"", resource_ref);
+        }
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"),
+            APP_MANIFEST.printf(@"pkg-$name", @"pkg-$name", depends_with("runtime", "[" + refs.str + "]")));
+
+        var archive = Path.build_filename(scratch, @"pkg-$name.usmc");
+        Usm.Util.archive(source, archive);
+        return archive;
+    }
+
+    /**
+     * Builds a .usmc archive for pkg-<name> providing provides_key with the
+     * given BUILD-phase refs (used for lot-admission fixtures).
+     */
+    string make_build_package_archive(string scratch, string name, string provides_key, string[] build_refs) throws Error {
+        var source = Path.build_filename(scratch, @"src-$name");
+        DirUtils.create(source, 0755);
+
+        var refs = new StringBuilder();
+        foreach(var resource_ref in build_refs) {
+            if(refs.len > 0) {
+                refs.append(",");
+            }
+            refs.append_printf("\"%s\"", resource_ref);
+        }
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"), """
+        {
+          "name": "pkg-%s",
+          "version": "1.0.0",
+          "summary": "lots",
+          "licences": [],
+          "flags": [],
+          "provides": { "%s": "as-expected" },
+          "depends": { "runtime": [], "build": [%s], "manage": [] },
+          "execs": {}
+        }
+        """.printf(name, provides_key, refs.str));
+
+        var archive = Path.build_filename(scratch, @"pkg-$name.usmc");
+        Usm.Util.archive(source, archive);
+        return archive;
+    }
+
+    /** Caches pkg-<name>'s archive as a CachedPackage and returns it. */
+    Usm.CachedPackage cache_package(string scratch, string name) throws Error {
+        var cache_path = Path.build_filename(scratch, @"cache-pkg-$name-1.0.0");
+        DirUtils.create(cache_path, 0755);
+        File.new_for_path(Path.build_filename(scratch, @"pkg-$name.usmc"))
+            .copy(File.new_for_path(Path.build_filename(cache_path, "package.usmc")), FileCopyFlags.OVERWRITE);
+        return new Usm.CachedPackage(cache_path);
+    }
+
+    Usm.ResolutionResult resolve_manifest(Usm.SystemPackageManager? spm, string manifest_json) throws Error {
+        var resolver = new Usm.Resolver(new Usm.ResourceFinder());
+        var roots = new Vector<Usm.AbstractPackage>();
+        roots.add(new Usm.AbstractPackage.from_manifest(manifest_from_json(manifest_json)));
+        return resolver.resolve(roots, spm);
+    }
+
+    void test_resolve_diamond_usm_only() throws Error {
+        var scratch = make_scratch();
+        var resolver = new Usm.Resolver(new Usm.ResourceFinder());
+        resolver.supply_package(make_package_archive(scratch, "a", {}));
+        resolver.supply_package(make_package_archive(scratch, "b", { "bin:pkg-a" }));
+        resolver.supply_package(make_package_archive(scratch, "c", { "bin:pkg-a" }));
+        resolver.supply_package(make_package_archive(scratch, "d", { "bin:pkg-b", "bin:pkg-c" }));
+
+        var roots = new Vector<Usm.AbstractPackage>();
+        roots.add(((!)resolver.find_package("pkg-d")));
+        var result = resolver.resolve(roots, null);
+
+        check(result.packages.count() == 4, "USM-only resolution closes the diamond");
+        check(result.install_order.to_string(p => p.manifest.name, ",") == "pkg-a,pkg-b,pkg-c,pkg-d", "install order is dependencies-first");
+        check(result.removal_order.to_string(p => p.manifest.name, ",") == "pkg-d,pkg-c,pkg-b,pkg-a", "removal order is the reverse of install order");
+        check(result.system_packages.length == 0, "no system packages are chosen without an SPM");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_resolve_spm_before_usm() throws Error {
+        var scratch = make_scratch();
+        var resolver = new Usm.Resolver(new Usm.ResourceFinder());
+        resolver.supply_package(make_package_archive(scratch, "x", {}));
+
+        var roots = new Vector<Usm.AbstractPackage>();
+        roots.add(new Usm.AbstractPackage.from_manifest(manifest_from_json(
+            APP_MANIFEST.printf("spm-app", "spm-app", depends_with("runtime", "[\"bin:pkg-x\"]")))));
+
+        var spm = stub_manager(scratch, spm_candidate("spm-owns-x", "bin:pkg-x", 3, 1));
+        var result = resolver.resolve(roots, spm);
+
+        check(result.system_packages.length == 1 && result.system_packages[0].name == "spm-owns-x", "a system package wins over an equally-good USM provider");
+        check(result.packages.count() == 1, "the USM provider is not pulled when the SPM satisfies the ref");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_group_selection_cheaper_wins() throws Error {
+        var scratch = make_scratch();
+        var spm = stub_manager(scratch,
+            spm_candidate("expensive", "bin:usmt-a1", 5, 0) + "," + spm_candidate("cheap", "bin:usmt-b1", 5, 3));
+
+        var result = resolve_manifest(spm, APP_MANIFEST.printf("grouped-app", "grouped-app",
+            depends_with("runtime", "[[\"bin:usmt-a1\"], [\"bin:usmt-b1\"]]")));
+
+        check(result.system_packages.length == 1 && result.system_packages[0].name == "cheap",
+            "the group whose candidate costs fewer NEW installs wins (dependency-count minus installed-dependency-count)");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_group_selection_tie_manifest_order() throws Error {
+        var scratch = make_scratch();
+        var spm = stub_manager(scratch,
+            spm_candidate("first-pkg", "bin:usmt-t1", 2, 0) + "," + spm_candidate("second-pkg", "bin:usmt-t2", 2, 0));
+
+        var result = resolve_manifest(spm, APP_MANIFEST.printf("grouped-app", "grouped-app",
+            depends_with("runtime", "[[\"bin:usmt-t1\"], [\"bin:usmt-t2\"]]")));
+
+        check(result.system_packages.length == 1 && result.system_packages[0].name == "first-pkg",
+            "equal-cost groups tie-break to manifest order");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_group_selection_none_viable_itemised() throws Error {
+        var scratch = make_scratch();
+        var spm = stub_manager(scratch, spm_candidate("irrelevant", "bin:usmt-other", 1, 0));
+
+        try {
+            resolve_manifest(spm, APP_MANIFEST.printf("grouped-app", "grouped-app",
+                depends_with("runtime", "[[\"bin:usmt-a1\", \"bin:usmt-a2\"], [\"bin:usmt-b1\"]]")));
+            check(false, "no viable group is a hard error");
+        }
+        catch(Usm.ResolverError e) {
+            check(e.message.contains("Group 1") && e.message.contains("Group 2"), "itemised failure lists every group");
+            check(e.message.contains("bin:usmt-a1") && e.message.contains("bin:usmt-a2") && e.message.contains("bin:usmt-b1"),
+                "itemised failure lists every missing ref");
+            check(e.message.contains("no system package provides it"), "itemised failure explains why each ref failed");
+        }
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_group_selection_single_batched_query() throws Error {
+        var scratch = make_scratch();
+        var spm = stub_manager(scratch,
+            spm_candidate("first-pkg", "bin:usmt-q1", 1, 0) + "," +
+            spm_candidate("second-pkg", "bin:usmt-q2", 2, 0) + "," +
+            spm_candidate("builddep-pkg", "bin:usmt-build1", 1, 0));
+
+        resolve_manifest(spm, APP_MANIFEST.printf("grouped-app", "grouped-app",
+            "{ \"runtime\": [[\"bin:usmt-q1\"], [\"bin:usmt-q2\", \"bin:usmt-q3\"]], \"build\": [\"bin:usmt-build1\"], \"manage\": [] }"));
+
+        string log;
+        FileUtils.get_contents(Path.build_filename(scratch, "query.log"), out log);
+        var queries = log.split("\n");
+        var query_lines = 0;
+        var query_line = "";
+        foreach(var line in queries) {
+            if(line.has_prefix("query")) {
+                query_lines++;
+                query_line = line;
+            }
+        }
+        check(query_lines == 1, "resolution issues exactly one batched SPM query");
+        check(query_line.contains("bin:usmt-q1") && query_line.contains("bin:usmt-q2") && query_line.contains("bin:usmt-q3") && query_line.contains("bin:usmt-build1"),
+            "the batched query covers refs from every group and phase");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_system_package_manager_install_stub() throws Error {
+        var scratch = make_scratch();
+        var spm = stub_manager(scratch, "");
+
+        var names = new Vector<string>();
+        names.add("one");
+        names.add("two");
+
+        var begin_events = 0;
+        var complete_events = 0;
+        var installed_count = 0;
+        var install_ok = false;
+        var loop = new MainLoop();
+        spm.install.begin(names, event => {
+            if(event.event_type == Usm.SystemInstallEventType.BEGIN) {
+                begin_events++;
+            }
+            if(event.event_type == Usm.SystemInstallEventType.COMPLETE) {
+                complete_events++;
+                installed_count = event.installed;
+            }
+        }, (obj, res) => {
+            try {
+                install_ok = spm.install.end(res);
+            }
+            catch(Error e) {
+                install_ok = false;
+            }
+            loop.quit();
+        });
+        loop.run();
+
+        check(install_ok, "stub install transaction reports success");
+        check(begin_events == 1 && complete_events == 1, "begin and complete events stream once each");
+        check(installed_count == 2, "complete event carries the installed count");
+
+        string install_log;
+        FileUtils.get_contents(Path.build_filename(scratch, "install.log"), out install_log);
+        check(install_log.split("\n").length == 3 && install_log.has_prefix("one\ntwo"), "install helper received the package names in order");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_transaction_accepts_orders() throws Error {
+        var scratch = make_scratch();
+        make_package_archive(scratch, "a", {});
+        make_package_archive(scratch, "b", { "bin:pkg-a" });
+        make_package_archive(scratch, "c", { "bin:pkg-a" });
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        var to_remove = new HashSet<Usm.CachedPackage>();
+        foreach(var name in new string[] { "a", "b", "c" }) {
+            var cache_path = Path.build_filename(scratch, @"cache-pkg-$name-1.0.0");
+            DirUtils.create(cache_path, 0755);
+            File.new_for_path(Path.build_filename(scratch, @"pkg-$name.usmc"))
+                .copy(File.new_for_path(Path.build_filename(cache_path, "package.usmc")), FileCopyFlags.OVERWRITE);
+            var cached = new Usm.CachedPackage(cache_path);
+            to_install.add(cached);
+            to_remove.add(cached);
+        }
+
+        var install_order = new Vector<string>();
+        install_order.add("pkg-c");
+        install_order.add("pkg-a");
+        install_order.add("pkg-b");
+        var remove_order = new Vector<string>();
+        remove_order.add("pkg-b");
+        remove_order.add("pkg-c");
+        remove_order.add("pkg-a");
+
+        var transaction = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = to_remove,
+            install_order = install_order,
+            remove_order = remove_order
+        };
+        transaction.strategise();
+
+        check(transaction.install_lots.length == 1, "packages without installtime deps form one lot");
+        check(transaction.install_lots[0].to_string(p => p.package_name, ",") == "cache-pkg-c-1.0.0,cache-pkg-a-1.0.0,cache-pkg-b-1.0.0", "the transaction installs in the supplied resolution order");
+        check(transaction.removal_order.to_string(p => p.package_name, ",") == "cache-pkg-b-1.0.0,cache-pkg-c-1.0.0,cache-pkg-a-1.0.0", "the transaction removes in the supplied reverse order");
+
+        var short_order = new Vector<string>();
+        short_order.add("pkg-a");
+        var partial = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            install_order = short_order
+        };
+        try {
+            partial.strategise();
+            check(false, "an order missing packages is rejected");
+        }
+        catch(Usm.TransactionError e) {
+            check(e.message.contains("pkg-b") || e.message.contains("pkg-c"), "the rejection names the uncovered packages");
+        }
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_transaction_lots_split_at_build_boundaries() throws Error {
+        var scratch = make_scratch();
+        make_build_package_archive(scratch, "a", "pc:lib-lot-a.pc", {});
+        make_build_package_archive(scratch, "b", "pc:lib-lot-b.pc", { "pc:lib-lot-a.pc" });
+        make_build_package_archive(scratch, "c", "pc:lib-lot-c.pc", { "pc:lib-lot-a.pc" });
+        make_build_package_archive(scratch, "d", "pc:lib-lot-d.pc", { "pc:lib-lot-b.pc", "pc:lib-lot-c.pc" });
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        foreach(var name in new string[] { "a", "b", "c", "d" }) {
+            to_install.add(cache_package(scratch, name));
+        }
+
+        var transaction = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>()
+        };
+        transaction.strategise();
+
+        check(transaction.install_lots.length == 3, "a build-dependency diamond splits into one lot per build level");
+        check(transaction.install_lots[0].to_string(p => p.package_name, ",") == "cache-pkg-a-1.0.0",
+            "the provider of the shared pc: file builds and installs alone in lot 1");
+        check(transaction.install_lots[1].to_string(p => p.package_name, ",") == "cache-pkg-b-1.0.0,cache-pkg-c-1.0.0",
+            "packages whose builds need lot 1's install share lot 2");
+        check(transaction.install_lots[2].to_string(p => p.package_name, ",") == "cache-pkg-d-1.0.0",
+            "the diamond tip waits for both lot 2 installs");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_transaction_manage_deps_admit_same_lot() throws Error {
+        var scratch = make_scratch();
+        make_package_archive(scratch, "m1", {});
+        // m2's manage phase needs m1's bin:, but its build phase needs
+        // nothing from the transaction — both belong in one lot because the
+        // manage executable runs at install time, after m1 installs
+        var source = Path.build_filename(scratch, "src-m2");
+        DirUtils.create(source, 0755);
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"),
+            APP_MANIFEST.printf("pkg-m2", "pkg-m2", depends_with("manage", "[\"bin:pkg-m1\"]")));
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        to_install.add(cache_package(scratch, "m1"));
+        var m2_archive = Path.build_filename(scratch, "pkg-m2.usmc");
+        Usm.Util.archive(source, m2_archive);
+        to_install.add(cache_package(scratch, "m2"));
+
+        var transaction = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>()
+        };
+        transaction.strategise();
+
+        check(transaction.install_lots.length == 1, "manage-phase refs satisfiable within the lot keep one lot");
+        check(transaction.install_lots[0].to_string(p => p.package_name, ",") == "cache-pkg-m1-1.0.0,cache-pkg-m2-1.0.0",
+            "the manage dependency orders its provider first inside the lot");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    int main() {
+        test_default_ignore();
+        try {
+            test_ignore_patterns();
+            test_ignore_always_included();
+            test_ignore_from_root();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL ignore matcher test threw: %s\n", e.message);
+        }
+
+        try {
+            test_data_package_flag();
+            test_data_package_validation();
+            test_data_package_from_file();
+            test_configuration_system_package_manager();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL data package test threw: %s\n", e.message);
+        }
+
+        try {
+            test_dependencies_flat_back_compat();
+            test_dependencies_nested();
+            test_dependencies_mixed_rejected();
+            test_dependencies_empty_group();
+            test_dependencies_roundtrip();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL dependencies mapper test threw: %s\n", e.message);
+        }
+
+        try {
+            test_topological_order_linear();
+            test_topological_order_diamond();
+            test_topological_order_name_tiebreak();
+            test_topological_order_cycle();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL topological order test threw: %s\n", e.message);
+        }
+
+        try {
+            test_resolve_diamond_usm_only();
+            test_resolve_spm_before_usm();
+            test_group_selection_cheaper_wins();
+            test_group_selection_tie_manifest_order();
+            test_group_selection_none_viable_itemised();
+            test_group_selection_single_batched_query();
+            test_system_package_manager_install_stub();
+            test_transaction_accepts_orders();
+            test_transaction_lots_split_at_build_boundaries();
+            test_transaction_manage_deps_admit_same_lot();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL resolver test threw: %s\n", e.message);
+        }
+
+        print("%d passed, %d failed\n", passes, failures);
+        return failures == 0 ? 0 : 1;
+    }
+}

+ 11 - 0
src/tests/meson.build

@@ -0,0 +1,11 @@
+# USM test suite: .usmignore matcher semantics, dataPackage manifest
+# validation and usm.config system_package_manager parsing. Prints PASS/FAIL
+# lines and exits non-zero on failure.
+
+usm_tests = executable('usm-tests',
+    ['TestMain.vala'],
+    dependencies: [usm_dep] + dependencies,
+    install: false
+)
+
+test('usm', usm_tests)