Răsfoiți Sursa

Merge branch 'web-stack' of Tilo15/usm into master

Billy Barrow 1 lună în urmă
părinte
comite
95dba79277
88 a modificat fișierele cu 24326 adăugiri și 733 ștergeri
  1. 5 1
      .gitignore
  2. 4 0
      .usmignore
  3. 19 8
      MANIFEST.usm
  4. 338 0
      PACKAGING_GUIDE.md
  5. 242 5
      README.md
  6. 24 2
      SPECIFICATION.md
  7. 17 0
      containers/alpine/Dockerfile
  8. 14 0
      containers/arch/Dockerfile
  9. 14 0
      containers/debian/Dockerfile
  10. 14 0
      containers/fedora/Dockerfile
  11. 14 0
      containers/ubuntu/Dockerfile
  12. 6 0
      fedora-x86_64.config
  13. 266 0
      installer/build_config.sh
  14. 224 0
      installer/compile.sh
  15. 2096 0
      installer/install-usm-unified.sh
  16. 1502 0
      installer/install-usm.sh
  17. 214 0
      installer/main.sh
  18. 65 0
      installer/meson.build
  19. 58 0
      installer/pm_apk.sh
  20. 58 0
      installer/pm_apt.sh
  21. 67 0
      installer/pm_base.sh
  22. 58 0
      installer/pm_dnf.sh
  23. 66 0
      installer/pm_emerge.sh
  24. 60 0
      installer/pm_pacman.sh
  25. 167 0
      installer/ui.sh
  26. 177 0
      installer/utils.sh
  27. 321 0
      slopdocs/structure.usm.manifest.dependency-management.md
  28. 352 0
      slopdocs/structure.usm.manifest.executable-scripts.md
  29. 225 0
      slopdocs/structure.usm.manifest.md
  30. 121 0
      slopdocs/structure.usm.manifest.packaging.md
  31. 219 0
      slopdocs/structure.usm.manifest.resource-types.md
  32. 269 0
      slopdocs/utility.usm.manifest.autoprovides.md
  33. 114 0
      slopdocs/utility.usm.manifest.validate.md
  34. 506 0
      spm/apk/usm-spm-apk
  35. 677 0
      spm/apt/usm-spm-apt
  36. 495 0
      spm/dnf/usm-spm-dnf
  37. 701 0
      spm/emerge/usm-spm-emerge
  38. 612 0
      spm/pacman/usm-spm-pacman
  39. 231 0
      src/cli/AddRepo.vala
  40. 213 0
      src/cli/Clean.vala
  41. 113 17
      src/cli/Cli.vala
  42. 40 0
      src/cli/Confirm.vala
  43. 824 0
      src/cli/Deploy.vala
  44. 203 0
      src/cli/Downgrade.vala
  45. 392 0
      src/cli/Enroll.vala
  46. 143 0
      src/cli/GenConfig.vala
  47. 94 0
      src/cli/Help.vala
  48. 344 35
      src/cli/Install.vala
  49. 794 117
      src/cli/Manifest.vala
  50. 307 0
      src/cli/ManifestBump.vala
  51. 857 0
      src/cli/ProgressBar.vala
  52. 168 0
      src/cli/Provides.vala
  53. 82 0
      src/cli/Rebuild.vala
  54. 242 0
      src/cli/Remove.vala
  55. 534 47
      src/cli/Repository.vala
  56. 283 0
      src/cli/Scaffold.vala
  57. 206 0
      src/cli/Search.vala
  58. 290 0
      src/cli/Update.vala
  59. 17 0
      src/cli/meson.build
  60. 69 3
      src/lib/Configuration.vala
  61. 211 9
      src/lib/Dependencies.vala
  62. 8 2
      src/lib/Exectuables.vala
  63. 20 4
      src/lib/File.vala
  64. 191 0
      src/lib/Ignore/UsmIgnore.vala
  65. 393 0
      src/lib/Journal.vala
  66. 550 54
      src/lib/Manifest.vala
  67. 137 68
      src/lib/Paths.vala
  68. 49 0
      src/lib/PcFile.vala
  69. 233 0
      src/lib/Repository/CurlRepositoryClient.vala
  70. 12 3
      src/lib/Repository/Repository.vala
  71. 11 1
      src/lib/Repository/RepositoryListing.vala
  72. 810 38
      src/lib/Resolver.vala
  73. 360 0
      src/lib/ResourceFinder.vala
  74. 136 187
      src/lib/ResourceRef.vala
  75. 45 6
      src/lib/State/CachedPackage.vala
  76. 21 3
      src/lib/State/OriginInformation.vala
  77. 173 7
      src/lib/State/State.vala
  78. 408 0
      src/lib/SystemPackageManager.vala
  79. 2 2
      src/lib/Tag/Tag.vala
  80. 839 80
      src/lib/Transaction.vala
  81. 191 0
      src/lib/TransactionSummary.vala
  82. 113 24
      src/lib/Util.vala
  83. 19 0
      src/lib/Version.vala
  84. 165 0
      src/lib/VersionConstraint.vala
  85. 20 8
      src/lib/meson.build
  86. 18 2
      src/meson.build
  87. 2333 0
      src/tests/TestMain.vala
  88. 11 0
      src/tests/meson.build

+ 5 - 1
.gitignore

@@ -1,2 +1,6 @@
 /build
-/build2
+/build2
+/install-builddir
+/installer/dist
+__pycache__/
+*.pyc

+ 4 - 0
.usmignore

@@ -0,0 +1,4 @@
+# build directories never belong in the source package
+builddir*/
+install-builddir/
+installer/dist/

+ 19 - 8
MANIFEST.usm

@@ -3,15 +3,27 @@
   "version": "0.0.1",
   "summary": "Universal Source Manifest",
   "licences": [ {"name": "GPLv3", "category": "libre", "text": "src/LICENSE"} ],
-  "flags": [],
+  "flags": ["ninjaStyleProgress"],
   "provides": {
     "bin:usm": "as-expected",
-    "lib:libusm.so": "as-expected",
-    "inc:usm.h": "as-expected",
-    "vapi:usm.vapi": "as-expected",
-    "typelib:usm-1.0.typelib": "as-expected",
-    "gir:usm-1.0.gir": "as-expected",
-    "pc:usm.pc": "as-expected"
+    "lib:libusm-0.1.so": "as-expected",
+    "inc:usm-0.1.h": "as-expected",
+    "vapi:usm-0.1.vapi": "as-expected",
+    "gir:usm-0.1.gir": "as-expected",
+    "typelib:usm-0.1.typelib": "as-expected",
+    "pc:usm-0.1.pc": "as-expected",
+    "bin:usm-spm-dnf": "as-expected",
+    "bin:usm-spm-apt": "as-expected",
+    "bin:usm-spm-apk": "as-expected",
+    "bin:usm-spm-emerge": "as-expected",
+    "bin:usm-spm-pacman": "as-expected",
+    "res:slopdocs/structure.usm.manifest.md": "source:slopdocs/structure.usm.manifest.md",
+    "res:slopdocs/utility.usm.manifest.validate.md": "source:slopdocs/utility.usm.manifest.validate.md",
+    "res:slopdocs/structure.usm.manifest.packaging.md": "source:slopdocs/structure.usm.manifest.packaging.md",
+    "res:slopdocs/utility.usm.manifest.autoprovides.md": "source:slopdocs/utility.usm.manifest.autoprovides.md",
+    "res:slopdocs/structure.usm.manifest.resource-types.md": "source:slopdocs/structure.usm.manifest.resource-types.md",
+    "res:slopdocs/structure.usm.manifest.executable-scripts.md": "source:slopdocs/structure.usm.manifest.executable-scripts.md",
+    "res:slopdocs/structure.usm.manifest.dependency-management.md": "source:slopdocs/structure.usm.manifest.dependency-management.md"
   },
   "depends": {
     "runtime": [
@@ -21,7 +33,6 @@
       "lib:libgio-2.0.so.0",
       "lib:libinvercargill-1.so",
       "lib:libinvercargill-json.so",
-      "lib:libc.so.6",
       "lib:libpcre2-8.so.0",
       "lib:libffi.so.8",
       "lib:libgmodule-2.0.so.0",

+ 338 - 0
PACKAGING_GUIDE.md

@@ -0,0 +1,338 @@
+# USM Packaging Guide
+
+Best practices for writing `MANIFEST.usm` files and creating packages that install correctly across platforms.
+
+---
+
+## The manifest
+
+```json
+{
+    "name": "mylib",
+    "version": "1.2.3",
+    "summary": "A concise one-line description",
+    "licences": ["MIT"],
+    "flags": ["ninjaStyleProgress"],
+    "provides": { "lib:libmylib.so": "as-expected" },
+    "depends": {
+        "runtime": ["lib:libglib-2.0.so.0"],
+        "build": ["bin:valac", "pc:glib-2.0.pc"],
+        "manage": ["bin:bash"]
+    },
+    "execs": {
+        "build": "usm-scripts/build.sh",
+        "install": "usm-scripts/install.sh"
+    }
+}
+```
+
+### Naming
+
+- Package names are lowercase kebab-case (`invercargill-sql`, not `InvercargillSql`)
+- Match the meson project name
+- The version comes from the meson project version — keep them in sync
+
+### Summary
+
+One line, lowercase, no trailing period. This is what `usm search` shows.
+
+---
+
+## Declaring dependencies
+
+### The most common mistake: trusting `ldd`
+
+`ldd` shows what the *linker* resolved at load time on *this specific machine*. It is **not** a complete picture of what your program needs, and it often shows things you don't actually depend on:
+
+| `ldd` says | Reality | Declare it? |
+|---|---|---|
+| `libselinux.so.1` | Indirect via glib — only needed on SELinux systems | **No** — it's a transitive dep of glibc/glib, not yours |
+| `libgcrypt.so.20` | Indirect via glib (gcrypt backend) | **No** — same reason |
+| `liblzma.so.5` | Indirect via libarchive | **No** — if you depend on `lib:libarchive.so`, its own manifest declares lzma |
+| `libglib-2.0.so.0` | You linked against it directly | **Yes** — `lib:libglib-2.0.so.0` |
+| `libjson-glib-1.0.so.0` | You linked against it directly | **Yes** — `lib:libjson-glib-1.0.so.0` |
+| *(missing from ldd)* `bin:valac` | Compile-time tool, not a library | **Yes** in `build` — ldd can't see build tools |
+| *(missing from ldd)* `pc:glib-2.0.pc` | Pkg-config discovery for meson | **Yes** in `build` — ldd can't see header files |
+| *(missing from ldd)* `gio:libgiognutls.so` | GIO TLS plugin loaded at runtime | **Yes** in `runtime` — ldd can't see dlopen'd modules |
+
+**Rule of thumb**: declare what *you* link against or *invoke*, not what your dependencies pull in transitively. Each package's manifest handles its own dependency chain.
+
+### Versioned library and pkg-config dependencies
+
+Hardcoding the full soname you happened to link against makes a package uninstallable on systems carrying a different (possibly newer) build of the same library: a package wanting `lib:libcmark.so.0.30.0` fails against a system providing `libcmark.so.0.31.1`. Since USM builds packages from source *on the target system*, a dependency is usually an API floor, not an ABI pin — express it with a version constraint on the base soname instead:
+
+```json
+"runtime": ["lib:libcmark.so>=0.30.0"]
+```
+
+This is satisfied by any `libcmark.so.<tail>` whose trailing dotted numbers meet the operator (`0.31.1` qualifies for `>=0.30.0`). Operators: `>=`, `<=`, `==` (equal, prefix-tolerant: `0.31` matches `0.31.0`) and `~=` (compatible release — `~=0.31.0` accepts `0.31.x` only, `~=0.31` accepts `0.x` from `0.31` up). The same syntax works for `pc:` refs, checked against the `.pc` file's `Version:` field:
+
+```json
+"build": ["pc:gtk4.pc>=4.12"]
+```
+
+When to use what:
+
+- **`>=`** — the normal choice: "the API I use exists since this version".
+- **`~=`** — when you know the library breaks ABI within the family you tested (common for pre-1.0 libraries that version their soname by minor).
+- **Exact full soname** (`lib:libcmark.so.0.31.1`) — only when you truly need one specific build.
+- Constraints on other resource types (`bin:`, `vapi:`, …) are rejected: those names carry no version.
+
+Packages that themselves *provide* a `pc:` file may declare its version in `provides` (`"pc:cmark.pc==0.31.1"`); without a declaration the file is assumed to match the package `version`, and validation fails a package whose installed `.pc` `Version:` field contradicts either.
+
+Note: constrained dependencies do not yet resolve through system package managers — only from local presence and USM packages.
+
+### Runtime dependencies (`depends.runtime`)
+
+Things the installed program needs at *run* time:
+
+- **Directly linked libraries**: `lib:libglib-2.0.so.0` (check with `ldd ./myprogram | grep -v linux-vdso`)
+- **GIO plugin modules**: `gio:libgiognutls.so` if the program uses HTTPS via GLib's GIO
+- **Data files**: `res:` refs for share-time resources the program reads
+
+**What NOT to put in runtime:**
+
+- `lib:libc.so.6` — the C library is implicit on every Linux system; musl doesn't even have it
+- `lib:libm.so.6` — same, the math library is always present
+- Transitive dependencies (glib pulls in pcre, ffi, selinux — don't declare those)
+- Build tools (valac, meson — those go in `build`)
+
+### Build dependencies (`depends.build`)
+
+Things needed to *compile* the package from source:
+
+- **Compilers**: `bin:valac`, `bin:gcc` (gcc is not always transitive — alpine's apk doesn't pull it)
+- **Build systems**: `bin:meson`, `bin:ninja`
+- **Pkg-config files**: `pc:glib-2.0.pc`, `pc:json-glib-1.0.pc` (these map to `-dev`/`-devel` system packages)
+- **Code generators**: `bin:g-ir-compiler` (if GIR/typelib are generated), `bin:statum-mkpstm` (for Statum pages)
+- **Scripting**: `bin:python3` if the build uses Python tools
+- **Stack-internal libraries**: `pc:invercargill-1.pc` (resolved from USM repositories, not the system)
+
+**What NOT to put in build:**
+
+- `bin:bash` — that goes in `manage` (build scripts run under the install environment's shell)
+- Runtime-only libraries the build doesn't link against
+
+### Manage dependencies (`depends.manage`)
+
+Things needed to *run the package's management scripts*:
+
+- `bin:bash` — almost always needed (usm-scripts are bash)
+- `bin:curl` — if the scripts download anything
+
+---
+
+## Declaring provides
+
+`provides` tells USM what your package installs and where to find it.
+
+### `"as-expected"` paths
+
+The default for most resources — USM knows the standard install location:
+
+```json
+"lib:libmylib.so": "as-expected",
+"inc:mylib.h": "as-expected",
+"pc:mylib.pc": "as-expected",
+"vapi:mylib.vapi": "as-expected"
+```
+
+USM derives the path from `Paths.get_suggested_base_path_for_type()` + the resource name.
+
+### Explicit paths
+
+For non-standard locations:
+
+```json
+"bin:myhelper": "usr/lib/myapp/myhelper"
+```
+
+### What to provide
+
+Provide what downstream packages might want to depend on:
+
+- `lib:<soname>` — the shared library (use the soname, not the full path)
+- `bin:<name>` — installed executables
+- `pc:<name>.pc` — the pkg-config file
+- `inc:<name>.h` or `inc:<dirname>` — headers (use the include directory name for multi-file headers)
+- `vapi:<name>.vapi` — Vala API definitions
+- `gir:<name>.gir` — GObject introspection XML
+- `typelib:<name>.typelib` — compiled introspection data
+
+**Don't provide** internal implementation details that nothing else should link against.
+
+---
+
+## Dependency groups (alternatives)
+
+When a package can be satisfied by *either* of multiple dependency sets, use the nested array form. A phase must be **either all plain strings (standard)** **or all arrays (alternatives)** — mixing strings and arrays in the same phase is a validation error.
+
+Each inner array is a complete, self-contained candidate group. Common dependencies are repeated in every group:
+
+```json
+"depends": {
+    "build": [
+        ["bin:valac", "pc:glib-2.0.pc", "pc:gtk4.pc", "pc:libadwaita-1.pc"],
+        ["bin:valac", "pc:glib-2.0.pc", "pc:gtk3.pc", "pc:libhandy-1.pc"]
+    ]
+}
+```
+
+USM picks whichever group is fully satisfiable, preferring the one that installs the fewest packages.
+
+**When to use groups:**
+- A library supports multiple backends (e.g., GTK4 or GTK3)
+- A newer monolithic package replaces two older split ones
+
+**When NOT to use groups:**
+- Optional features (use separate packages or flags instead)
+- Platform-specific deps (declare both; the resolver picks what's available)
+- When you only need "one of these" for a single dependency — that's what the resolver already does with SPM + USM fallback
+
+---
+
+## Scripts (`usm-scripts/`)
+
+### `build.sh`
+
+```bash
+#!/bin/bash
+set -e
+
+SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+BUILD_DIR="$1"
+PREFIX="${PREFIX:-/usr}"
+LIBDIR="${LIBDIR:-lib}"
+
+cd "$SRC_DIR"
+meson setup "$BUILD_DIR" --prefix="$PREFIX" --libdir="$LIBDIR"
+ninja -C "$BUILD_DIR"
+```
+
+**Rules:**
+- Accept the build directory as `$1` — USM provides it
+- Respect `PREFIX`, `LIBDIR`, `BINDIR`, `INCLUDEDIR` environment variables
+- Be idempotent — ninja handles incremental builds; don't `rm -rf` the build dir
+- Don't install — that's `install.sh`'s job
+
+### `install.sh`
+
+```bash
+#!/bin/bash
+set -e
+
+SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+BUILD_DIR="$1"
+INSTALL_DIR="$2"
+TYPE="$3"
+
+cd "$SRC_DIR"
+DESTDIR="$INSTALL_DIR" meson install -C "$BUILD_DIR"
+```
+
+**Rules:**
+- `$1` = build dir, `$2` = install staging dir (DESTDIR), `$3` = install type (`FRESH`, `UPDATE`, `DOWNGRADE`)
+- Install into the staging dir, not the real system — USM handles the final placement
+- Match the `provides` entries exactly — if you provide `lib:libfoo.so`, the library must actually land where USM expects it
+
+---
+
+## `.usmignore`
+
+Exclude files that shouldn't be in the `.usmc` archive:
+
+```
+builddir*/
+*.sqlite*
+db.sqlite
+web-config.json
+.kilo/
+.git/
+*.tar.xz
+```
+
+**Always exclude:**
+- Build directories (`builddir*/`, `build/`)
+- Databases (`*.sqlite*`)
+- Secrets and keys (`web-config.json`, `*-key*`, `*.env`)
+- VCS (`.git/`, `.hg/`)
+- Editor artifacts (`.kilo/`, `.vscode/`, `*.swp`)
+
+---
+
+## Common pitfalls
+
+### 1. Over-declaring dependencies
+
+**Symptom**: The resolver pulls in packages you don't need; installs are slow.
+
+**Fix**: Remove transitive dependencies. If you depend on `lib:libglib-2.0.so.0`, you don't also need `lib:libpcre2-8.so.0` — glib's own manifest declares that.
+
+### 2. Under-declaring build deps
+
+**Symptom**: Build fails in a container but works on the dev machine.
+
+**Fix**: The dev machine has tools installed that the container doesn't. Check: `bin:gcc` (alpine doesn't pull it), `bin:g-ir-compiler` (GIR generation), `bin:python3` (build tooling).
+
+### 3. Wrong soname in provides
+
+**Symptom**: Downstream packages can't resolve `lib:libfoo.so.1` because you provided `lib:libfoo.so`.
+
+**Fix**: Use the actual soname (what `readelf -d yourlib.so | grep SONAME` reports). The soname includes the version suffix.
+
+### 4. Platform-specific deps on the wrong platform
+
+**Symptom**: `usm install` fails on alpine because `lib:libc.so.6` doesn't exist on musl.
+
+**Fix**: Don't declare the C library. If you must (rare), use dependency groups with platform alternatives.
+
+### 5. GIR/typelib not generated on musl
+
+**Symptom**: Install fails on alpine with "Expected to find file at gir-1.0/..."
+
+**Fix**: Only declare `gir:` and `typelib:` in provides if gobject-introspection is available on all target platforms. If it's not, omit them — they're only needed for Python/JS bindings.
+
+---
+
+## Version bumping
+
+```bash
+# Bump patch version (1.2.3 → 1.2.4)
+usm manifest bump patch
+
+# Bump + git commit + tag
+usm manifest bump minor --git
+
+# Bump + commit + tag + push
+usm manifest bump major --git --push
+
+# Set explicit version
+usm manifest bump 2.0.0
+
+# Manage the rebuildDependants flag
+usm manifest bump patch --rebuild-dependants
+usm manifest bump patch --no-rebuild-dependants
+```
+
+Only set `rebuildDependants` when the update includes C API changes that break downstream compilation. Don't set it for bug fixes or internal changes.
+
+---
+
+## Testing your package
+
+```bash
+# Validate the manifest
+usm manifest validate
+
+# Build + install to a scratch DESTDIR
+USM_DESTDIR=/tmp/kilo/test-stage usm manifest install
+
+# Package it
+usm manifest package
+
+# Add to a scratch repository and test resolution
+cd /tmp/kilo/test-repo && usm repository init test && usm repository add ../mylib-1.0.0.usmc
+USM_CONFIGDIR=/tmp/kilo/test-config usm install mylib
+```
+
+Verify that every `provides` entry actually exists at the expected location in the DESTDIR. If it doesn't, either the install script or the manifest is wrong.

+ 242 - 5
README.md

@@ -26,11 +26,15 @@
       "bin:bash"
     ]
   },
+  "flags": [
+    "simpleBuildEnvironment"
+  ],
   "execs": {
-    "install": "usp-exec/install",
-    "remove": "usp-exec/remove",
-    "build": "usp-exec/build",
-    "rebuild": "usp-exec/rebuild"
+    "install": "usm-scripts/install",
+    "remove": "usm-scripts/remove",
+    "build": "usm-scripts/build",
+    "rebuild": "usm-scripts/rebuild",
+    "test": "usm-scripts/test"
   }
 }
 ```
@@ -49,6 +53,15 @@
 }
 ```
 
+## Manifest flags
+
+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
 
 - "rootpath": Generic resource: file on root filesystem without leading "/"
@@ -70,6 +83,7 @@
 - "vapi": Vala API: file name in /
 - "gir": GObject introspection: file name locatable in /usr/share/gir
 - "typelib": GObject typelib: file name locatable in /usr/lib64/girepository-1.0, /usr/lib/girepository-1.0, /lib64/girepository-1.0, or /lib/girepository-1.0
+- "gio": GIO plugin module: module filename locatable in /usr/lib64/gio/modules, /usr/lib/gio/modules or `/usr/lib/<multiarch-triple>/gio/modules`
 - "tag": A USM system tag, dot seperated with ".tag" appended, i.e. "hello.world" would be locatable in /usr/share/usm-tags/hello/world.tag
 
 ## Source Repositories (Repo.usmr)
@@ -116,6 +130,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"] }
+}
+```
+
 
 
 
@@ -188,4 +257,172 @@ JSONL transaction journal format
 - USM will not manage the installation if an install exec is present
 - Compare backup and transaction snapshots for validation
 - Scripts can optionally handle installation but it must be verified that the expected files (and only the expected files) are installed
-- Add post-install and maybe post-transaction execs that are not subject to validations
+- Add post-install and maybe post-transaction execs that are not subject to validations
+
+## USM Commands
+
+### Manifest Commands
+
+- `usm manifest build <build path>`: Build the package
+- `usm manifest install <build path>`: Build and install the package to the system
+- `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:
+
+```bash
+usm manifest validate [build path]
+```
+
+The validation process includes:
+- Manifest syntax validation
+- Dependency checking
+- License verification (with warning if missing)
+- Build execution with progress reporting
+- Install execution to temporary directory
+- Resource validation based on path base type (as-expected, build:, source:, install:)
+- Unexpected resource detection
+
+Exit codes indicate specific validation failures, making it suitable for CI/CD environments.
+
+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; five reference helpers ship under `spm/` (installed to `bindir` as `usm-spm-<spm>`):
+
+| Helper | Distro(s) | Language | Container bootstrap | Limitations |
+|---|---|---|---|---|
+| `spm/dnf/usm-spm-dnf` | Fedora | Python 3 + dnf4 | `dnf install -y python3-dnf && dnf clean all` | — |
+| `spm/apt/usm-spm-apt` | Debian, Ubuntu | Python 3 + python3-apt | `apt-get update && apt-get install -y curl python3 python3-apt apt-file && apt-file update && apt-get clean` | file queries go through **apt-file only** (no naming heuristics), so the contents index downloaded by `apt-file update` (~100 MB) is mandatory; paths follow Debian multiarch (`/usr/lib/x86_64-linux-gnu`, both pkgconfig dirs) |
+| `spm/apk/usm-spm-apk` | Alpine | POSIX shell (busybox ash) | `apk add --no-cache bash curl` | dependency-free — no python3, no jq; queries ride apk's native `pc:`/`so:` provides and the `/lib` (not lib64) busybox layout |
+| `spm/emerge/usm-spm-emerge` | Gentoo | Python 3 + portage | none — the stage3 base already carries portage + python | offline ebuilds expose no file lists: file queries for not-yet-installed packages resolve only via installed files, virtuals and a small package-name table, otherwise land in `not-found`; dependency counts come from `emerge --pretend` (see the `usm-spm-emerge` docstring for the full limits) |
+| `spm/pacman/usm-spm-pacman` | Arch | POSIX shell (bash) | `pacman -Sy --noconfirm curl && pacman -Fy --noconfirm && rm -rf /var/cache/pacman/pkg/*` | dependency-free like the apk helper: bsdtar (a pacman dependency) parses the sync databases for soname provides and the **files databases** for file queries, so the `pacman -Fy` refresh (~40 MB) is mandatory — without it only installed files resolve (`pacman -Qo`), no naming heuristics; dependency counts come from a `--print` run against a throwaway `--dbpath`; merged-usr `/usr/lib` layout |
+
+All five implement the identical JSON/JSONL contract below; `usm deploy --spm` (see [Container deployment](#container-deployment)) wires the matching one into generated images.
+
+### 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.
+
+Version-constrained refs (`lib:…>=…`, `pc:…==…`) never reach the helper — the resolver skips them in the batched query (helpers match names exactly), so they resolve from local presence or USM packages only.
+
+### Plan contract
+
+`<plan> <native-name>…` → STDOUT single JSON object, no side effects: the material install set — the chosen names plus every dependency of a dependency — in the manager's own transaction order:
+
+```json
+{ "packages": ["libgpm2", "libncurses6", "sl"] }
+```
+
+Used to preview and drive progress for the whole native transaction. Any resolution failure exits non-zero with the message on STDERR (the caller degrades to the input names).
+
+### 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 --spm apt                        # wire the apt SPM helper
+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`) |
+| `--spm dnf\|apt\|apk\|emerge\|pacman\|none` | System package manager wired into the image: the generated `usm.config` points `system_package_manager` at the `usm-spm-<spm>` helper the installer ships and sets the install `lib` directory per target (`lib` for the musl/Alpine, Debian-multiarch and Arch merged-usr layouts, `lib64` otherwise), and the Containerfile gains that SPM's bootstrap RUN (dnf installs `python3-dnf`; apt installs `curl python3 python3-apt apt-file` and refreshes the apt-file index; apk adds `bash curl`; emerge needs nothing — stage3 carries portage; pacman installs `curl` and refreshes the files databases with `pacman -Fy`; the curl installs cover base images that ship no fetcher for the USM installer). Default **none**: no `system_package_manager` section and no bootstrap RUN, so the image resolves everything from USM repositories alone (repo-only resolution — anything the repositories cannot provide fails the build) |
+| `--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`; with `--spm <spm>` also the SPM wired to `/opt/usm/bin/usm-spm-<spm>`), `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 (when wired) 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.

+ 24 - 2
SPECIFICATION.md

@@ -65,7 +65,8 @@ The USM manifest file is contains a JSON object with the following properties:
 - `flags` (string array, required): A list of flags for this manifest, each string in the array can be one of the following:
     - `"buildInSourceTree"` to tell usm to not create a separate build directory, and compile the package "in place".
     - `"setManifestPropertyEnvs"` idk TODO
-- `execs` (object, required): An object describing executable scripts for different phases of the package lifecycle (see [Executable Scripts](#executable-scripts)). It has the following properties :
+    - `"simpleBuildEnvironment"` to copy the full source tree to the build directory before running the build exec, and execute all build-related scripts from the build directory instead of the source directory.
+    - `execs` (object, required): An object describing executable scripts for different phases of the package lifecycle (see [Executable Scripts](#executable-scripts)). It has the following properties :
     - `build` (string, required): Path to the build script relative to the package root.
     - `install` (string, optional): Path to the install script relative to the package root.
     - `remove` (string, optional): Path to the removal script relative to the package root.
@@ -118,6 +119,27 @@ The following resource types are defined:
 - `cfg:myapp.conf`: A configuration file for `myapp`.
 - `app:myapp.desktop`: A desktop entry for `myapp`.
 
+### Version Constraints
+
+A resource name in a **dependency** may end with a version constraint, one of the operators `==` (equal), `>=` (at least), `<=` (at most) or `~=` (compatible release, PEP 440 style: at least the given version and sharing all of its numeric components except the last, so `~=0.31.0` accepts `0.31.1` but not `0.32.0`, while `~=0.31` accepts any `0.x` from `0.31` up but not `1.0.0`):
+
+```
+"lib:libcmark.so>=0.30.0"
+"lib:libcmark.so~=0.31.0"
+"pc:gtk4.pc>=4.12"
+```
+
+Version constraints are only valid where a resource's concrete names carry a version:
+
+- `lib`: the version is the trailing dotted-numeric tail of the soname (`libcmark.so.0.31.1` is version `0.31.1` of base `libcmark.so`). A dependency `lib:libcmark.so>=0.30.0` is satisfied by any installed or provided `libcmark.so.<tail>` whose tail satisfies the operator; a bare `libcmark.so` declares no version.
+- `pc`: the version is read from the `.pc` file's `Version:` field when checking the local system.
+
+Comparison semantics are prefix-tolerant on numeric components and ignore `+release` suffixes: `0.31` matches `0.31.0`. Constraints on any other resource type are a manifest validation error.
+
+In `provides`, only `pc` resource names may carry a constraint, only the `==` operator, and it acts as a **version declaration** for the concrete file (`"pc:cmark.pc==0.31.1": "as-expected"`). A `pc` provide without a declaration is assumed to carry the package's own `version`. During validation, a present `.pc` file whose `Version:` field matches neither the `==` declaration nor the package version invalidates the package, exactly like a missing file. Sonames already declare their version in the filename, so `==` on `lib` provides is redundant and rejected.
+
+Version-constrained dependencies are not yet forwarded to system package managers: they resolve from local presence or USM packages only.
+
 ## Installation Lifecycle Directories
 
 Each of these directories are created by USM at runtime for each package.
@@ -129,7 +151,7 @@ Each of these directories are created by USM at runtime for each package.
 
 ## Executable Scripts
 
-All executable scripts except for `remove` and `postInstall` are run from the source directory (see above). They may be anywhere in the source package, but are often placed in a folder at the root of the package called `scripts`.
+All executable scripts except for `remove` and `postInstall` are run from the source directory (see above). They may be anywhere in the source package, but are often placed in a folder at the root of the package called `usm-scripts` (recommended).
 
 - `build` is the only required exec script, it has the needed commands to actually compile the program from source. The build directory is passed to this exec as the first argument.
 - `install` when specified will install the files into the install directory, in the same structure that it would if that directory were the filesystem root. The build directory is passed to this exec as the first argument, the install directory is passed as the second argument, and the install type ("fresh", "upgrade", "downgrade") is passed as the third argument.

+ 17 - 0
containers/alpine/Dockerfile

@@ -0,0 +1,17 @@
+FROM alpine:latest
+
+# Install bash first (Alpine uses ash by default)
+RUN apk add --no-cache bash
+
+# Copy the installer
+COPY install-builddir/install-usm.sh /tmp/install-usm.sh
+RUN chmod +x /tmp/install-usm.sh
+
+# Run the installer non-interactively
+RUN /tmp/install-usm.sh -y
+
+# Clean up installer
+RUN rm /tmp/install-usm.sh
+
+# Default command
+CMD ["/usr/bin/usm"]

+ 14 - 0
containers/arch/Dockerfile

@@ -0,0 +1,14 @@
+FROM archlinux:latest
+
+# Copy the installer
+COPY install-builddir/install-usm.sh /tmp/install-usm.sh
+RUN chmod +x /tmp/install-usm.sh
+
+# Run the installer non-interactively
+RUN /tmp/install-usm.sh -y
+
+# Clean up installer
+RUN rm /tmp/install-usm.sh
+
+# Default command
+CMD ["/usr/bin/usm"]

+ 14 - 0
containers/debian/Dockerfile

@@ -0,0 +1,14 @@
+FROM debian:latest
+
+# Copy the installer
+COPY install-builddir/install-usm.sh /tmp/install-usm.sh
+RUN chmod +x /tmp/install-usm.sh
+
+# Run the installer non-interactively
+RUN /tmp/install-usm.sh -y
+
+# Clean up installer
+RUN rm /tmp/install-usm.sh
+
+# Default command
+CMD ["/usr/bin/usm"]

+ 14 - 0
containers/fedora/Dockerfile

@@ -0,0 +1,14 @@
+FROM fedora:latest
+
+# Copy the installer
+COPY install-builddir/install-usm.sh /tmp/install-usm.sh
+RUN chmod +x /tmp/install-usm.sh
+
+# Run the installer non-interactively
+RUN /tmp/install-usm.sh -y
+
+# Clean up installer
+RUN rm /tmp/install-usm.sh
+
+# Default command
+CMD ["/usr/bin/usm"]

+ 14 - 0
containers/ubuntu/Dockerfile

@@ -0,0 +1,14 @@
+FROM ubuntu:latest
+
+# Copy the installer
+COPY install-builddir/install-usm.sh /tmp/install-usm.sh
+RUN chmod +x /tmp/install-usm.sh
+
+# Run the installer non-interactively
+RUN /tmp/install-usm.sh -y
+
+# Clean up installer
+RUN rm /tmp/install-usm.sh
+
+# Default command
+CMD ["/usr/bin/usm"]

+ 6 - 0
fedora-x86_64.config

@@ -0,0 +1,6 @@
+{
+    "is_managed": false,
+    "paths": {
+        "lib": "lib64"
+    }
+}

+ 266 - 0
installer/build_config.sh

@@ -0,0 +1,266 @@
+#!/bin/bash
+# build_config.sh - Build and install configuration for USM and dependencies
+
+# Build order - dependencies must be built in this order
+BUILD_ORDER=("invercargill" "invercargill-json" "usm")
+
+# Component definitions
+declare -A COMPONENTS=(
+    ["invercargill"]="Invercargill|invercargill-1|https://fabrica.unitatem.net/Tilo15/Invercargill.git"
+    ["invercargill-json"]="Invercargill-Json|invercargill-json|https://git.sr.ht/~tilo15/Invercargill-Json"
+    ["usm"]="USM|usm|BUNDLED"
+)
+
+# Component properties accessor functions
+get_component_name() {
+    local key="$1"
+    echo "${COMPONENTS[$key]}" | cut -d'|' -f1
+}
+
+get_component_vapi() {
+    local key="$1"
+    echo "${COMPONENTS[$key]}" | cut -d'|' -f2
+}
+
+get_component_source() {
+    local key="$1"
+    echo "${COMPONENTS[$key]}" | cut -d'|' -f3
+}
+
+# Get output redirection based on VERBOSE flag
+get_output_redirect() {
+    if [[ "$VERBOSE" == "true" ]]; then
+        echo "/dev/stdout"
+    else
+        echo "/dev/null"
+    fi
+}
+
+# Build function for Invercargill
+build_invercargill() {
+    local src_dir="$1"
+    local prefix="$2"
+    local output_redirect=$(get_output_redirect)
+    
+    pushd "$src_dir" >/dev/null || return 1
+    
+    # Configure with meson (force libdir=lib for consistency across platforms)
+    if ! meson setup src builddir --prefix="$prefix" --libdir=lib -Ddefault_library=static >"$output_redirect" 2>&1; then
+        log_error "Failed to configure Invercargill"
+        [[ "$VERBOSE" != "true" ]] && log_info "Run with --verbose for details"
+        popd >/dev/null
+        return 1
+    fi
+    
+    # Build with ninja
+    if ! ninja -C builddir >"$output_redirect" 2>&1; then
+        log_error "Failed to build Invercargill"
+        [[ "$VERBOSE" != "true" ]] && log_info "Run with --verbose for details"
+        popd >/dev/null
+        return 1
+    fi
+    
+    # Install
+    if ! ninja -C builddir install >"$output_redirect" 2>&1; then
+        log_error "Failed to install Invercargill"
+        [[ "$VERBOSE" != "true" ]] && log_info "Run with --verbose for details"
+        popd >/dev/null
+        return 1
+    fi
+    
+    popd >/dev/null
+    log_info "Invercargill built and installed successfully"
+    return 0
+}
+
+# Build function for Invercargill-Json
+build_invercargill_json() {
+    local src_dir="$1"
+    local prefix="$2"
+    local output_redirect=$(get_output_redirect)
+    
+    pushd "$src_dir" >/dev/null || return 1
+    
+    # Set PKG_CONFIG_PATH to find bundled Invercargill library
+    export PKG_CONFIG_PATH="$prefix/lib/pkgconfig:$prefix/share/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
+    
+    # Set XDG_DATA_DIRS for GObject Introspection and Vala to find .gir and .vapi files
+    # Include default system paths to ensure system vapi files are found
+    export XDG_DATA_DIRS="$prefix/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}:/usr/local/share:/usr/share"
+    
+    # Configure with meson (force libdir=lib for consistency across platforms)
+    if ! meson setup src builddir --prefix="$prefix" --libdir=lib -Ddefault_library=static >"$output_redirect" 2>&1; then
+        log_error "Failed to configure Invercargill-Json"
+        [[ "$VERBOSE" != "true" ]] && log_info "Run with --verbose for details"
+        popd >/dev/null
+        return 1
+    fi
+    
+    # Build with ninja
+    if ! ninja -C builddir >"$output_redirect" 2>&1; then
+        log_error "Failed to build Invercargill-Json"
+        [[ "$VERBOSE" != "true" ]] && log_info "Run with --verbose for details"
+        popd >/dev/null
+        return 1
+    fi
+    
+    # Install
+    if ! ninja -C builddir install >"$output_redirect" 2>&1; then
+        log_error "Failed to install Invercargill-Json"
+        [[ "$VERBOSE" != "true" ]] && log_info "Run with --verbose for details"
+        popd >/dev/null
+        return 1
+    fi
+    
+    popd >/dev/null
+    log_info "Invercargill-Json built and installed successfully"
+    return 0
+}
+
+# Build function for USM
+build_usm() {
+    local src_dir="$1"
+    local prefix="$2"
+    local output_redirect=$(get_output_redirect)
+    
+    pushd "$src_dir" >/dev/null || return 1
+    
+    # Set PKG_CONFIG_PATH to find bundled Invercargill libraries
+    export PKG_CONFIG_PATH="$prefix/lib/pkgconfig:$prefix/share/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
+    
+    # Set XDG_DATA_DIRS for GObject Introspection and Vala to find .gir and .vapi files
+    # Include default system paths to ensure system vapi files are found
+    export XDG_DATA_DIRS="$prefix/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}:/usr/local/share:/usr/share"
+    
+    # Configure with meson (USM's meson.build is at root, force libdir=lib for consistency)
+    if ! meson setup builddir --prefix="$prefix" --libdir=lib >"$output_redirect" 2>&1; then
+        log_error "Failed to configure USM"
+        [[ "$VERBOSE" != "true" ]] && log_info "Run with --verbose for details"
+        popd >/dev/null
+        return 1
+    fi
+    
+    # Build with ninja
+    if ! ninja -C builddir >"$output_redirect" 2>&1; then
+        log_error "Failed to build USM"
+        [[ "$VERBOSE" != "true" ]] && log_info "Run with --verbose for details"
+        popd >/dev/null
+        return 1
+    fi
+    
+    # Install
+    if ! ninja -C builddir install >"$output_redirect" 2>&1; then
+        log_error "Failed to install USM"
+        [[ "$VERBOSE" != "true" ]] && log_info "Run with --verbose for details"
+        popd >/dev/null
+        return 1
+    fi
+    
+    popd >/dev/null
+    log_info "USM built and installed successfully"
+    return 0
+}
+
+# Generic build dispatcher
+build_component() {
+    local component="$1"
+    local src_dir="$2"
+    local prefix="$3"
+    
+    case "$component" in
+        "invercargill")
+            build_invercargill "$src_dir" "$prefix"
+            ;;
+        "invercargill-json")
+            build_invercargill_json "$src_dir" "$prefix"
+            ;;
+        "usm")
+            build_usm "$src_dir" "$prefix"
+            ;;
+        *)
+            log_error "Unknown component: $component"
+            return 1
+            ;;
+    esac
+}
+
+# Install shim to /usr/bin
+install_shim() {
+    local target_dir="$1"
+    local shim_path="/usr/bin/usm"
+    local config_dir="/etc/usm"
+    local config_path="$config_dir/usm.config"
+    local sudo=""
+    
+    if ! is_root; then
+        sudo=$(get_sudo)
+    fi
+    
+    log_step "Installing shim to $shim_path..."
+    
+    local shim_content="#!/bin/bash
+export LD_LIBRARY_PATH=\"${target_dir}/lib\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}\"
+exec \"${target_dir}/bin/usm\" \"\$@\"
+"
+    
+    if [[ -n "$sudo" ]]; then
+        echo "$shim_content" | $sudo tee "$shim_path" >/dev/null
+        $sudo chmod +x "$shim_path"
+    else
+        echo "$shim_content" > "$shim_path"
+        chmod +x "$shim_path"
+    fi
+    
+    log_info "Shim installed to $shim_path"
+    
+    # Generate USM configuration file only if it doesn't already exist
+    if [[ -f "$config_path" ]]; then
+        log_info "Configuration file already exists at $config_path, skipping generation"
+    else
+        log_step "Generating USM configuration..."
+        
+        # Create config directory if it doesn't exist
+        if [[ -n "$sudo" ]]; then
+            $sudo mkdir -p "$config_dir"
+        else
+            mkdir -p "$config_dir"
+        fi
+        
+        # Generate config using usm genconfig
+        if [[ -n "$sudo" ]]; then
+            usm genconfig | $sudo tee "$config_path" >/dev/null
+        else
+            usm genconfig > "$config_path"
+        fi
+        
+        log_info "Configuration generated at $config_path"
+    fi
+}
+
+# Build all components in order
+build_all() {
+    local src_base="$1"
+    local prefix="$2"
+    local total=${#BUILD_ORDER[@]}
+    local current=0
+    
+    for component in "${BUILD_ORDER[@]}"; do
+        ((current++))
+        log_step "[$current/$total] Building $(get_component_name "$component")..."
+        
+        local src_dir
+        
+        if [[ "$(get_component_source "$component")" == "BUNDLED" ]]; then
+            src_dir="$src_base/usm"
+        else
+            src_dir="$src_base/$(get_component_name "$component")"
+        fi
+        
+        if ! build_component "$component" "$src_dir" "$prefix"; then
+            log_error "Failed to build $(get_component_name "$component"). Installation halted."
+            return 1
+        fi
+    done
+    
+    return 0
+}

+ 224 - 0
installer/compile.sh

@@ -0,0 +1,224 @@
+#!/bin/bash
+# compile.sh - Compile installer sources into a single distributable script
+# This script is run during the build process to create the final installer
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+OUTPUT_FILE="${1:-install-usm.sh}"
+VERSION="${2:-0.1.0}"
+USM_SOURCE_DIR="${3:-$SCRIPT_DIR/../src}"
+
+INVERCARGILL_TAG="v1.0.1"      # e.g. "v1.0.0"; empty = default branch
+INVERCARGILL_JSON_TAG="v0.1.1" # e.g. "v0.1.1"; empty = default branch
+USM_TAG=""               # empty = use local source directory
+
+# Temporary directory for building
+WORK_DIR=$(mktemp -d)
+trap "rm -rf $WORK_DIR" EXIT
+
+echo "Compiling USM installer..."
+echo "  Version: $VERSION"
+echo "  Output: $OUTPUT_FILE"
+if [[ -n "$USM_TAG" ]]; then
+    echo "  USM Source: usm.git at tag \"$USM_TAG\""
+else
+    echo "  USM Source: $USM_SOURCE_DIR"
+fi
+
+# Step 1: Clone external repositories
+echo "Cloning external repositories..."
+SOURCES_DIR="$WORK_DIR/sources"
+mkdir -p "$SOURCES_DIR"
+
+# Clone Invercargill
+INVERCARGILL_BRANCH_ARGS=()
+if [[ -n "$INVERCARGILL_TAG" ]]; then
+    INVERCARGILL_BRANCH_ARGS+=(--branch "$INVERCARGILL_TAG")
+fi
+if [[ ! -d "$SOURCES_DIR/Invercargill" ]]; then
+    echo "  Cloning Invercargill..."
+    git clone --depth 1 "${INVERCARGILL_BRANCH_ARGS[@]}" https://fabrica.unitatem.net/Tilo15/Invercargill.git "$SOURCES_DIR/Invercargill" 2>/dev/null || {
+        echo "Warning: Could not clone Invercargill, will try without --depth 1"
+        git clone "${INVERCARGILL_BRANCH_ARGS[@]}" https://fabrica.unitatem.net/Tilo15/Invercargill.git "$SOURCES_DIR/Invercargill" 2>/dev/null || {
+            echo "Error: Could not clone Invercargill repository"
+            exit 1
+        }
+    }
+fi
+
+# Clone Invercargill-Json
+INVERCARGILL_JSON_BRANCH_ARGS=()
+if [[ -n "$INVERCARGILL_JSON_TAG" ]]; then
+    INVERCARGILL_JSON_BRANCH_ARGS+=(--branch "$INVERCARGILL_JSON_TAG")
+fi
+if [[ ! -d "$SOURCES_DIR/Invercargill-Json" ]]; then
+    echo "  Cloning Invercargill-Json..."
+    git clone --depth 1 "${INVERCARGILL_JSON_BRANCH_ARGS[@]}" https://fabrica.unitatem.net/Tilo15/Invercargill-Json.git "$SOURCES_DIR/Invercargill-Json" 2>/dev/null || {
+        echo "Warning: Could not clone Invercargill-Json, will try without --depth 1"
+        git clone "${INVERCARGILL_JSON_BRANCH_ARGS[@]}" https://fabrica.unitatem.net/Tilo15/Invercargill-Json.git "$SOURCES_DIR/Invercargill-Json" 2>/dev/null || {
+            echo "Error: Could not clone Invercargill-Json repository"
+            exit 1
+        }
+    }
+fi
+
+# Clone USM at a tag instead of using the local source tree; the copy steps
+# below read everything through USM_SOURCE_DIR, so pointing it at the clone's
+# src/ picks up the tagged MANIFEST.usm, usm.config and spm/ helpers as well
+if [[ -n "$USM_TAG" ]]; then
+    echo "  Cloning USM at tag \"$USM_TAG\"..."
+    USM_CLONE_DIR="$WORK_DIR/usm-clone"
+    git clone --depth 1 --branch "$USM_TAG" https://fabrica.unitatem.net/Tilo15/usm.git "$USM_CLONE_DIR" 2>/dev/null || {
+        echo "Warning: Could not clone USM at tag \"$USM_TAG\", will try without --depth 1"
+        git clone --branch "$USM_TAG" https://fabrica.unitatem.net/Tilo15/usm.git "$USM_CLONE_DIR" 2>/dev/null || {
+            echo "Error: Could not clone USM repository at tag \"$USM_TAG\""
+            exit 1
+        }
+    }
+    USM_SOURCE_DIR="$USM_CLONE_DIR/src"
+fi
+
+# Copy USM source (USM's meson.build is in src/, not project root)
+echo "Copying USM source..."
+mkdir -p "$SOURCES_DIR/usm"
+# USM_SOURCE_DIR points to the src/ directory which contains meson.build
+# Copy the entire src directory contents to usm/
+cp -r "$USM_SOURCE_DIR"/* "$SOURCES_DIR/usm/" 2>/dev/null || true
+# Copy additional root level files for reference
+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 SPM helpers live in spm/ at the project root, outside src/, but
+# src/meson.build configures them via '../spm/<spm>/usm-spm-<spm>' — the
+# payload must therefore carry spm/ as a SIBLING of usm/ so the relative
+# inputs resolve inside the extracted tree
+echo "Copying USM SPM helpers..."
+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"
+
+cat > "$HEADER_FILE" << SCRIPT_HEADER
+#!/bin/bash
+# USM Installer - self-contained installation script
+# This script was automatically generated. Do not edit.
+# Version: $VERSION
+# Generated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
+
+# Materialise to a temp file when piped (curl | sh), then re-exec WITHOUT
+# sudo so the guided install flow (banner → summary → confirm → sudo prompt)
+# runs exactly as if the user had downloaded and run the script directly
+if [[ ! -f "\$0" ]]; then
+    echo "Downloading USM installer..."
+    _usm_tmp=\$(mktemp /tmp/usm-install.XXXXXX.sh)
+    _usm_bytes=\$(tee "\$_usm_tmp" | wc -c)
+    _usm_kb=\$((_usm_bytes / 1024))
+    chmod +x "\$_usm_tmp"
+    echo "  Downloaded \${_usm_kb}KB"
+    echo ""
+    exec bash "\$_usm_tmp" "\$@" < /dev/tty
+fi
+
+set -e
+
+SCRIPT_HEADER
+
+# Step 3: Auto-detect package manager implementations
+PM_IMPLEMENTATIONS=()
+for pm_file in "$SCRIPT_DIR"/pm_*.sh; do
+    if [[ -f "$pm_file" ]]; then
+        # Extract PM name from filename (pm_apt.sh -> apt)
+        pm_name=$(basename "$pm_file" .sh | sed 's/^pm_//')
+        # Skip pm_base.sh - it's the interface, not an implementation
+        if [[ "$pm_name" != "base" ]]; then
+            PM_IMPLEMENTATIONS+=("$pm_name")
+        fi
+    fi
+done
+
+echo "  Detected package managers: ${PM_IMPLEMENTATIONS[*]}"
+
+# Step 4: Combine all shell scripts
+COMBINED_FILE="$WORK_DIR/combined.sh"
+
+# Build the PM implementations array initialization
+PM_ARRAY_FILE="$WORK_DIR/pm_array.sh"
+cat > "$PM_ARRAY_FILE" << PM_ARRAY
+# Auto-detected package manager implementations
+PM_IMPLEMENTATIONS=(${PM_IMPLEMENTATIONS[*]})
+
+PM_ARRAY
+
+# Function to process a script file
+process_script() {
+    local file="$1"
+    local section="$2"
+    
+    echo "# === $section ==="
+    cat "$file"
+}
+
+# Combine scripts in order
+{
+    process_script "$SCRIPT_DIR/utils.sh" "Utils"
+    echo ""
+    process_script "$SCRIPT_DIR/ui.sh" "UI"
+    echo ""
+    # Include PM implementations first
+    for pm_name in "${PM_IMPLEMENTATIONS[@]}"; do
+        process_script "$SCRIPT_DIR/pm_${pm_name}.sh" "Package Manager ${pm_name}"
+        echo ""
+    done
+    # Include PM array initialization
+    cat "$PM_ARRAY_FILE"
+    echo ""
+    # Include pm_base which uses the array
+    process_script "$SCRIPT_DIR/pm_base.sh" "Package Manager Base"
+    echo ""
+    process_script "$SCRIPT_DIR/build_config.sh" "Build Config"
+    echo ""
+    process_script "$SCRIPT_DIR/main.sh" "Main"
+    echo ""
+    # Add the main call at the very end
+    echo "# Run main function"
+    echo 'main "$@"'
+    echo ""
+    echo "# Exit before payload"
+    echo "exit 0"
+} > "$COMBINED_FILE"
+
+# Replace version placeholder in combined file
+sed -i "s/@VERSION@/$VERSION/g" "$COMBINED_FILE"
+
+# Step 5: Create the payload
+echo "Creating compressed payload..."
+PAYLOAD_FILE="$WORK_DIR/payload.tar.xz"
+
+# Use highest compression for smaller file size
+tar -C "$SOURCES_DIR" -cf - . | xz -9 -T0 > "$PAYLOAD_FILE"
+
+# Step 6: Assemble final installer
+echo "Assembling final installer..."
+{
+    cat "$HEADER_FILE"
+    cat "$COMBINED_FILE"
+    echo ""
+    echo "__PAYLOAD_START__"
+    cat "$PAYLOAD_FILE"
+} > "$OUTPUT_FILE"
+
+# Make executable
+chmod +x "$OUTPUT_FILE"
+
+# Calculate output size
+OUTPUT_SIZE=$(du -h "$OUTPUT_FILE" | cut -f1)
+echo ""
+echo "✓ Installer created successfully!"
+echo "  Output: $OUTPUT_FILE"
+echo "  Size: $OUTPUT_SIZE"

Fișier diff suprimat deoarece este prea mare
+ 2096 - 0
installer/install-usm-unified.sh


Fișier diff suprimat deoarece este prea mare
+ 1502 - 0
installer/install-usm.sh


+ 214 - 0
installer/main.sh

@@ -0,0 +1,214 @@
+#!/bin/bash
+# main.sh - Main entry point for USM installer
+# This file will be the entry point of the compiled installer
+
+# Default configuration
+TARGET_DIR="/opt/usm"
+ASSUME_YES="false"
+VERBOSE="false"
+SCRIPT_VERSION="@VERSION@"
+PM_TYPE=""
+ORIGINAL_ARGS=("$@")
+
+# Parse command line arguments
+parse_args() {
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --target|-t)
+                TARGET_DIR="$2"
+                shift 2
+                ;;
+            --target=*)
+                TARGET_DIR="${1#*=}"
+                shift
+                ;;
+            -y|--yes|--assume-yes)
+                ASSUME_YES="true"
+                shift
+                ;;
+            --verbose|-V)
+                VERBOSE="true"
+                shift
+                ;;
+            --help|-h)
+                show_help
+                exit 0
+                ;;
+            --version|-v)
+                echo "USM Installer v${SCRIPT_VERSION}"
+                exit 0
+                ;;
+            *)
+                echo "Unknown option: $1"
+                show_help
+                exit 1
+                ;;
+        esac
+    done
+}
+
+# Show help message
+show_help() {
+    cat << EOF
+USM Installer v${SCRIPT_VERSION}
+
+Usage: $(basename "$0") [OPTIONS]
+
+Options:
+    -t, --target DIR    Install to DIR instead of /opt/usm
+    -y, --assume-yes    Answer yes to all prompts automatically
+    -V, --verbose       Show verbose build output
+    -h, --help          Show this help message
+    -v, --version       Show version information
+
+Examples:
+    $(basename "$0")                    # Install to /opt/usm
+    $(basename "$0") -t /opt/myusm     # Install to /opt/myusm
+    $(basename "$0") -y                # Non-interactive install
+    $(basename "$0") -V                # Verbose output
+
+EOF
+}
+
+# Check for root privileges and offer to re-run with sudo
+check_root() {
+    if is_root; then
+        return 0
+    fi
+    
+    local sudo_cmd=$(get_sudo)
+    
+    if [[ -z "$sudo_cmd" ]]; then
+        show_error_and_exit "This script requires root privileges. Please run with sudo or as root."
+    fi
+    
+    log_error "This script requires root privileges."
+    
+    if confirm "Would you like to re-run the script with $sudo_cmd?" "y"; then
+        log_info "Re-running with $sudo_cmd..."
+        exec $sudo_cmd "$0" "${ORIGINAL_ARGS[@]}"
+    else
+        show_error_and_exit "Root privileges are required. Installation cancelled."
+    fi
+}
+
+# Check if target directory exists and handle reinstallation
+check_existing_installation() {
+    if [[ -d "$TARGET_DIR" ]]; then
+        log_warn "Target directory '$TARGET_DIR' already exists."
+        
+        if [[ "$ASSUME_YES" == "true" ]]; then
+            log_info "Removing existing installation (auto-confirmed with -y)..."
+            rm -rf "$TARGET_DIR"
+            return 0
+        fi
+        
+        if confirm "Do you want to delete it and reinstall?" "n"; then
+            log_info "Removing existing installation..."
+            rm -rf "$TARGET_DIR"
+        else
+            echo "Installation cancelled."
+            exit 0
+        fi
+    fi
+}
+
+# Install the system package manager helper matching the detected PM from
+# the extracted payload to /usr/bin. Runs before config generation so the
+# `usm genconfig` call in install_shim detects the system package manager
+# and wires the generated usm.config to this helper.
+install_spm_shim() {
+    local extract_dir="$1"
+    local shim_name="$PM_TYPE"
+    local source_shim="$extract_dir/spm/$shim_name/usm-spm-$shim_name"
+    local shim_path="/usr/bin/usm-spm-$shim_name"
+    local sudo=""
+    
+    if ! is_root; then
+        sudo=$(get_sudo)
+    fi
+    
+    if [[ ! -f "$source_shim" ]]; then
+        log_error "SPM helper \"$source_shim\" not found in the installer payload"
+        return 1
+    fi
+    
+    log_step "Installing SPM helper to $shim_path..."
+    
+    if [[ -n "$sudo" ]]; then
+        $sudo cp "$source_shim" "$shim_path" && $sudo chmod 755 "$shim_path"
+    else
+        cp "$source_shim" "$shim_path" && chmod 755 "$shim_path"
+    fi
+}
+
+# Main installation function
+run_installation() {
+    # Install system dependencies first (before extracting sources)
+    log_step "Checking system dependencies..."
+    if ! pm_install_missing_deps; then
+        show_error_and_exit "Failed to install system dependencies. Installation halted."
+    fi
+    
+    # Set up temporary directory
+    setup_temp_dir
+    local extract_dir="$TEMP_DIR/sources"
+    ensure_dir "$extract_dir"
+    
+    # Extract bundled sources
+    log_step "Extracting bundled sources..."
+    if ! extract_payload "$extract_dir"; then
+        show_error_and_exit "Failed to extract bundled sources"
+    fi
+    
+    # Build and install all components
+    log_step "Building and installing components..."
+    if ! build_all "$extract_dir" "$TARGET_DIR"; then
+        show_error_and_exit "Failed to build components. Installation halted."
+    fi
+    
+    # Install the SPM helper before the shim step so config generation sees it
+    if ! install_spm_shim "$extract_dir"; then
+        show_error_and_exit "Failed to install the SPM helper. Installation halted."
+    fi
+    
+    # Install shim
+    install_shim "$TARGET_DIR"
+    
+    # Show completion message
+    show_completion "$TARGET_DIR"
+}
+
+# Main entry point
+main() {
+    parse_args "$@"
+    
+    # Print banner
+    print_banner
+    
+    # Check for root privileges
+    check_root
+    
+    # Detect package manager
+    if ! detect_package_manager; then
+        show_error_and_exit "Could not detect a supported package manager (apk, apt, dnf, emerge or pacman)"
+    fi
+    
+    log_info "Detected package manager: ${PM_TYPE}"
+    
+    # Get missing dependencies for display
+    local missing_deps=$(pm_get_missing_deps)
+    local deps_count=$(count_missing_deps)
+    
+    # Show installation summary and get confirmation
+    if ! show_installation_summary "$TARGET_DIR" "$PM_TYPE" "$deps_count" "$missing_deps"; then
+        echo "Installation cancelled."
+        exit 0
+    fi
+    
+    # Check if target directory exists and handle reinstallation
+    check_existing_installation
+    
+    # Run the installation
+    run_installation
+}

+ 65 - 0
installer/meson.build

@@ -0,0 +1,65 @@
+# meson.build - USM Installer build configuration
+# This is a standalone project for building the USM installer
+
+project('usm-installer', 'c',
+    version: '0.1.0',
+    default_options: ['warning_level=1']
+)
+
+installer_dir = meson.current_source_dir()
+project_root = installer_dir / '..'
+usm_source_dir = project_root / 'src'
+
+# Version from project
+version = meson.project_version()
+
+# Find required programs
+bash = find_program('bash', required: true)
+tar = find_program('tar', required: true)
+xz = find_program('xz', required: true)
+git = find_program('git', required: true)
+
+# Installer source files
+installer_sources = files(
+    'utils.sh',
+    'ui.sh',
+    'pm_base.sh',
+    'pm_apk.sh',
+    'pm_apt.sh',
+    'pm_dnf.sh',
+    'pm_emerge.sh',
+    'pm_pacman.sh',
+    'build_config.sh',
+    'main.sh'
+)
+
+# Compile script
+compile_script = installer_dir / 'compile.sh'
+
+# Output directory and file
+output_dir = installer_dir / 'dist'
+installer_output = output_dir / 'install-usm.sh'
+
+# Ensure the output directory exists
+run_command('mkdir', '-p', output_dir, check: false)
+
+# Custom command to build the installer
+build_installer = custom_target(
+    'install-usm.sh',
+    output: 'install-usm.sh',
+    input: installer_sources,
+    command: [
+        bash, compile_script,
+        '@OUTPUT@',
+        version,
+        usm_source_dir
+    ],
+    build_by_default: true,
+    install: false,
+)
+
+# Create a custom target for distribution
+alias_target('installer', build_installer)
+
+# Message about the installer
+message('USM Installer will be built to: ' + installer_output)

+ 58 - 0
installer/pm_apk.sh

@@ -0,0 +1,58 @@
+#!/bin/bash
+# pm_apk.sh - APK package manager implementation for Alpine Linux systems
+# 
+# To add a new package manager, create a file named pm_<name>.sh with:
+#   - pm_<name>_detect: Returns 0 if this PM is available, 1 otherwise
+#   - pm_<name>_get_missing_deps: Prints space-separated list of missing packages
+#   - pm_<name>_install_missing_deps: Installs the missing packages
+
+# Package names required by USM
+APK_DEPS="vala meson ninja pkgconf gcc musl-dev glib-dev glib-networking libsodium-dev json-glib-dev libarchive-dev libgee-dev xz gobject-introspection gobject-introspection-dev"
+
+# Check if this package manager is available
+pm_apk_detect() {
+    command -v apk &>/dev/null
+}
+
+# Get list of missing dependencies
+pm_apk_get_missing_deps() {
+    local -a missing=()
+    
+    for pkg in $APK_DEPS; do
+        if ! apk info -e "$pkg" &>/dev/null; then
+            missing+=("$pkg")
+        fi
+    done
+    
+    echo "${missing[*]}"
+}
+
+# Install missing dependencies
+pm_apk_install_missing_deps() {
+    local missing=$(pm_apk_get_missing_deps)
+    
+    if [[ -z "$missing" ]]; then
+        log_info "All dependencies are already installed"
+        return 0
+    fi
+    
+    log_step "Installing packages via apk: ${missing}"
+    
+    local sudo=""
+    if ! is_root; then
+        sudo=$(get_sudo)
+    fi
+    
+    local apk_opts="--quiet"
+    if [[ "$ASSUME_YES" == "true" ]]; then
+        apk_opts="--quiet --force"
+    fi
+    
+    if [[ -n "$sudo" ]]; then
+        $sudo apk update
+        $sudo apk add $apk_opts $missing
+    else
+        apk update
+        apk add $apk_opts $missing
+    fi
+}

+ 58 - 0
installer/pm_apt.sh

@@ -0,0 +1,58 @@
+#!/bin/bash
+# pm_apt.sh - APT package manager implementation for Debian/Ubuntu systems
+# 
+# To add a new package manager, create a file named pm_<name>.sh with:
+#   - pm_<name>_detect: Returns 0 if this PM is available, 1 otherwise
+#   - pm_<name>_get_missing_deps: Prints space-separated list of missing packages
+#   - pm_<name>_install_missing_deps: Installs the missing packages
+
+# Package names required by USM
+APT_DEPS="valac meson ninja-build pkg-config gcc libglib2.0-dev libglib-networking libsodium-dev libjson-glib-dev libarchive-dev libgee-0.8-dev xz-utils gobject-introspection"
+
+# Check if this package manager is available
+pm_apt_detect() {
+    command -v apt-get &>/dev/null
+}
+
+# Get list of missing dependencies
+pm_apt_get_missing_deps() {
+    local -a missing=()
+    
+    for pkg in $APT_DEPS; do
+        if ! dpkg -l "$pkg" 2>/dev/null | grep -q "^ii"; then
+            missing+=("$pkg")
+        fi
+    done
+    
+    echo "${missing[*]}"
+}
+
+# Install missing dependencies
+pm_apt_install_missing_deps() {
+    local missing=$(pm_apt_get_missing_deps)
+    
+    if [[ -z "$missing" ]]; then
+        log_info "All dependencies are already installed"
+        return 0
+    fi
+    
+    log_step "Installing packages via apt: ${missing}"
+    
+    local sudo=""
+    if ! is_root; then
+        sudo=$(get_sudo)
+    fi
+    
+    local apt_opts="-y -qq"
+    if [[ "$ASSUME_YES" == "true" ]]; then
+        apt_opts="-y -qq --allow-downgrades --allow-remove-essential --allow-change-held-packages"
+    fi
+    
+    if [[ -n "$sudo" ]]; then
+        DEBIAN_FRONTEND=noninteractive $sudo apt-get update -qq
+        DEBIAN_FRONTEND=noninteractive $sudo apt-get install $apt_opts $missing
+    else
+        DEBIAN_FRONTEND=noninteractive apt-get update -qq
+        DEBIAN_FRONTEND=noninteractive apt-get install $apt_opts $missing
+    fi
+}

+ 67 - 0
installer/pm_base.sh

@@ -0,0 +1,67 @@
+#!/bin/bash
+# pm_base.sh - Base package manager interface for USM installer
+# 
+# This file provides the generic interface that dispatches to the
+# appropriate package manager implementation.
+#
+# To add a new package manager:
+#   1. Create pm_<name>.sh with:
+#      - pm_<name>_detect: Returns 0 if available, 1 otherwise
+#      - pm_<name>_get_missing_deps: Prints missing package names
+#      - pm_<name>_install_missing_deps: Installs missing packages
+#   2. The compiler will automatically pick it up
+
+# Detect the available package manager
+# Sets PM_TYPE and returns 0 on success, 1 if no supported PM found
+detect_package_manager() {
+    # Try each PM implementation
+    for pm_impl in "${PM_IMPLEMENTATIONS[@]}"; do
+        if "pm_${pm_impl}_detect" 2>/dev/null; then
+            PM_TYPE="$pm_impl"
+            return 0
+        fi
+    done
+    
+    PM_TYPE="unknown"
+    return 1
+}
+
+# Get missing dependencies using the detected package manager
+pm_get_missing_deps() {
+    case "$PM_TYPE" in
+        apk) pm_apk_get_missing_deps "$@" ;;
+        apt) pm_apt_get_missing_deps "$@" ;;
+        dnf) pm_dnf_get_missing_deps "$@" ;;
+        emerge) pm_emerge_get_missing_deps "$@" ;;
+        pacman) pm_pacman_get_missing_deps "$@" ;;
+        *)
+            log_error "No package manager detected"
+            echo ""
+            ;;
+    esac
+}
+
+# Install missing dependencies using the detected package manager
+pm_install_missing_deps() {
+    case "$PM_TYPE" in
+        apk) pm_apk_install_missing_deps "$@" ;;
+        apt) pm_apt_install_missing_deps "$@" ;;
+        dnf) pm_dnf_install_missing_deps "$@" ;;
+        emerge) pm_emerge_install_missing_deps "$@" ;;
+        pacman) pm_pacman_install_missing_deps "$@" ;;
+        *)
+            log_error "No package manager detected"
+            return 1
+            ;;
+    esac
+}
+
+# Count missing dependencies
+count_missing_deps() {
+    local missing=$(pm_get_missing_deps)
+    if [[ -z "$missing" ]]; then
+        echo 0
+    else
+        echo "$missing" | wc -w
+    fi
+}

+ 58 - 0
installer/pm_dnf.sh

@@ -0,0 +1,58 @@
+#!/bin/bash
+# pm_dnf.sh - DNF package manager implementation for Fedora/RHEL systems
+# 
+# To add a new package manager, create a file named pm_<name>.sh with:
+#   - pm_<name>_detect: Returns 0 if this PM is available, 1 otherwise
+#   - pm_<name>_get_missing_deps: Prints space-separated list of missing packages
+#   - pm_<name>_install_missing_deps: Installs the missing packages
+
+# Package names required by USM
+DNF_DEPS="vala meson ninja-build pkgconf-pkg-config gcc glib2-devel glib-networking libsodium-devel json-glib-devel libarchive-devel libgee-devel xz gobject-introspection"
+
+# Check if this package manager is available
+pm_dnf_detect() {
+    command -v dnf &>/dev/null
+}
+
+# Get list of missing dependencies
+pm_dnf_get_missing_deps() {
+    local -a missing=()
+    
+    for pkg in $DNF_DEPS; do
+        if ! rpm -q "$pkg" &>/dev/null; then
+            missing+=("$pkg")
+        fi
+    done
+    
+    echo "${missing[*]}"
+}
+
+# Install missing dependencies
+pm_dnf_install_missing_deps() {
+    local missing=$(pm_dnf_get_missing_deps)
+    
+    if [[ -z "$missing" ]]; then
+        log_info "All dependencies are already installed"
+        return 0
+    fi
+    
+    log_step "Installing packages via dnf: ${missing}"
+    
+    local sudo=""
+    if ! is_root; then
+        sudo=$(get_sudo)
+    fi
+    
+    local dnf_opts="-y --quiet"
+    if [[ "$ASSUME_YES" == "true" ]]; then
+        dnf_opts="-y --quiet --skip-broken"
+    fi
+    
+    if [[ -n "$sudo" ]]; then
+        $sudo dnf makecache -q 2>/dev/null || $sudo dnf clean metadata
+        $sudo dnf install $dnf_opts $missing
+    else
+        dnf makecache -q 2>/dev/null || dnf clean metadata
+        dnf install $dnf_opts $missing
+    fi
+}

+ 66 - 0
installer/pm_emerge.sh

@@ -0,0 +1,66 @@
+#!/bin/bash
+# pm_emerge.sh - Portage (emerge) package manager implementation for Gentoo systems
+#
+# To add a new package manager, create a file named pm_<name>.sh with:
+#  - pm_<name>_detect: Returns 0 if this PM is available, 1 otherwise
+#  - pm_<name>_get_missing_deps: Prints space-separated list of missing packages
+#  - pm_<name>_install_missing_deps: Installs the missing packages
+#
+# Gentoo notes:
+#  - EMERGE_DEPS are Gentoo atoms mirroring the dnf/apt dependency sets
+#    (vala, meson, ninja, pkgconf, gcc, glib, libsodium, json-glib,
+#    libarchive, libgee, xz-utils, gobject-introspection). A stage3 already
+#    carries gcc and friends; portageq skips whatever is present.
+#  - Everything is compiled from source: this step can take a long time.
+#  - USE flags are NOT modified. The stack's platform libraries (glib,
+#    json-glib, libgee, ...) enable their introspection USE flag by default
+#    and vala ships the platform .vapi bindings itself; only a system that
+#    explicitly disabled introspection via package.use/make.conf will lack
+#    .gir/.typelib/.vapi artifacts after install — re-enable the flag in
+#    /etc/portage/package.use and re-emerge if the USM build needs them.
+
+# Package atoms required by USM
+EMERGE_DEPS="dev-lang/vala dev-build/meson dev-build/ninja dev-util/pkgconf sys-devel/gcc dev-libs/glib net-libs/glib-networking dev-libs/libsodium dev-libs/json-glib app-arch/libarchive dev-libs/libgee app-arch/xz-utils dev-libs/gobject-introspection"
+
+# Check if this package manager is available
+pm_emerge_detect() {
+    command -v emerge &>/dev/null
+}
+
+# Get list of missing dependencies
+pm_emerge_get_missing_deps() {
+    local -a missing=()
+    
+    for pkg in $EMERGE_DEPS; do
+        if ! portageq has_version / "$pkg" &>/dev/null; then
+            missing+=("$pkg")
+        fi
+    done
+    
+    echo "${missing[*]}"
+}
+
+# Install missing dependencies
+pm_emerge_install_missing_deps() {
+    local missing=$(pm_emerge_get_missing_deps)
+    
+    if [[ -z "$missing" ]]; then
+        log_info "All dependencies are already installed"
+        return 0
+    fi
+    
+    log_step "Installing packages via emerge: ${missing}"
+    
+    local sudo=""
+    if ! is_root; then
+        sudo=$(get_sudo)
+    fi
+    
+    local emerge_opts="--ask=n --quiet=y"
+    
+    if [[ -n "$sudo" ]]; then
+        $sudo emerge $emerge_opts $missing
+    else
+        emerge $emerge_opts $missing
+    fi
+}

+ 60 - 0
installer/pm_pacman.sh

@@ -0,0 +1,60 @@
+#!/bin/bash
+# pm_pacman.sh - pacman package manager implementation for Arch systems
+#
+# To add a new package manager, create a file named pm_<name>.sh with:
+#  - pm_<name>_detect: Returns 0 if this PM is available, 1 otherwise
+#  - pm_<name>_get_missing_deps: Prints space-separated list of missing packages
+#  - pm_<name>_install_missing_deps: Installs the missing packages
+#
+# Arch notes:
+#  - PACMAN_DEPS mirror the dnf/apt dependency sets. Arch carries no
+#    -dev split: glib2, json-glib, libgee, gobject-introspection ship
+#    headers, pkg-config, .gir/.typelib and .vapi artifacts themselves,
+#    and glibc already bundles the crt objects gcc needs, so no
+#    companion package (musl-dev style) is required.
+
+# Package names required by USM
+PACMAN_DEPS="vala meson ninja pkgconf gcc glib2 libsodium json-glib libarchive libgee xz gobject-introspection"
+
+# Check if this package manager is available
+pm_pacman_detect() {
+    command -v pacman &>/dev/null
+}
+
+# Get list of missing dependencies
+pm_pacman_get_missing_deps() {
+    local -a missing=()
+
+    for pkg in $PACMAN_DEPS; do
+        if ! pacman -Q "$pkg" &>/dev/null; then
+            missing+=("$pkg")
+        fi
+    done
+
+    echo "${missing[*]}"
+}
+
+# Install missing dependencies
+pm_pacman_install_missing_deps() {
+    local missing=$(pm_pacman_get_missing_deps)
+
+    if [[ -z "$missing" ]]; then
+        log_info "All dependencies are already installed"
+        return 0
+    fi
+
+    log_step "Installing packages via pacman: ${missing}"
+
+    local sudo=""
+    if ! is_root; then
+        sudo=$(get_sudo)
+    fi
+
+    local pacman_opts="--needed --noconfirm"
+
+    if [[ -n "$sudo" ]]; then
+        $sudo pacman -Sy $pacman_opts $missing
+    else
+        pacman -Sy $pacman_opts $missing
+    fi
+}

+ 167 - 0
installer/ui.sh

@@ -0,0 +1,167 @@
+#!/bin/bash
+# ui.sh - Text User Interface functions for USM installer
+
+# Print a banner/header
+print_banner() {
+    echo -e "${CYAN}${BOLD}"
+    echo "USM Installer - Universal Source Manifest"
+    echo -e "Version: ${SCRIPT_VERSION}${NC}"
+    echo ""
+}
+
+# Print a section header
+print_section() {
+    local title="$1"
+    echo ""
+    echo -e "${BOLD}${BLUE}▶ $title${NC}"
+    echo ""
+}
+
+# Print a summary box
+print_summary() {
+    local -a items=("$@")
+    local max_width=0
+    
+    # Find max width
+    for item in "${items[@]}"; do
+        local len=${#item}
+        (( len > max_width )) && max_width=$len
+    done
+    
+    (( max_width < 50 )) && max_width=50
+    (( max_width > 70 )) && max_width=70
+    
+    local line=$(printf '─%.0s' $(seq 1 $((max_width + 2))))
+    
+    echo -e "${CYAN}┌${line}┐${NC}"
+    for item in "${items[@]}"; do
+        printf "${CYAN}│${NC} %-$((max_width))s ${CYAN}│${NC}\n" "$item"
+    done
+    echo -e "${CYAN}└${line}┘${NC}"
+}
+
+# Print a progress indicator
+print_progress() {
+    local current="$1"
+    local total="$2"
+    local description="$3"
+    local width=40
+    local filled=$((width * current / total))
+    local empty=$((width - filled))
+    
+    printf "\r${GREEN}["
+    printf "%$((filled))s" | tr ' ' '█'
+    printf "%$((empty))s" | tr ' ' '░'
+    printf "]${NC} ($current/$total) %s" "$description"
+    
+    if (( current == total )); then
+        echo ""
+    fi
+}
+
+# Print a list item
+print_list_item() {
+    local bullet="${1:-•}"
+    local text="$2"
+    local indent="${3:-  }"
+    echo -e "${indent}${CYAN}${bullet}${NC} ${text}"
+}
+
+# Print a status line
+print_status() {
+    local label="$1"
+    local value="$2"
+    local status="${3:-info}"
+    local status_icon
+    
+    case "$status" in
+        ok)    status_icon="${GREEN}✓${NC}" ;;
+        warn)  status_icon="${YELLOW}!${NC}" ;;
+        error) status_icon="${RED}✗${NC}" ;;
+        *)     status_icon="${BLUE}i${NC}" ;;
+    esac
+    
+    printf "  ${status_icon} %-25s %s\n" "$label" "$value"
+}
+
+# Show installation summary and get confirmation
+show_installation_summary() {
+    local target_dir="$1"
+    local pm="$2"
+    local deps_count="$3"
+    local missing_deps="$4"
+    
+    print_section "Installation Summary"
+    
+    local -a summary_items=(
+        "Target Directory: ${target_dir}"
+        "Package Manager: ${pm}"
+    )
+    
+    if (( deps_count > 0 )); then
+        summary_items+=("Dependencies to install: ${deps_count}")
+    else
+        summary_items+=("Dependencies: All installed")
+    fi
+    
+    print_summary "${summary_items[@]}"
+    
+    echo ""
+    
+    # Show package installation step if there are deps to install
+    local step_num=1
+    if (( deps_count > 0 )); then
+        echo -e "${YELLOW}Step $step_num:${NC} The following packages will be installed using ${pm}:"
+        for pkg in $missing_deps; do
+            print_list_item "•" "$pkg"
+        done
+        echo ""
+        ((step_num++))
+    fi
+    
+    echo -e "${YELLOW}Step $step_num:${NC} Build and install components:"
+    ((step_num++))
+    print_list_item "1" "Invercargill (invercargill-1) - Core library"
+    print_list_item "2" "Invercargill-Json - JSON serialization"
+    print_list_item "3" "USM - Universal Source Manifest"
+    
+    echo ""
+    echo -e "${YELLOW}Step $step_num:${NC} Create shim at /usr/bin/usm"
+    
+    echo ""
+    if ! confirm "Do you want to continue with the installation?" "n"; then
+        return 1
+    fi
+    
+    return 0
+}
+
+# Show completion message
+show_completion() {
+    local target_dir="$1"
+    
+    echo ""
+    echo -e "${GREEN}✓ Installation Complete!${NC}"
+    echo ""
+    echo -e "  ${CYAN}Binary:${NC}     ${target_dir}/bin/usm"
+    echo -e "  ${CYAN}Shim:${NC}       /usr/bin/usm"
+    echo ""
+    echo -e "Run ${BOLD}usm --help${NC} to get started."
+    echo ""
+}
+
+# Show error and exit
+show_error_and_exit() {
+    local message="$1"
+    local code="${2:-1}"
+    
+    echo ""
+    echo -e "${RED}╔══════════════════════════════════════════════════════════════╗${NC}"
+    echo -e "${RED}║ Installation Failed                                          ║${NC}"
+    echo -e "${RED}╚══════════════════════════════════════════════════════════════╝${NC}"
+    echo ""
+    echo -e "${RED}Error:${NC} $message"
+    echo ""
+    
+    exit "$code"
+}

+ 177 - 0
installer/utils.sh

@@ -0,0 +1,177 @@
+#!/bin/bash
+# utils.sh - Common utility functions for USM installer
+
+# Colors for terminal output
+readonly RED='\033[0;31m'
+readonly GREEN='\033[0;32m'
+readonly YELLOW='\033[1;33m'
+readonly BLUE='\033[0;34m'
+readonly CYAN='\033[0;36m'
+readonly BOLD='\033[1m'
+readonly NC='\033[0m' # No Color
+
+# Logging functions
+log_info() {
+    echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+log_warn() {
+    echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+log_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+log_step() {
+    echo -e "${CYAN}==>${NC} ${BOLD}$1${NC}"
+}
+
+# Check if running as root
+is_root() {
+    [[ $EUID -eq 0 ]]
+}
+
+# Check if a command exists
+command_exists() {
+    command -v "$1" &>/dev/null
+}
+
+# Get the system's package manager
+detect_package_manager() {
+    if command_exists apt-get; then
+        echo "apt"
+    elif command_exists dnf; then
+        echo "dnf"
+    elif command_exists yum; then
+        echo "dnf"  # Fallback to dnf implementation
+    elif command_exists pacman; then
+        echo "pacman"
+    else
+        echo "unknown"
+    fi
+}
+
+# Get the system's sudo command
+get_sudo() {
+    if is_root; then
+        echo ""
+    elif command_exists sudo; then
+        echo "sudo"
+    elif command_exists doas; then
+        echo "doas"
+    else
+        echo ""
+    fi
+}
+
+# Confirm action with user
+confirm() {
+    local prompt="$1"
+    local default="${2:-n}"
+    local response
+    
+    if [[ "$ASSUME_YES" == "true" ]]; then
+        return 0
+    fi
+    
+    if [[ "$default" == "y" ]]; then
+        prompt="$prompt [Y/n]: "
+    else
+        prompt="$prompt [y/N]: "
+    fi
+    
+    read -r -p "$prompt" response
+    response="${response:-$default}"
+    
+    [[ "$response" =~ ^[Yy]$ ]]
+}
+
+# Create directory if it doesn't exist
+ensure_dir() {
+    local dir="$1"
+    if [[ ! -d "$dir" ]]; then
+        mkdir -p "$dir"
+    fi
+}
+
+# Extract XZ compressed data from this script
+extract_payload() {
+    local output_dir="$1"
+    local script_path="$0"
+    local payload_start
+    
+    # Find the payload marker
+    payload_start=$(grep -an "^__PAYLOAD_START__$" "$script_path" | cut -d: -f1)
+    
+    if [[ -z "$payload_start" ]]; then
+        log_error "Could not find payload in installer"
+        return 1
+    fi
+    
+    # Extract and decompress the payload
+    tail -n +$((payload_start + 1)) "$script_path" | xz -d | tar -xf - -C "$output_dir"
+}
+
+# Cleanup function for traps
+cleanup() {
+    if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then
+        rm -rf "$TEMP_DIR"
+    fi
+}
+
+# Set up temporary directory
+setup_temp_dir() {
+    TEMP_DIR=$(mktemp -d)
+    trap cleanup EXIT
+}
+
+# Download a file
+download_file() {
+    local url="$1"
+    local output="$2"
+    
+    if command_exists curl; then
+        curl -fsSL "$url" -o "$output"
+    elif command_exists wget; then
+        wget -q "$url" -O "$output"
+    else
+        log_error "Neither curl nor wget is available"
+        return 1
+    fi
+}
+
+# Clone a git repository
+clone_repo() {
+    local url="$1"
+    local dest="$2"
+    local branch="${3:-main}"
+    
+    if ! command_exists git; then
+        log_error "git is not installed"
+        return 1
+    fi
+    
+    git clone --depth 1 -b "$branch" "$url" "$dest" 2>/dev/null
+}
+
+# Get total size of a directory
+get_dir_size() {
+    local dir="$1"
+    du -sb "$dir" 2>/dev/null | cut -f1
+}
+
+# Format bytes to human readable
+format_size() {
+    local bytes=$1
+    local units=('B' 'KB' 'MB' 'GB' 'TB')
+    local unit=0
+    local size=$bytes
+    
+    while (( size > 1024 && unit < 4 )); do
+        size=$((size / 1024))
+        ((unit++))
+    done
+    
+    echo "$size${units[$unit]}"
+}

+ 321 - 0
slopdocs/structure.usm.manifest.dependency-management.md

@@ -0,0 +1,321 @@
+# USM Dependency Management
+
+## Overview
+USM manages dependencies through resource references organized by lifecycle phase. Dependencies are specified in the `depends` section of MANIFEST.usm and validated before package operations.
+
+## Dependency Categories
+
+### runtime
+Resources required for the package to function when installed.
+
+**Used for**: Shared libraries, runtime binaries, data files
+**Checked during**: Installation, dependency resolution
+**Example**:
+```json
+"runtime": [
+  "lib:libc.so.6",
+  "lib:libglib-2.0.so",
+  "lib:libgio-2.0.so",
+  "res:common-themes"
+]
+```
+
+### build
+Resources required to compile the package from source.
+
+**Used for**: Compilers, build tools, development libraries
+**Checked during**: Build operations
+**Example**:
+```json
+"build": [
+  "bin:gcc",
+  "bin:make",
+  "pc:glib-2.0.pc",
+  "inc:glib-2.0",
+  "vapi:gio-2.0.vapi"
+]
+```
+
+### manage
+Resources required to run management scripts (build, install, remove).
+
+**Used for**: Shell interpreters, system utilities
+**Checked during**: All script executions
+**Example**:
+```json
+"manage": [
+  "bin:bash",
+  "bin:ls",
+  "bin:find"
+]
+```
+
+### acquire
+Resources required to download and extract source code.
+
+**Used for**: Download tools, archive utilities
+**Checked during**: Source acquisition
+**Optional**: Only required when `execs.acquire` is specified
+**Example**:
+```json
+"acquire": [
+  "bin:bash",
+  "bin:wget",
+  "bin:tar",
+  "bin:gzip"
+]
+```
+
+## Resource Reference Format
+
+All dependencies use the format `[resource-type]:[resource-name]` where the resource name refers to an actual installed file on the filesystem, NOT a package name.
+
+**Important**: Dependencies must reference specific files, not package names. For example:
+- Use `bin:find` to reference the find utility executable, NOT `bin:findutils`
+- Use `bin:ls` to reference the ls executable, NOT `bin:coreutils`
+- Use `lib:libssl.so` to reference the SSL library, NOT `bin:openssl`
+
+Resource references are resolved by checking for the existence of the specified file in the appropriate system directories.
+
+### Common Runtime Dependencies
+```json
+{
+  "runtime": [
+    "lib:libssl.so.1.1",
+    "lib:libcrypto.so.1.1",
+    "lib:libz.so.1",
+    "cfg:ssl/certs/ca-certificates.crt"
+  ]
+}
+```
+
+### Common Build Dependencies
+```json
+{
+  "build": [
+    "bin:gcc",
+    "bin:pkg-config",
+    "pc:openssl.pc",
+    "pc:zlib.pc",
+    "inc:openssl.h",
+    "inc:zlib.h"
+  ]
+}
+```
+
+### Development Libraries
+```json
+{
+  "build": [
+    "pc:glib-2.0.pc",
+    "pc:gtk-4.pc",
+    "inc:glib-2.0.h",
+    "inc:gtk-4.h",
+    "vapi:gtk-4.vapi"
+  ],
+  "runtime": [
+    "lib:libglib-2.0.so",
+    "lib:libgtk-4.so"
+  ]
+}
+```
+
+## Dependency Resolution
+
+### Validation Process
+USM validates dependencies by:
+1. Parsing resource references
+2. Checking system paths for resource existence
+3. Verifying accessibility and permissions
+4. Reporting missing dependencies
+
+### Resolution Order
+Dependencies are resolved in this priority:
+1. System standard locations
+2. Environment variable overrides
+3. Package manager databases
+4. User-specified paths
+
+### Error Handling
+When dependencies are missing:
+- Build operations fail with detailed error messages
+- Installation operations list all unsatisfied dependencies
+- Script execution fails before starting
+
+## Language-Specific Dependencies
+
+### C Projects
+```json
+{
+  "build": [
+    "bin:gcc",
+    "bin:make",
+    "pc:libname.pc"
+  ],
+  "runtime": [
+    "lib:libname.so"
+  ]
+}
+```
+
+### Vala Projects
+```json
+{
+  "build": [
+    "bin:valac",
+    "pc:glib-2.0.pc",
+    "pc:gobject-2.0.pc",
+    "vapi:glib-2.0.vapi"
+  ],
+  "runtime": [
+    "lib:libglib-2.0.so",
+    "lib:libgobject-2.0.so"
+  ]
+}
+```
+
+### Meson Projects
+```json
+{
+  "build": [
+    "bin:meson",
+    "bin:ninja",
+    "bin:pkg-config"
+  ],
+  "manage": [
+    "bin:bash"
+  ]
+}
+```
+
+### CMake Projects
+```json
+{
+  "build": [
+    "bin:cmake",
+    "bin:make",
+    "bin:pkg-config"
+  ],
+  "manage": [
+    "bin:bash"
+  ]
+}
+```
+
+## System Integration
+
+### Package Manager Integration
+USM can integrate with system package managers for dependency resolution:
+- Check system package databases
+- Install missing dependencies when authorized
+- Track package manager installations
+
+### Cross-Platform Dependencies
+USM handles different system layouts:
+- Linux distributions with varying directory structures
+- Different library naming conventions
+- Architecture-specific dependencies
+
+### Virtual Dependencies
+Some resource types provide virtual dependencies:
+- `bin:sh` - Any POSIX shell
+- `bin:cc` - Any C compiler
+- `lib:libc.so.6` - Standard C library
+
+## Best Practices
+
+### Minimal Dependencies
+- Specify only required dependencies
+- Avoid unnecessary build dependencies
+- Separate runtime and build dependencies clearly
+- Use specific version requirements when needed
+
+### Dependency Accuracy
+- Test dependency specifications
+- Verify all required resources are listed
+- Check for missing transitive dependencies
+- Validate resource references
+
+## Common Patterns
+
+### GUI Applications
+```json
+{
+  "runtime": [
+    "lib:libgtk-4.so",
+    "lib:libgdk_pixbuf-2.0.so",
+    "lib:libpango-1.0.so",
+    "res:shared-mime-info",
+    "res:hicolor-icon-theme"
+  ],
+  "build": [
+    "bin:gcc",
+    "pc:gtk-4.pc",
+    "bin:pkg-config"
+  ]
+}
+```
+
+### Command Line Tools
+```json
+{
+  "runtime": [
+    "lib:libreadline.so",
+    "lib:libncurses.so"
+  ],
+  "build": [
+    "bin:gcc",
+    "inc:readline",
+    "inc:ncurses"
+  ],
+  "manage": [
+    "bin:bash"
+  ]
+}
+```
+
+### Libraries
+```json
+{
+  "build": [
+    "bin:gcc",
+    "bin:pkg-config",
+    "pc:glib-2.0.pc"
+  ],
+  "runtime": [
+    "lib:libglib-2.0.so"
+  ],
+  "provides": [
+    "lib:libmylib.so",
+    "inc:mylib.h",
+    "pc:mylib.pc"
+  ]
+}
+```
+
+## Troubleshooting
+
+### Missing Dependencies
+Common causes and solutions:
+- Incorrect resource type: Verify type matches system location
+- Wrong resource name: Check exact filename/version
+- Path issues: Ensure standard directories are accessible
+- Permission problems: Verify file access rights
+
+### Dependency Conflicts
+When multiple packages provide same resource:
+- Use specific version requirements
+- Specify alternative dependencies
+- Check package manager conflicts
+- Consider virtual dependencies
+
+### Circular Dependencies
+Avoid circular dependency chains:
+- Review dependency graph
+- Split packages if necessary
+- Use optional dependencies
+- Reorganize package structure
+
+---
+
+This guide provides comprehensive information for managing USM package dependencies effectively.

+ 352 - 0
slopdocs/structure.usm.manifest.executable-scripts.md

@@ -0,0 +1,352 @@
+# USM Executable Scripts Reference
+
+## Overview
+USM packages use executable scripts to handle different phases of package lifecycle. Scripts are defined in the `execs` section of MANIFEST.usm and receive specific arguments based on their purpose.
+
+## Script Types
+
+### build
+Required script that compiles source code into binaries.
+
+**Arguments**: `[build-directory]`
+- `build-directory`: Path where build output should be placed
+
+**Working Directory**:
+- By default: Source directory
+- With `simpleBuildEnvironment` flag: Build directory (source tree is copied first)
+
+**Environment Variables**:
+USM sets these non-prefixed variables for execs:
+- `DESTDIR`: Destination directory
+- `PREFIX`: Installation prefix
+- `BINDIR`: Binary directory
+- `INCLUDEDIR`: Include directory
+- `DATADIR`: Data directory
+- `INFODIR`: Info directory
+- `LIBDIR`: Library directory
+- `MANDIR`: Manual directory
+- `LIBEXECDIR`: Libexec directory
+- `LOCALEDIR`: Locale directory
+- `LOCALSTATEDIR`: Local state directory
+- `SBINDIR`: System binary directory
+- `SHAREDSTATEDIR`: Shared state directory
+- `SYSCONFIGDIR`: Configuration directory
+- `TAGSDIR`: Tags directory
+
+Note: The `USM_*` prefixed environment variables are for the USM process itself and should not be used in exec scripts.
+
+**Example**:
+```bash
+#!/bin/bash
+set -e
+
+build_dir=$1
+
+# With simpleBuildEnvironment flag, we're already in build directory
+# Without the flag, we need to change to build directory
+if [ ! -d "$build_dir" ]; then
+  mkdir -p "$build_dir"
+fi
+cd ${build_dir}
+meson setup ${src_dir} --prefix=${PREFIX} --libdir=${LIBDIR}
+ninja
+```
+
+### install
+Optional script that installs built files to destination directory.
+
+**Arguments**: `[build-directory] [install-directory] [install-type]`
+- `build-directory`: Path containing build output
+- `install-directory`: Path where files should be installed (DESTDIR)
+- `install-type`: "fresh", "upgrade", or "downgrade"
+
+**Working Directory**:
+- By default: Source directory
+- With `simpleBuildEnvironment` flag: Build directory
+
+**Environment Variables**:
+- `DESTDIR` is overridden to install-directory
+- Other USM variables are available
+
+**Example**:
+```bash
+#!/bin/bash
+set -e
+
+build_dir=$1
+install_dir=$2
+install_type=$3
+
+# With simpleBuildEnvironment flag, we're already in build directory
+# Without the flag, we need to change to build directory
+cd ${build_dir}
+meson install --destdir ${install_dir}
+```
+
+### postInstall
+Optional script that runs after USM installs resources to system.
+
+**Arguments**: `[build-directory] [install-type]`
+- `build-directory`: Path containing build output
+- `install-type`: "fresh", "upgrade", or "downgrade"
+
+**Working Directory**:
+- By default: Source directory
+- With `simpleBuildEnvironment` flag: Build directory
+
+**Use Case**: System integration, cache generation, service registration
+
+**Example**:
+```bash
+#!/bin/bash
+set -e
+
+build_dir=$1
+install_type=$2
+
+# Update desktop database
+update-desktop-database
+
+# Update icon cache
+gtk-update-icon-cache -f -t /usr/share/icons/hicolor
+```
+
+### remove
+Optional script that runs before USM removes package resources.
+
+**Arguments**: `[remove-type]`
+- `remove-type`: "final", "upgrade", or "downgrade"
+
+**Use Case**: Cleanup, service stopping, data preservation
+
+**Example**:
+```bash
+#!/bin/bash
+set -e
+
+remove_type=$1
+
+# Stop service if being upgraded
+if [ "$remove_type" != "final" ]; then
+    systemctl stop myapp || true
+fi
+
+# Remove user data only on final removal
+if [ "$remove_type" = "final" ]; then
+    rm -rf /var/lib/myapp
+fi
+```
+
+### acquire
+Optional script that downloads and extracts source code.
+
+**Arguments**: None
+**Working Directory**: Package root directory
+
+**Use Case**: Projects without native USM support, repository packaging
+
+**Dependencies**: Listed in `depends.acquire` section
+
+**Example**:
+```bash
+#!/bin/bash
+set -e
+
+ARCHIVE_URL="https://github.com/example/myapp/archive/v1.0.0.tar.gz"
+ARCHIVE_NAME="source.tar.gz"
+EXTRACT_DIR="."
+
+echo "Downloading source..."
+wget -O ${ARCHIVE_NAME} ${ARCHIVE_URL}
+
+echo "Extracting archive..."
+tar -xzf ${ARCHIVE_NAME} --strip-components=1
+
+echo "Cleaning up..."
+rm ${ARCHIVE_NAME}
+```
+
+### test
+Optional script that runs tests after build but before installation.
+
+**Arguments**: `[build-directory]`
+- `build-directory`: Path containing build output
+
+**Working Directory**:
+- By default: Source directory
+- With `simpleBuildEnvironment` flag: Build directory
+
+**Environment Variables**:
+USM sets the same non-prefixed variables as described in the build script section:
+- `DESTDIR`, `PREFIX`, `BINDIR`, `INCLUDEDIR`, `DATADIR`, `INFODIR`, `LIBDIR`, `MANDIR`, `LIBEXECDIR`, `LOCALEDIR`, `LOCALSTATEDIR`, `SBINDIR`, `SHAREDSTATEDIR`, `SYSCONFIGDIR`, `TAGSDIR`
+
+**Use Case**: Unit tests, integration tests, validation of build artifacts
+
+**Example**:
+```bash
+#!/bin/bash
+set -e
+
+build_dir=$1
+
+# With simpleBuildEnvironment flag, we're already in build directory
+# Without the flag, we need to change to build directory
+cd ${build_dir}
+# Run unit tests
+./test-suite
+# Run integration tests
+./integration-tests
+echo "All tests passed"
+```
+
+## Script Requirements
+
+### Permissions
+All scripts must be executable:
+```bash
+chmod +x usm-scripts/build.sh
+```
+
+### Error Handling
+Scripts should:
+- Use `set -e` to exit on errors
+- Return appropriate exit codes
+- Handle cleanup on failure
+
+### Environment Variables
+
+#### USM Process Variables (Internal Use)
+The following `USM_*` prefixed environment variables are used by the USM process itself to configure paths:
+- `USM_DESTDIR`: Destination directory
+- `USM_PREFIX`: Installation prefix
+- `USM_BINDIR`: Binary directory
+- `USM_INCLUDEDIR`: Include directory
+- `USM_DATADIR`: Data directory
+- `USM_INFODIR`: Info directory
+- `USM_LIBDIR`: Library directory
+- `USM_MANDIR`: Manual directory
+- `USM_LIBEXECDIR`: Libexec directory
+- `USM_LOCALEDIR`: Locale directory
+- `USM_LOCALSTATEDIR`: Local state directory
+- `USM_SBINDIR`: System binary directory
+- `USM_SHAREDSTATEDIR`: Shared state directory
+- `USM_SYSCONFIGDIR`: Configuration directory
+- `USM_TAGSDIR`: Tags directory
+- `USM_CONFIGDIR`: USM configuration directory
+
+#### Executable Script Variables (For Scripts)
+USM sets these non-prefixed variables for all executable scripts:
+- `DESTDIR`: Destination directory
+- `PREFIX`: Installation prefix
+- `BINDIR`: Binary directory
+- `INCLUDEDIR`: Include directory
+- `DATADIR`: Data directory
+- `INFODIR`: Info directory
+- `LIBDIR`: Library directory
+- `MANDIR`: Manual directory
+- `LIBEXECDIR`: Libexec directory
+- `LOCALEDIR`: Locale directory
+- `LOCALSTATEDIR`: Local state directory
+- `SBINDIR`: System binary directory
+- `SHAREDSTATEDIR`: Shared state directory
+- `SYSCONFIGDIR`: Configuration directory
+- `TAGSDIR`: Tags directory
+
+**Important**: Executable scripts should use the non-prefixed variables (e.g., `${PREFIX}`, `${LIBDIR}`) and NOT the `USM_*` prefixed variables, which are for internal USM process use only.
+
+## Script Locations
+
+Scripts can be placed anywhere in the package, but common conventions:
+- `usm-scripts/`: Directory at package root (recommended)
+- `scripts/`: Directory at package root (legacy)
+- Package root: For simple packages
+
+## Build System Integration
+
+### Meson
+```bash
+#!/bin/bash
+set -e
+
+src_dir=$(pwd)
+build_dir=$1
+
+cd ${build_dir}
+meson setup ${src_dir} --prefix=${PREFIX} --libdir=${LIBDIR} --bindir=${BINDIR} --includedir=${INCLUDEDIR}
+ninja
+```
+
+### Make
+```bash
+#!/bin/bash
+set -e
+
+src_dir=$(pwd)
+build_dir=$1
+
+cd ${build_dir}
+make PREFIX=${PREFIX} LIBDIR=${LIBDIR}
+```
+
+### CMake
+```bash
+#!/bin/bash
+set -e
+
+src_dir=$(pwd)
+build_dir=$1
+
+cd ${build_dir}
+cmake ${src_dir} -DCMAKE_INSTALL_PREFIX=${PREFIX} -DCMAKE_INSTALL_LIBDIR=${LIBDIR}
+make
+```
+
+## Special Manifest Flags
+
+### simpleBuildEnvironment
+When the `simpleBuildEnvironment` flag is set in the manifest:
+- The full source tree is copied to the build directory before executing any build-related scripts
+- All build-related scripts (build, install, test, postInstall) are executed from the build directory instead of the source directory
+- This provides a clean, isolated build environment where scripts can safely write to the current directory
+
+This flag is particularly useful for:
+- Build systems that expect to run from the build directory
+- Projects that need to write temporary files during build
+- Simplifying build scripts by eliminating the need to change directories
+
+## Progress Reporting
+
+When using `ninjaStyleProgress` flag, USM parses build output for progress:
+- Looks for pattern: `[current/total]`
+- Updates progress bar accordingly
+- Requires build system to output this format
+
+## Best Practices
+
+### Script Design
+- Keep scripts focused on single responsibility
+- Use absolute paths when available
+- Handle all required arguments
+- Validate environment before execution
+
+### Error Handling
+- Check for required dependencies
+- Validate input arguments
+- Provide meaningful error messages
+- Clean up temporary files on exit
+
+### Portability
+- Use POSIX-compliant shell syntax
+- Avoid bash-specific features when possible
+- Test on target systems
+- Consider different directory layouts
+
+### Security
+- Validate input parameters
+- Use secure temporary directories
+- Avoid command injection vulnerabilities
+- Follow principle of least privilege
+
+---
+
+This reference provides complete specification for USM executable scripts used throughout package lifecycle management.

+ 225 - 0
slopdocs/structure.usm.manifest.md

@@ -0,0 +1,225 @@
+# MANIFEST.usm Reference
+
+## Overview
+MANIFEST.usm is a JSON file that defines the structure, metadata, and behavior of a USM software package. This document provides a complete reference for all manifest fields.
+
+## Required Fields
+
+### name
+Type: string
+The package identifier without spaces.
+```json
+"name": "my-package"
+```
+
+### version
+Type: string
+Semantic version with optional package version suffix.
+```json
+"version": "1.2.3+1"
+```
+
+### summary
+Type: string
+Short description of the package.
+```json
+"summary": "A sample package for demonstration"
+```
+
+### licences
+Type: array of license objects
+List of licenses applicable to the package.
+
+Each license object contains:
+- `name` (string): License name
+- `category` (string): License category ("libre", "open-source", "source-available", "proprietary")
+- `text` (string): Path to license file relative to manifest
+
+```json
+"licences": [
+  {
+    "name": "GPLv3",
+    "category": "libre",
+    "text": "LICENSE"
+  }
+]
+```
+
+### provides
+Type: object
+Resources provided by the package. Keys are resource references, values are file specifications.
+
+```json
+"provides": {
+  "bin:myapp": "as-expected",
+  "lib:libmyapp.so": "as-expected",
+  "inc:myapp.h": {"pathBase": "source", "path": "include/myapp.h", "type": "reg"}
+}
+```
+
+### depends
+Type: object
+Package dependencies organized by category.
+
+- `runtime` (array): Runtime dependencies
+- `build` (array): Build-time dependencies  
+- `manage` (array): Management dependencies
+- `acquire` (array, optional): Source acquisition dependencies
+
+```json
+"depends": {
+  "runtime": ["lib:libc.so.6", "lib:libglib-2.0.so"],
+  "build": ["bin:gcc", "pc:glib-2.0.pc"],
+  "manage": ["bin:bash"]
+}
+```
+
+### execs
+Type: object
+Executable scripts for different package lifecycle phases.
+
+- `build` (string, required): Build script path
+- `install` (string, optional): Install script path
+- `remove` (string, optional): Removal script path
+- `postInstall` (string, optional): Post-install script path
+- `acquire` (string, optional): Source acquisition script path
+- `test` (string, optional): Test script path
+
+```json
+"execs": {
+  "build": "usm-scripts/build.sh",
+  "install": "usm-scripts/install.sh",
+  "acquire": "usm-scripts/acquire.sh",
+  "test": "usm-scripts/test.sh"
+}
+```
+
+### flags
+Type: array of strings
+Package behavior flags.
+
+- `"buildInSourceTree"`: Build in source directory
+- `"setManifestPropertyEnvs"`: Set manifest properties as environment variables
+- `"ninjaStyleProgress"`: Parse Ninja-style progress output
+- `"simpleBuildEnvironment"`: Copy full source tree to build directory before building and execute all build-related scripts from build directory
+
+```json
+"flags": ["simpleBuildEnvironment"]
+```
+
+## Optional Fields
+
+### md
+Type: string
+Path to markdown description file relative to manifest root.
+```json
+"md": "DESCRIPTION.md"
+```
+
+### url
+Type: string
+Project website or repository URL.
+```json
+"url": "https://github.com/example/my-package"
+```
+
+### screenshots
+Type: array of strings
+Paths to screenshot files relative to manifest root.
+```json
+"screenshots": ["screenshot1.png", "screenshot2.png"]
+```
+
+### icon
+Type: string
+Path to package icon file relative to manifest root.
+```json
+"icon": "my-package.svg"
+```
+
+### metainfo
+Type: string
+Path to AppStream metainfo file relative to manifest root.
+```json
+"metainfo": "my-package.appdata.xml"
+```
+
+### git
+Type: object
+Git repository information.
+- `origin` (string): Repository URL
+- `commit` (string): Commit hash, tag, or branch
+
+```json
+"git": {
+  "origin": "https://github.com/example/my-package",
+  "commit": "v1.2.3"
+}
+```
+
+### extras
+Type: object
+Additional custom properties.
+```json
+"extras": {
+  "customField": "customValue",
+  "buildSystem": "cmake"
+}
+```
+
+## File Specification Objects
+
+When using object notation in `provides`, files can be specified with:
+
+### pathBase
+Base location for the file:
+- `"source"`: Extracted source directory
+- `"build"`: Build output directory
+- `"install"`: Install destination directory
+- `"as-expected"`: Standard system location
+
+### path
+File path relative to pathBase. Empty string when pathBase is "as-expected".
+
+### type
+File system entry type:
+- `"reg"`: Regular file
+- `"dir"`: Directory
+- `"lnk"`: Symbolic link
+
+### dest
+Destination path for symbolic links (required when type is "lnk").
+
+### keepOn
+Array of conditions when resource should not be deleted:
+- `"final"`: During final uninstallation
+- `"upgrade"`: During package upgrade
+- `"downgrade"`: During package downgrade
+
+### skipFor
+Array of conditions when resource should not be installed:
+- `"fresh"`: During fresh installation
+- `"upgrade"`: During package upgrade
+- `"downgrade"`: During package downgrade
+
+## Shorthand Notation
+
+Files can be specified using shorthand notation:
+- `"as-expected"`: Standard location
+- `"build:path/to/file"`: From build directory
+- `"source:path/to/file"`: From source directory
+- `"install:path/to/file"`: From install directory
+
+## Validation Rules
+
+USM validates manifests for:
+- All required fields present
+- Valid JSON syntax
+- Correct license categories
+- Valid resource references
+- Proper file specification objects
+- Semantic version format compliance
+
+---
+
+This reference provides complete specification for MANIFEST.usm files used by USM packages.

+ 121 - 0
slopdocs/structure.usm.manifest.packaging.md

@@ -0,0 +1,121 @@
+# USM Package Maintainer Guide
+
+## Overview
+USM (Universal Source Manifest) is a JSON-based package management system that defines software packages through manifest files. This guide provides package maintainers with comprehensive information for creating and managing MANIFEST.usm files.
+
+## Package Structure
+
+A USM package consists of:
+- `MANIFEST.usm`: JSON manifest file at the project root
+- `usm-scripts/`: Directory containing executable scripts (optional, recommended)
+- Source code and project files
+
+## Creating a New Package
+
+### Using Scaffold Templates
+USM provides scaffold templates to quickly create new packages:
+
+```bash
+usm scaffold <package-name> <template> [attributes...]
+```
+
+Available templates:
+- `basic`: Creates only MANIFEST.usm
+- `makefile`: Adds make build system support
+- `meson`: Adds Meson build system support
+
+Attributes:
+- `vala`: Add Vala language dependencies
+- `c`: Add C language dependencies
+- `acquire`: Add source acquisition support
+
+### Manual Manifest Creation
+Create `MANIFEST.usm` at the project root with required fields:
+- `name`: Package identifier (no spaces)
+- `version`: Semantic version with optional package version
+- `summary`: Short description
+- `licences`: Array of license objects
+- `provides`: Resources provided by the package
+- `depends`: Package dependencies
+- `execs`: Executable scripts
+- `flags`: Package behavior flags
+
+## Package Management Workflow
+
+### Building Packages
+```bash
+usm manifest build <build-path>
+```
+
+### Installing Packages
+```bash
+usm manifest install <build-path>
+```
+
+### Creating Complete Packages
+```bash
+usm manifest package
+```
+
+This creates a `.usmc` (USM Complete) archive containing the manifest and all source files.
+
+### Auto-Generating Provides
+```bash
+usm manifest autoprovides [--replace] [build-path]
+```
+Scans build output to automatically generate the `provides` section.
+
+## Package Validation
+
+USM validates manifests for:
+- Required field presence
+- Correct license categories
+- Valid resource references
+- Proper dependency satisfaction
+- Executable script permissions
+
+## Best Practices
+
+### Version Management
+Use semantic versioning: `MAJOR.MINOR.PATCH+PACKAGE_VERSION`
+- Increment MAJOR for incompatible changes
+- Increment MINOR for new features
+- Increment PATCH for bug fixes
+- Add `+N` for packaging revisions
+
+### Dependency Management
+- Specify minimal required dependencies
+- Use appropriate resource types
+- Separate runtime, build, and management dependencies
+- Verify dependency satisfaction before distribution
+
+### Resource Provision
+- Use `as-expected` for standard file locations
+- Specify exact paths for non-standard installations
+- Include all necessary files in `provides`
+- Use appropriate resource types
+
+### Script Management
+- Make all scripts executable
+- Use proper exit codes
+- Handle script arguments correctly
+- Test scripts in isolation
+
+## Repository Integration
+
+Packages can be distributed through USM repositories using:
+- `PACKAGES.usml`: Package listing files
+- `Repo.usmr`: Repository metadata
+- Cryptographic signatures for security
+
+## Troubleshooting
+
+Common issues and solutions:
+- Missing dependencies: Verify all resource references exist
+- Build failures: Check executable script permissions and paths
+- Installation errors: Ensure `provides` matches actual build output
+- Validation failures: Review JSON syntax and required fields
+
+---
+
+This guide provides package maintainers with essential information for creating, managing, and distributing USM packages effectively.

+ 219 - 0
slopdocs/structure.usm.manifest.resource-types.md

@@ -0,0 +1,219 @@
+# USM Resource Types Reference
+
+## Overview
+Resource references identify specific system resources using the format `[resource-type]:[resource-name]`. The resource type determines where the resource is located on the filesystem.
+
+## Resource Types
+
+### rootpath
+Location: Filesystem root (`/[resource-name]`)
+Used for: Root-level files and directories
+Example: `rootpath:boot/config`
+
+### path
+Location: `/usr/[resource-name]`
+Used for: General files in /usr hierarchy
+Example: `path:local/bin/custom-tool`
+
+### opt
+Location: `/opt/[resource-name]`
+Used for: Optional software packages
+Example: `opt:myapp/bin/myapp`
+
+### res
+Location: `/usr/share/[resource-name]`
+Used for: Shared data files
+Example: `res:myapp/themes/default.theme`
+
+### cfg
+Location: `/etc/[resource-name]`
+Used for: Configuration files
+Example: `cfg:myapp/config.conf`
+
+### bin
+Location: System PATH directories
+Used for: Executable binaries
+Example: `bin:myapp`
+
+### sbin
+Location: `/usr/sbin` or `/sbin`
+Used for: System administration binaries
+Example: `sbin:myapp-daemon`
+
+### lib
+Location: System library paths
+Used for: Shared libraries
+Example: `lib:libmyapp.so.1`
+
+### libexec
+Location: `/usr/libexec`
+Used for: Library executables
+Example: `libexec:myapp-helper`
+
+### libres
+Location: `/usr/lib`, `/usr/lib64`, `/lib`, or `/lib64`
+Used for: Library resource files
+Example: `libres:myapp/plugins/extension.so`
+
+### info
+Location: `/usr/share/info`
+Used for: GNU info documentation
+Example: `info:myapp.info`
+
+### man
+Location: `/usr/share/man`
+Used for: Manual pages
+Example: `man:myapp.1`
+
+### locale
+Location: `/usr/share/locale`
+Used for: Localization files
+Example: `locale:fr/LC_MESSAGES/myapp.mo`
+
+### app
+Location: `/usr/share/applications`
+Used for: Desktop application entries
+Example: `app:myapp.desktop`
+
+### inc
+Location: `/usr/include`
+Used for: C/C++ header files
+Example: `inc:myapp.h`
+
+### pc
+Location: pkg-config paths
+Used for: pkg-config files
+Example: `pc:myapp.pc`
+
+### vapi
+Location: `/usr/share/vala/vapi` or `/usr/share/vala-0.56/vapi`
+Used for: Vala API files
+Example: `vapi:myapp.vapi`
+
+### gir
+Location: `/usr/share/gir`
+Used for: GObject Introspection files
+Example: `gir:MyApp-1.0.gir`
+
+### typelib
+Location: `/usr/lib64/girepository-1.0`, `/usr/lib/girepository-1.0`, `/lib64/girepository-1.0`, or `/lib/girepository-1.0`
+Used for: GObject typelib files
+Example: `typelib:MyApp-1.0.typelib`
+
+### gio
+Location: `/usr/lib64/gio/modules`, `/usr/lib/gio/modules`, or `/usr/lib/<multiarch-triple>/gio/modules`
+Used for: GIO plugin modules (the name is the module filename)
+Example: `gio:libgiognutls.so`
+
+### tag
+Location: `/usr/share/usm-tags/[hierarchical-path].tag`
+Used for: USM system tags
+Example: `tag:category.subcategory.feature`
+
+## Resource Reference Format
+
+Resource references follow the pattern: `type:name` where the name refers to an actual installed file on the filesystem, NOT a package name.
+
+**Important**: Resource references must specify actual files, not package names. For example:
+- Use `bin:find` to reference the find utility executable, NOT `bin:findutils`
+- Use `bin:ls` to reference the ls executable, NOT `bin:coreutils`
+- Use `lib:libssl.so` to reference the SSL library, NOT `bin:openssl`
+
+Examples:
+- `bin:ls` - The ls executable
+- `lib:libc.so.6` - The libc shared library
+- `man:ls.1` - Manual page for ls
+- `cfg:myapp.conf` - Configuration file for myapp
+- `app:myapp.desktop` - Desktop entry for myapp
+
+## Path Resolution
+
+USM resolves resource paths in this order:
+1. Check standard system locations for the resource type
+2. Use USM_* environment variables when set (USM_PREFIX, USM_LIBDIR, etc.)
+3. Fall back to default paths
+
+Note: The USM_* environment variables are for the USM process itself and are used to configure how USM resolves paths internally.
+
+## Common Usage Patterns
+
+### Development Libraries
+```json
+{
+  "provides": {
+    "lib:libmyapp.so": "as-expected",
+    "inc:myapp.h": "as-expected",
+    "pc:myapp.pc": "as-expected"
+  }
+}
+```
+
+### Applications
+```json
+{
+  "provides": {
+    "bin:myapp": "as-expected",
+    "app:myapp.desktop": "as-expected",
+    "res:myapp/icons/hicolor/256x256/apps/myapp.png": "as-expected"
+  }
+}
+```
+
+### Development Tools
+```json
+{
+  "provides": {
+    "bin:myapp-tool": "as-expected",
+    "man:myapp-tool.1": "as-expected",
+    "libexec:myapp-helper": "as-expected"
+  }
+}
+```
+
+## Dependency Specification
+
+Resources are specified as dependencies using the same format:
+
+```json
+{
+  "depends": {
+    "runtime": ["lib:libglib-2.0.so", "lib:libc.so.6"],
+    "build": ["bin:gcc", "pc:glib-2.0.pc"],
+    "manage": ["bin:bash"]
+  }
+}
+```
+
+## Resource Validation
+
+USM validates resource references by:
+- Checking existence in expected locations
+- Verifying file permissions
+- Validating resource type formats
+- Ensuring path accessibility
+
+## Environment Variables
+
+USM respects these environment variables for path customization:
+- `USM_DESTDIR`: Destination directory
+- `USM_PREFIX`: Installation prefix
+- `USM_LIBDIR`: Library directory
+- `USM_BINDIR`: Binary directory
+- `USM_INCLUDEDIR`: Include directory
+- `USM_DATADIR`: Data directory
+- `USM_MANDIR`: Manual page directory
+- `USM_TAGSDIR`: Tags directory
+- `USM_INFODIR`: Info directory
+- `USM_LIBEXECDIR`: Libexec directory
+- `USM_LOCALEDIR`: Locale directory
+- `USM_LOCALSTATEDIR`: Local state directory
+- `USM_SBINDIR`: System binary directory
+- `USM_SHAREDSTATEDIR`: Shared state directory
+- `USM_SYSCONFIGDIR`: Configuration directory
+- `USM_CONFIGDIR`: USM configuration directory
+
+Note: These USM_* variables are for the USM process itself. Executable scripts should use the non-prefixed versions (DESTDIR, PREFIX, LIBDIR, etc.) as documented in the executable scripts reference.
+
+---
+
+This reference provides complete specification for all USM resource types and their usage patterns.

+ 269 - 0
slopdocs/utility.usm.manifest.autoprovides.md

@@ -0,0 +1,269 @@
+# USM Manifest Autoprovides Command
+
+## Overview
+The `usm manifest autoprovides` command automatically generates the `provides` section of MANIFEST.usm by scanning build output. This tool helps package maintainers ensure all installed files are properly declared.
+
+## Usage
+
+```bash
+usm manifest autoprovides [--replace] [--debug] [build-path]
+```
+
+### Arguments
+- `build-path`: Directory containing build output (optional, auto-generated if not provided)
+
+### Options
+- `--replace`: Update MANIFEST.usm file with generated provides section
+- `--debug`: Disable build progress reporting and inherit file descriptors for subprocesses
+
+## Process
+
+### 1. Build Execution
+The command first builds the package using the specified build script:
+- Runs `usm manifest build` with silent output (unless --debug is used)
+- Captures build progress for display (disabled with --debug)
+- Uses temporary build directory if not specified
+- With --debug: inherits file descriptors and disables progress callbacks
+
+### 2. Installation Simulation
+Build output is installed to temporary directory:
+- Creates temporary install directory
+- Runs package's install script if present
+- Uses standard USM installation process
+- With --debug: inherits file descriptors for install subprocess
+
+### 3. Filesystem Scanning
+The temporary install directory is scanned recursively:
+- Traverses all files and directories
+- Identifies resource types based on file locations
+- Maps file paths to appropriate resource references
+
+### 4. Resource Type Detection
+Files are classified using these rules:
+- `/usr/bin/*` → `bin:filename`
+- `/usr/sbin/*` → `sbin:filename`
+- `/usr/lib*/*` → `lib:filename` or `libres:filename`
+- `/usr/include/*` → `inc:filename`
+- `/usr/share/applications/*` → `app:filename`
+- `/usr/share/man/*` → `man:filename`
+- `/usr/share/info/*` → `info:filename`
+- `/usr/share/locale/*` → `locale:filename`
+- `/usr/share/vala*/vapi/*` → `vapi:filename`
+- `/usr/share/gir*/*` → `gir:filename`
+- `/usr/lib*/girepository-1.0/*` → `typelib:filename`
+- `/usr/lib*/pkgconfig/*` → `pc:filename`
+- `/etc/*` → `cfg:filename`
+- `/opt/*` → `opt:filename`
+- `/usr/share/usm-tags/*` → `tag:filename`
+- Other `/usr/share/*` → `res:filename`
+- All other paths → `rootpath:filename` (fallback when no specific type matches)
+
+### 5. Output Generation
+Results are formatted as JSON provides section:
+
+```json
+"provides": {
+  "bin:myapp": "as-expected",
+  "lib:libmyapp.so": "as-expected",
+  "inc:myapp.h": "as-expected",
+  "app:myapp.desktop": "as-expected",
+  "res:myapp/icons/hicolor/256x256/apps/myapp.png": "as-expected",
+  "rootpath:boot/config": "as-expected"
+}
+```
+
+## Examples
+
+### Basic Usage
+Generate provides section and display to stdout:
+```bash
+usm manifest autoprovides
+```
+
+### With Custom Build Directory
+Use specific build directory:
+```bash
+usm manifest autoprovides /tmp/myapp-build
+```
+
+### Replace in Manifest
+Update MANIFEST.usm file directly:
+```bash
+usm manifest autoprovides --replace
+```
+
+### With Custom Build Directory and Replace
+Combine options:
+```bash
+usm manifest autoprovides --replace /tmp/myapp-build
+```
+
+### Debug Mode
+Enable debug mode for troubleshooting:
+```bash
+usm manifest autoprovides --debug
+```
+
+### Debug Mode with Replace
+Combine debug and replace options:
+```bash
+usm manifest autoprovides --debug --replace
+```
+
+### All Options Combined
+Use all available options:
+```bash
+usm manifest autoprovides --debug --replace /tmp/myapp-build
+```
+
+## Output Format
+
+### Console Output
+Without `--replace`, command outputs JSON provides section:
+```json
+"provides": {
+  "bin:myapp": "as-expected",
+  "lib:libmyapp.so": "as-expected",
+  "rootpath:custom-file": "as-expected"
+}
+```
+
+### File Update
+With `--replace`, command:
+- Updates existing MANIFEST.usm file
+- Preserves all other manifest fields
+- Replaces only the provides section
+- Creates backup of original file
+
+## Integration Workflow
+
+### 1. Initial Package Creation
+```bash
+usm scaffold myapp meson vala
+```
+
+### 2. Build and Test
+```bash
+usm manifest build /tmp/build
+```
+
+### 3. Generate Provides
+```bash
+usm manifest autoprovides --replace /tmp/build
+```
+
+### 4. Review and Customize
+Edit MANIFEST.usm to:
+- Add custom file locations
+- Remove unwanted resources
+- Specify non-standard installations
+
+### 5. Final Testing
+```bash
+usm manifest install /tmp/build
+```
+
+## Best Practices
+
+### When to Use
+- After implementing build system
+- When adding new files to package
+- After changing installation paths
+- Before package release
+
+### Review Required
+Always review generated provides for:
+- Missing important files
+- Incorrect resource types
+- Unwanted temporary files
+- Non-standard file locations
+
+### Manual Overrides
+Some files require manual specification:
+- Configuration files in custom locations
+- Data files outside standard paths
+- Generated files created during runtime
+- Optional components
+
+### Common Adjustments
+```json
+{
+  "provides": {
+    "bin:myapp": "as-expected",
+    "lib:libmyapp.so": "as-expected",
+    "cfg:myapp.conf": {"pathBase": "install", "path": "etc/myapp.conf", "type": "reg"},
+    "res:myapp/default-config": {"pathBase": "source", "path": "config/default.json", "type": "reg"},
+    "rootpath:boot/loader.conf": {"pathBase": "install", "path": "boot/loader.conf", "type": "reg"}
+  }
+}
+```
+
+## Troubleshooting
+
+### Empty Provides
+If no resources are detected:
+- Check build completed successfully
+- Verify install script exists and works
+- Ensure files are installed to correct locations
+- Check file permissions
+
+### Incorrect Resource Types
+If files are misclassified:
+- Verify build follows FHS standards
+- Check for custom installation paths
+- Review build system configuration
+- Consider manual overrides
+
+### Build Failures
+When autoprovides fails during build:
+- Check build dependencies are satisfied
+- Verify build script is executable
+- Review build script syntax
+- Test build manually first
+
+### Permission Issues
+If file update fails:
+- Check MANIFEST.usm file permissions
+- Verify directory write access
+- Ensure file is not locked by other processes
+- Run with appropriate user permissions
+
+## Advanced Usage
+
+### Debug Mode Benefits
+The `--debug` flag provides enhanced debugging capabilities:
+- **Full subprocess output**: Inherit file descriptors to see all build/install output
+- **No progress interference**: Disables progress reporting that can obscure error messages
+- **Direct error visibility**: Build and install errors appear immediately without progress overlay
+- **Process monitoring**: Enables direct observation of subprocess behavior
+
+### When to Use Debug Mode
+- Troubleshooting build failures
+- Investigating install script issues
+- Analyzing subprocess output for debugging
+- When progress reporting interferes with error visibility
+- For detailed build/install process examination
+
+### Custom Resource Mapping
+For non-standard layouts, manually edit generated provides:
+```json
+{
+  "provides": {
+    "bin:myapp": "as-expected",
+    "lib:libmyapp.so": {"pathBase": "build", "path": "custom/lib/libmyapp.so", "type": "reg"},
+    "res:myapp/data": {"pathBase": "source", "path": "data/*", "type": "reg"},
+    "rootpath:etc/custom-config": {"pathBase": "source", "path": "config/custom.conf", "type": "reg"}
+  }
+}
+```
+
+### Selective Resource Inclusion
+Remove unwanted resources from generated output:
+- Delete lines for temporary files
+- Remove development-only resources
+- Exclude documentation if not needed
+- Filter out test files
+
+---
+
+This command provides package maintainers with automated tools for generating accurate manifest provides sections.

+ 114 - 0
slopdocs/utility.usm.manifest.validate.md

@@ -0,0 +1,114 @@
+# USM Manifest Validate Command
+
+## Overview
+The `usm manifest validate` command validates USM packages by building and installing them in a temporary environment, then verifying all declared resources are present and correctly located.
+
+## Usage
+
+```bash
+usm manifest validate [build-path]
+```
+
+### Arguments
+- `build-path`: Directory for package build (optional, auto-generated if not provided)
+
+## Validation Process
+
+### 1. Manifest Validation
+- Verifies MANIFEST.usm exists and is readable
+- Checks JSON syntax and required fields
+
+### 2. Dependency Verification
+- Validates management dependencies are available
+- Checks build dependencies are satisfied
+- Ensures runtime dependencies exist
+
+### 3. Package Metadata Checks
+- Warns if no license is provided
+- Warns if no markdown description is provided
+- Warns if no URL is provided
+
+### 4. Build Execution
+- Runs build script with progress reporting
+- Uses silent output to reduce noise
+
+### 5. Installation Simulation
+- Creates temporary install directory
+- Runs install script if specified
+- Skips installation validation if no install script exists
+
+### 6. Resource Validation
+Validates each declared resource based on path base:
+
+#### as-expected
+Checks file exists at standard system location in temporary install directory.
+
+#### build:
+Checks file exists at specified path in build directory.
+
+#### source:
+Checks file exists at specified path in source directory.
+
+#### install:
+Checks file exists at specified path in install directory.
+
+### 7. Unexpected Resource Detection
+Scans install directory for resources not declared in manifest and reports warnings.
+
+## Exit Codes
+
+| Code | Description |
+|------|-------------|
+| 0 | Validation completed successfully |
+| 247 | Error validating installation result |
+| 248 | Expected resources not found |
+| 249 | Error running install exec |
+| 250 | Could not create temporary install directory |
+| 251 | Error running build exec |
+| 252 | Missing dependencies |
+| 253 | Could not read MANIFEST.usm |
+| 254 | No MANIFEST.usm file found |
+| 255 | Could not create temporary build directory |
+
+## Examples
+
+### Basic Validation
+```bash
+usm manifest validate
+```
+
+### Custom Build Directory
+```bash
+usm manifest validate /tmp/my-build
+```
+
+## Output Details
+
+The command reports:
+- Missing dependencies with specific resource names
+- Warnings for missing metadata (license, markdown, URL)
+- Build progress percentage
+- Missing expected resources with location descriptions
+- Unexpected resources found during installation scan
+- Final validation status
+
+## CI/CD Integration
+
+Designed for automated environments:
+- Non-zero exit codes indicate failures
+- Detailed error messages for quick issue identification
+- Progress reporting compatible with CI logging
+- Automatic temporary directory cleanup
+
+## Command Comparison
+
+| Command | Purpose |
+|---------|---------|
+| **validate** | Verifies package structure and resources |
+| **autoprovides** | Generates provides section from build output |
+| **test** | Runs package test scripts |
+| **install** | Installs package to system |
+
+---
+
+This command provides comprehensive package validation by building and installing packages in temporary environments to ensure all declared resources are correctly present and located.

+ 506 - 0
spm/apk/usm-spm-apk

@@ -0,0 +1,506 @@
+#!/bin/sh
+# USM system-package-manager helper for apk-based systems (Alpine).
+#
+# Implements the USM SPM contract (see usm/README.md, "System package
+# manager integration") for Alpine-style systems:
+#
+#     usm-spm-apk query <usm-ref>...       -> contract JSON on STDOUT
+#     usm-spm-apk install <native-name>... -> contract JSONL events on STDOUT
+#
+# API choice: pure POSIX shell (busybox ash) on purpose -- the Alpine
+# base image ships neither python3 nor jq, and the SPM bootstrap must
+# stay at zero extra packages (busybox is already there). apk-tools
+# >= 2.10 (alpine:latest currently ships apk-tools 3.0.6) expose
+# provides namespaces that map USM refs natively, so file-list indexes
+# are not needed for the hot types:
+#
+#     bin:/sbin: X  ->  provides "cmd:X"   (abuild auto-generates for
+#                       every packaged executable, /usr/bin and /sbin)
+#     lib: X       ->  provides "so:X"    (verbatim soname)
+#     pc: X        ->  provides "pc:${X%.pc}" -- apk indexes pkg-config
+#                      module names WITHOUT the ".pc" suffix (observed:
+#                      glib-dev provides pc:glib-2.0, vala provides
+#                      pc:libvala-0.56), so one trailing ".pc" is
+#                      stripped from the ref before querying
+#
+# Providers are found with `apk search -x <provide>` (exact match that
+# also matches provides); the from-scratch solo-install closure comes
+# from `apk search --recursive <name>` (runs the solver against the
+# repositories only -- same semantics as the dnf helper's repo-only
+# sack); installed-dependency-count is the overlap of that closure with
+# the installed database (`apk info`). Residual resource types (inc:,
+# vapi:, gir:, typelib:, gio:, res:, cfg:, man:, info:, locale:,
+# libexec:, rootpath:, tag:) are resolved against the installed database only via
+# `apk info -W`/directory sampling: APKINDEX carries no file lists, so
+# files of not-yet-installed packages are unresolvable and land in
+# "not-found" (documented limitation, the same offline stance as the
+# emerge helper).
+#
+# apk has no per-download progress output on a non-tty stdout; it
+# prints one "(k/N) Installing pkg (ver)" line per package instead, so
+# "package" events are emitted at package-completion granularity
+# (progress 1.0) driven by those lines, and begin.total comes from an
+# `apk add --simulate` pass against the current system.
+#
+# Install transactions expand INSTALL_COMPANIONS (currently gcc ->
+# musl-dev): apk's gcc package deliberately ships no crt objects, and a
+# USM manifest cannot express them as resource refs.
+#
+# The query subcommand never modifies system state: repository indexes
+# are fetched into a throwaway cache directory (at most refreshing
+# package-metadata caches). The install subcommand must run as root;
+# apk never prompts interactively: unresolved dependencies or bad
+# signatures fail instead.
+#
+# Alpine path table (musl/busybox layout, no lib64): /usr/bin, /sbin,
+# /lib, /usr/lib, /usr/include, /usr/share (+/usr/share/pkgconfig).
+
+set -eu
+
+export LC_ALL=C
+
+PROG=usm-spm-apk
+
+EXIT_OK=0
+EXIT_FAILURE=1
+EXIT_USAGE=2
+EXIT_RESOLVE=3
+EXIT_DOWNLOAD=4
+EXIT_TRANSACTION=5
+
+WORK=""
+
+cleanup() {
+    if [ -n "$WORK" ]; then
+        rm -rf "$WORK"
+    fi
+}
+
+warn() {
+    printf '%s: %s\n' "$PROG" "$*" >&2
+}
+
+die_usage() {
+    printf 'usage: %s query <usm-ref>...\n' "$PROG" >&2
+    printf '       %s plan <native-name>...\n' "$PROG" >&2
+    printf '       %s install <native-name>...\n' "$PROG" >&2
+    exit "$EXIT_USAGE"
+}
+
+emit() {
+    printf '%s\n' "$1"
+}
+
+json_escape() {
+    printf '%s' "$1" | awk '
+        {
+            s = $0
+            gsub(/\\/, "\\\\", s)
+            gsub(/"/, "\\\"", s)
+            gsub(/\t/, "\\t", s)
+            gsub(/[[:cntrl:]]/, "", s)
+            print s
+        }'
+}
+
+mkwork() {
+    WORK=$(mktemp -d "${TMPDIR:-/tmp}/usm-spm-apk.XXXXXX" 2>/dev/null) || {
+        WORK="${TMPDIR:-/tmp}/usm-spm-apk.$$"
+        (umask 077 && mkdir "$WORK")
+    }
+    trap cleanup EXIT
+}
+
+prime_index_cache() {
+    CACHE="$WORK/cache"
+    PRIMED=0
+    if mkdir -p "$CACHE" && apk update -q --cache-dir "$CACHE" >/dev/null 2>"$WORK/prime.err"; then
+        if [ -n "$(ls -A "$CACHE" 2>/dev/null)" ]; then
+            PRIMED=1
+        fi
+    fi
+    if [ "$PRIMED" != 1 ]; then
+        warn "could not prime a repository index cache; every lookup will fetch indexes"
+    fi
+}
+
+sp_search() {
+    if [ "$PRIMED" = 1 ]; then
+        apk search --cache-dir "$CACHE" "$@"
+    else
+        apk search --no-cache "$@"
+    fi
+}
+
+
+apk_diag_line() {
+    [ -r "$1" ] || return 0
+    sed -n 's/^\(ERROR\|WARNING\):[[:space:]]*//p' "$1" | head -n 1
+}
+
+strip_version() {
+    sed -e 's/-r[0-9][0-9]*$//' -e 's/-[0-9][0-9A-Za-z._+]*$//'
+}
+
+not_found_add() {
+    printf '%s\n' "$1" >>"$WORK/nf"
+}
+
+map_add() {
+    printf '%s\t%s\n' "$1" "$2" >>"$WORK/map"
+}
+
+record_providers() {
+    _rp_ref=$1
+    _rp_prov=$2
+    _rp_out=$(sp_search -x -- "$_rp_prov" 2>"$WORK/search.err" || true)
+    if { [ -z "$_rp_out" ] && [ -s "$WORK/search.err" ]; } || grep -q '^ERROR' "$WORK/search.err" 2>/dev/null; then
+        warn "apk search failed for \"$_rp_prov\": $(apk_diag_line "$WORK/search.err")"
+        exit "$EXIT_RESOLVE"
+    fi
+    if [ -n "$_rp_out" ]; then
+        printf '%s\n' "$_rp_out" | strip_version | sort -u >"$WORK/pv"
+        while IFS= read -r _rp_nm; do
+            [ -n "$_rp_nm" ] || continue
+            map_add "$_rp_nm" "$_rp_ref"
+        done <"$WORK/pv"
+    else
+        not_found_add "$_rp_ref"
+    fi
+}
+
+record_file_owner() {
+    _fo_ref=$1
+    shift
+    _fo_hit=0
+    for _fo_p in "$@"; do
+        _fo_f=
+        if [ -d "$_fo_p" ]; then
+            _fo_f=$(find "$_fo_p" -type f 2>/dev/null | head -n 1) || _fo_f=
+        elif [ -f "$_fo_p" ]; then
+            _fo_f=$_fo_p
+        else
+            continue
+        fi
+        [ -n "$_fo_f" ] || continue
+        _fo_own=$(apk info -W "$_fo_f" 2>/dev/null) || continue
+        _fo_nm=$(printf '%s\n' "$_fo_own" | sed -n 's/^.* is owned by //p' | strip_version)
+        [ -n "$_fo_nm" ] || continue
+        map_add "$_fo_nm" "$_fo_ref"
+        _fo_hit=1
+    done
+    if [ "$_fo_hit" != 1 ]; then
+        not_found_add "$_fo_ref"
+    fi
+}
+
+handle_ref() {
+    _hr_ref=$1
+    _hr_type=${_hr_ref%%:*}
+    _hr_res=${_hr_ref#*:}
+    if [ "$_hr_type" = "$_hr_ref" ] || [ -z "$_hr_res" ]; then
+        warn "resource type of \"$_hr_ref\" has no system-package-manager translation"
+        not_found_add "$_hr_ref"
+        return
+    fi
+    case $_hr_res in
+        *[!A-Za-z0-9._+/-]*)
+            warn "resource name of \"$_hr_ref\" has no system-package-manager translation"
+            not_found_add "$_hr_ref"
+            return
+            ;;
+    esac
+    case $_hr_type in
+        bin|sbin)
+            record_providers "$_hr_ref" "cmd:$_hr_res"
+            ;;
+        lib)
+            record_providers "$_hr_ref" "so:$_hr_res"
+            ;;
+        pc)
+            record_providers "$_hr_ref" "pc:${_hr_res%.pc}"
+            ;;
+        libexec)
+            record_file_owner "$_hr_ref" "/usr/libexec/$_hr_res"
+            ;;
+        gir)
+            record_file_owner "$_hr_ref" "/usr/share/gir-1.0/$_hr_res"
+            ;;
+        typelib)
+            record_file_owner "$_hr_ref" "/usr/lib/girepository-1.0/$_hr_res"
+            ;;
+        gio)
+            record_file_owner "$_hr_ref" "/usr/lib/gio/modules/$_hr_res"
+            ;;
+        res)
+            record_file_owner "$_hr_ref" "/usr/share/$_hr_res"
+            ;;
+        cfg)
+            record_file_owner "$_hr_ref" "/etc/$_hr_res"
+            ;;
+        man)
+            record_file_owner "$_hr_ref" "/usr/share/man/$_hr_res"
+            ;;
+        info)
+            record_file_owner "$_hr_ref" "/usr/share/info/$_hr_res"
+            ;;
+        locale)
+            record_file_owner "$_hr_ref" "/usr/share/locale/$_hr_res"
+            ;;
+        inc)
+            record_file_owner "$_hr_ref" "/usr/include/$_hr_res"
+            ;;
+        vapi)
+            set -- "/usr/share/vala/vapi/$_hr_res"
+            for _hr_d in /usr/share/vala-*/vapi/"$_hr_res"; do
+                if [ -f "$_hr_d" ]; then
+                    set -- "$@" "$_hr_d"
+                fi
+            done
+            record_file_owner "$_hr_ref" "$@"
+            ;;
+        rootpath)
+            record_file_owner "$_hr_ref" "/$_hr_res"
+            ;;
+        tag)
+            _hr_t=${_hr_res%.tag}
+            record_file_owner "$_hr_ref" "/usr/share/usm-tags/$(printf '%s' "$_hr_t" | tr '.' '/').tag"
+            ;;
+        *)
+            warn "resource type of \"$_hr_ref\" has no system-package-manager translation"
+            not_found_add "$_hr_ref"
+            return
+            ;;
+    esac
+}
+
+counts_for() {
+    _cf_name=$1
+    _cf_cached=
+    if [ -s "$WORK/counts" ]; then
+        _cf_cached=$(awk -F'\t' -v n="$_cf_name" '$1 == n { print $2 " " $3; exit }' "$WORK/counts") || _cf_cached=
+    fi
+    if [ -n "$_cf_cached" ]; then
+        printf '%s\n' "$_cf_cached"
+        return
+    fi
+    _cf_cl=$(sp_search --recursive -- "$_cf_name" 2>"$WORK/rec.err" || true)
+    if [ -z "$_cf_cl" ] || grep -q '^ERROR' "$WORK/rec.err" 2>/dev/null; then
+        warn "could not resolve solo install of $_cf_name, estimating dependency counts"
+        if grep -qx -F "$_cf_name" "$WORK/installed" 2>/dev/null; then
+            printf '1 1\n'
+        else
+            printf '1 0\n'
+        fi
+        return
+    fi
+    printf '%s\n' "$_cf_cl" | strip_version >"$WORK/cl"
+    _cf_dep=$(awk 'END { print NR }' "$WORK/cl")
+    _cf_inst=0
+    if [ -s "$WORK/installed" ]; then
+        _cf_inst=$(grep -x -F -f "$WORK/installed" "$WORK/cl" | awk 'END { print NR }') || _cf_inst=0
+    fi
+    printf '%s\t%s\t%s\n' "$_cf_name" "$_cf_dep" "$_cf_inst" >>"$WORK/counts"
+    printf '%s %s\n' "$_cf_dep" "$_cf_inst"
+}
+
+cmd_query() {
+    [ $# -ge 1 ] || die_usage
+    mkwork
+    prime_index_cache
+    apk info >"$WORK/installed" 2>/dev/null || : >"$WORK/installed"
+    : >"$WORK/map"
+    : >"$WORK/nf"
+    : >"$WORK/counts"
+    : >"$WORK/seen"
+    for _q_ref in "$@"; do
+        if [ -n "$_q_ref" ] && grep -x -F -q "$_q_ref" "$WORK/seen" 2>/dev/null; then
+            continue
+        fi
+        printf '%s\n' "$_q_ref" >>"$WORK/seen"
+        handle_ref "$_q_ref"
+    done
+    _q_out='{"not-found":['
+    _q_first=1
+    while IFS= read -r _q_r; do
+        [ -n "$_q_r" ] || continue
+        if [ "$_q_first" = 1 ]; then _q_first=0; else _q_out="$_q_out,"; fi
+        _q_out="$_q_out\"$(json_escape "$_q_r")\""
+    done <"$WORK/nf"
+    _q_out="$_q_out],\"packages\":["
+    _q_first=1
+    for _q_nm in $(awk -F'\t' '{ print $1 }' "$WORK/map" | sort -u); do
+        if [ "$_q_first" = 1 ]; then _q_first=0; else _q_out="$_q_out,"; fi
+        awk -F'\t' -v n="$_q_nm" '$1 == n { print $2 }' "$WORK/map" >"$WORK/tmpres"
+        _q_res=""
+        _q_rf=1
+        while IFS= read -r _q_rr; do
+            if [ "$_q_rf" = 1 ]; then _q_rf=0; else _q_res="$_q_res,"; fi
+            _q_res="$_q_res\"$(json_escape "$_q_rr")\""
+        done <"$WORK/tmpres"
+        _q_cnts=$(counts_for "$_q_nm")
+        _q_dep=${_q_cnts%% *}
+        _q_inst=${_q_cnts##* }
+        _q_out="$_q_out{\"name\":\"$(json_escape "$_q_nm")\",\"resources\":[${_q_res}],\"dependency-count\":$_q_dep,\"installed-dependency-count\":$_q_inst}"
+    done
+    _q_out="$_q_out]}"
+    emit "$_q_out"
+    exit "$EXIT_OK"
+}
+
+classify_failure() {
+    if grep -Eqi 'fetch|download|network|temporary failure|connection refused|timed out|untrusted|checksum|signature|mirror|404|503' "$1" 2>/dev/null; then
+        printf '%s\n' "$EXIT_DOWNLOAD"
+    elif grep -Eq 'unable to select|constraint|conflict|world' "$1" 2>/dev/null; then
+        printf '%s\n' "$EXIT_RESOLVE"
+    else
+        printf '%s\n' "$EXIT_TRANSACTION"
+    fi
+}
+
+# Packages whose apk split leaves a toolchain unusable alone: gcc ships no
+# crt objects (musl-dev owns Scrt1.o/crti.o/libssp_nonshared.a), so linking
+# fails without it. USM manifests cannot express crt files as resource
+# refs; this table completes the toolchain at install time instead.
+INSTALL_COMPANIONS="gcc:musl-dev"
+
+cmd_install() {
+    [ $# -ge 1 ] || die_usage
+    for _i_n in "$@"; do
+        case $_i_n in
+            -*) die_usage ;;
+        esac
+    done
+    _i_expanded=""
+    for _i_n in "$@"; do
+        _i_expanded="$_i_expanded $_i_n"
+        for _i_c in $INSTALL_COMPANIONS; do
+            if [ "${_i_c%%:*}" = "$_i_n" ]; then
+                _i_expanded="$_i_expanded ${_i_c#*:}"
+            fi
+        done
+    done
+    set -- $_i_expanded
+    mkwork
+    _i_simrc=0
+    _i_sim=$(apk add --simulate --no-cache --no-progress -- "$@" 2>"$WORK/sim.err") || _i_simrc=$?
+    if [ "$_i_simrc" != 0 ]; then
+        _i_msg=$(apk_diag_line "$WORK/sim.err")
+        [ -n "$_i_msg" ] || _i_msg="apk add --simulate failed with status $_i_simrc"
+        emit "{\"type\":\"error\",\"message\":\"$(json_escape "$_i_msg")\"}"
+        exit "$(classify_failure "$WORK/sim.err")"
+    fi
+    _i_total=$(printf '%s\n' "$_i_sim" | sed -n 's/^(\([0-9][0-9]*\)\/\([0-9][0-9]*\)).*/\2/p' | tail -n 1)
+    [ -n "$_i_total" ] || _i_total=0
+    emit "{\"type\":\"begin\",\"total\":$_i_total}"
+    {
+        apk add --no-cache --no-progress -- "$@" 2>"$WORK/inst.err"
+        printf '%s\n' "$?" >"$WORK/rc"
+    } | {
+        _i_done=0
+        while IFS= read -r _i_line || [ -n "$_i_line" ]; do
+            case $_i_line in
+                \(*\))
+                    _i_kn=${_i_line#"("}
+                    _i_kn=${_i_kn%%")"*}
+                    _i_k=${_i_kn%%/*}
+                    _i_n=${_i_kn##*/}
+                    _i_rest=${_i_line#*") "}
+                    _i_verb=${_i_rest%% *}
+                    _i_pkg=${_i_rest#* }
+                    _i_pkg=${_i_pkg%% *}
+                    case $_i_verb in
+                        Installing|Upgrading|Reinstalling|Downgrading)
+                            emit "{\"type\":\"package\",\"name\":\"$(json_escape "$_i_pkg")\",\"current\":$_i_k,\"total\":$_i_n,\"progress\":1.0}"
+                            emit "{\"type\":\"package-complete\",\"name\":\"$(json_escape "$_i_pkg")\"}"
+                            _i_done=$((_i_done + 1))
+                            ;;
+                    esac
+                    ;;
+            esac
+        done
+        printf '%s\n' "$_i_done" >"$WORK/done"
+    }
+    _i_rc=$(cat "$WORK/rc" 2>/dev/null) || _i_rc=1
+    if [ "$_i_rc" = 0 ]; then
+        _i_done=$(cat "$WORK/done" 2>/dev/null) || _i_done=0
+        emit "{\"type\":\"complete\",\"status\":\"ok\",\"installed\":$_i_done}"
+        exit "$EXIT_OK"
+    fi
+    _i_msg=$(apk_diag_line "$WORK/inst.err")
+    [ -n "$_i_msg" ] || _i_msg="apk add failed with status $_i_rc"
+    emit "{\"type\":\"error\",\"message\":\"$(json_escape "$_i_msg")\"}"
+    exit "$(classify_failure "$WORK/inst.err")"
+}
+
+cmd_plan() {
+    [ $# -ge 1 ] || die_usage
+    for _p_n in "$@"; do
+        case $_p_n in
+            -*) die_usage ;;
+        esac
+    done
+    _p_expanded=""
+    for _p_n in "$@"; do
+        _p_expanded="$_p_expanded $_p_n"
+        for _p_c in $INSTALL_COMPANIONS; do
+            if [ "${_p_c%%:*}" = "$_p_n" ]; then
+                _p_expanded="$_p_expanded ${_p_c#*:}"
+            fi
+        done
+    done
+    set -- $_p_expanded
+    mkwork
+    _p_rc=0
+    _p_sim=$(apk add --simulate --no-cache --no-progress -- "$@" 2>"$WORK/plan.err") || _p_rc=$?
+    if [ "$_p_rc" != 0 ]; then
+        _p_msg=$(apk_diag_line "$WORK/plan.err")
+        [ -n "$_p_msg" ] || _p_msg="apk add --simulate failed with status $_p_rc"
+        printf '%s: %s\n' "$PROG" "$_p_msg" >&2
+        exit "$(classify_failure "$WORK/plan.err")"
+    fi
+    # simulate output names every package the transaction would touch:
+    # "(3/7) Installing musl (1.2.5-r1)"
+    _p_json=$(printf '%s\n' "$_p_sim" \
+        | sed -n 's/^(\([0-9][0-9]*\)\/\([0-9][0-9]*\)) Installing \([^ ]*\) .*/"\3"/p' \
+        | awk 'NR>1{printf ","} {printf "%s", $0}')
+    printf '{"packages":[%s]}\n' "$_p_json"
+    exit "$EXIT_OK"
+}
+
+main() {
+    if [ $# -lt 1 ]; then
+        die_usage
+    fi
+    if ! command -v apk >/dev/null 2>&1; then
+        if [ "$1" = install ]; then
+            emit '{"type":"error","message":"apk not found in PATH"}'
+            exit "$EXIT_FAILURE"
+        fi
+        warn "apk not found in PATH"
+        exit "$EXIT_RESOLVE"
+    fi
+    _m_cmd=$1
+    shift
+    case $_m_cmd in
+        query)
+            cmd_query "$@"
+            ;;
+        plan)
+            cmd_plan "$@"
+            ;;
+        install)
+            cmd_install "$@"
+            ;;
+        -h|--help|help)
+            printf 'usage: %s query <usm-ref>...\n' "$PROG"
+            printf '       %s install <native-name>...\n' "$PROG"
+            exit "$EXIT_OK"
+            ;;
+        *)
+            die_usage
+            ;;
+    esac
+}
+
+main "$@"

+ 677 - 0
spm/apt/usm-spm-apt

@@ -0,0 +1,677 @@
+#!/usr/bin/env python3
+"""USM system-package-manager helper for APT-based systems.
+
+Implements the USM SPM contract (see usm/README.md, "System package manager
+integration") for Debian-style systems:
+
+    usm-spm-apt query <usm-ref>...   -> contract JSON on STDOUT
+    usm-spm-apt install <name>...    -> contract JSONL events on STDOUT
+
+File queries are answered from apt-file's Contents index ONLY. This helper
+therefore requires apt-file to be installed and its index updated
+(`apt-file update`; ~100 MB of Contents per enabled architecture -- the
+container bootstrap used by `usm deploy --spm apt` performs both). There
+are no package-name heuristics: any ref whose translated path has no owner
+in the index lands in not-found. The two sanctioned exceptions are curated
+tables: SONAME_PACKAGES (sonames whose Debian package lags the upstream
+soname the manifests reference) and PACKAGE_REMAPS (owners remapped to the
+package that makes the toolchain usable, e.g. valac-bin to the valac
+meta-package carrying the split-out vapis). One
+deviation from the DNF helper, which
+unions repository filelists with the local rpmdb: files that exist only on
+the local system (packages installed from .debs outside the configured
+repositories) are invisible to apt-file, so such refs land in not-found.
+The DPKG database is consulted for package NAMES only, to compute
+installed-dependency-count.
+
+The query translation mirrors spm/dnf with Debian multiarch paths,
+preferring x86_64 and following the machine architecture otherwise:
+
+    bin/sbin/libexec -> /usr/bin, /usr/sbin, /usr/bin (Debian ships helper
+                        executables under /usr/bin; no /usr/libexec tree)
+    lib               -> /usr/lib/<triple>/<soname> and /lib/<triple>/<soname>
+                         (usr-merge: both path forms appear across suites)
+    gio               -> /usr/lib/<triple>/gio/modules/<module>
+    pc                -> /usr/lib/<triple>/pkgconfig/<n> and /usr/share/pkgconfig/<n>
+    inc               -> /usr/include/<P> prefix
+    typelib           -> /usr/lib/<triple>/girepository-1.0/<n>
+    vapi              -> /usr/share/vala/vapi/<n> and /usr/share/vala-*/vapi/<n>
+    gir/res/cfg/man/info/locale/rootpath/tag -> as in the DNF table
+
+apt-file is invoked as `apt-file search --fixed-string` (exact paths) and
+`apt-file search --substring-match` (anchored prefixes: inc: and the
+versioned vapi directory), with all patterns of one mode batched via
+--from-file (falling back to one invocation per pattern on apt-file builds
+without --from-file). apt-file 3.3 reads --fixed-string as a full-path
+exact match and exits 1 without diagnostics when a pattern has no owner,
+which this helper treats as not-found rather than failure; every result is
+additionally re-validated against an exact or anchored-prefix path
+predicate before being credited to a ref, so no over-matches leak into the
+contract output.
+
+dependency-count models "a solo install on this machine" exactly like the
+DNF helper's repository-only sack: python3-apt resolves the package with
+DPkg status pointed at /dev/null (nothing installed), so every dependency
+resolves as an install; the overlap with the real DPKG status is reported
+as installed-dependency-count. If the python3-apt cache cannot be built or
+a package cannot be marked, counts degrade to the DNF helper's fallback
+(1, installed ? 1 : 0) with a note on STDERR.
+
+Install runs one `apt-get install -y` transaction with
+DEBIAN_FRONTEND=noninteractive and never prompts. An `apt-get --simulate`
+pass first provides the transaction size and order for the begin event;
+real-run "Get:", "Unpacking" and "Setting up" lines are then mapped to
+(coalesced) package progress events and package-complete events. There is
+no --test mode: apt cannot run the dpkg transaction phases that the DNF
+--test mode exercises. Exit codes match the contract table, with 5 meaning
+a dpkg transaction failure (apt's analogue of the rpm code).
+"""
+
+import argparse
+import json
+import os
+import platform
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+
+import apt.progress.base
+import apt_pkg
+
+MULTIARCH_TRIPLES = {
+    "x86_64": "x86_64-linux-gnu",
+    "aarch64": "aarch64-linux-gnu",
+    "armv7l": "arm-linux-gnueabihf",
+    "i386": "i386-linux-gnu",
+    "i686": "i386-linux-gnu",
+    "ppc64le": "powerpc64le-linux-gnu",
+    "s390x": "s390x-linux-gnu",
+}
+TRIPLE = MULTIARCH_TRIPLES.get(platform.machine(), "x86_64-linux-gnu")
+
+# Sonames whose Debian package lags the soname the Web-Stack manifests
+# reference: the Contents index has no owner for the exact path, but the
+# library is present under an older soname (libsodium23 ships
+# libsodium.so.23 while the manifests reference the upstream .26). Curated
+# like the emerge helper's atom exception table; entries are validated
+# against the apt cache so a stale mapping degrades to not-found instead
+# of resolving a ghost package.
+SONAME_PACKAGES = {
+    "libsodium.so.26": "libsodium23",
+}
+
+# Owners the Contents index reports for a file, remapped to the package
+# that actually makes the toolchain usable: Debian splits valac's vapis
+# into valac-<version>-vapi, which valac-bin does not depend on, and keeps
+# g-ir-compiler's real binary in gobject-introspection-bin-linux, which
+# gobject-introspection-bin (owner of the dangling /usr/bin symlink) does
+# not depend on either; the valac and gobject-introspection meta-packages
+# pull each split together. Answering with the meta-package keeps one
+# install transaction sufficient.
+PACKAGE_REMAPS = {
+    "valac-bin": "valac",
+    "gobject-introspection-bin": "gobject-introspection",
+}
+
+EXIT_OK = 0
+EXIT_FAILURE = 1
+EXIT_RESOLVE = 3
+EXIT_DOWNLOAD = 4
+EXIT_TRANSACTION = 5
+
+GET_LINE = re.compile(r"^Get:\d+\s")
+UNPACK_LINE = re.compile(r"^Unpacking\s+(\S+)")
+SETUP_LINE = re.compile(r"^Setting up\s+(\S+)")
+INST_LINE = re.compile(r"^Inst\s+(\S+)")
+ERROR_LINE = re.compile(r"^E:\s+")
+
+
+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-apt: %s\n" % message)
+    sys.exit(exit_code)
+
+
+def plain_name(token):
+    """Strip a multiarch :arch qualifier from an apt/dpkg package token."""
+    return token.split(":", 1)[0]
+
+
+def exact(path):
+    """Descriptor for one exact indexed path (apt-file --fixed-string)."""
+    return ("exact", path, lambda candidate, path=path: candidate == path)
+
+
+def prefixed(path):
+    """Descriptor matching indexed paths under path (--substring-match)."""
+    return ("substring", path,
+            lambda candidate, path=path: candidate.startswith(path))
+
+
+def vapi_versioned(resource):
+    """Descriptor for /usr/share/vala-*/vapi/<resource>."""
+    return (
+        "substring",
+        "/usr/share/vala-",
+        lambda candidate, resource=resource: (
+            candidate.startswith("/usr/share/vala-")
+            and candidate.endswith("/vapi/" + resource)
+        ),
+    )
+
+
+def translate_ref(ref):
+    """Map a USM resource ref to apt-file (mode, pattern, predicate) triples.
+
+    Mode is "exact" for --fixed-string full-path matches and "substring"
+    for --substring-match anchored prefixes. Returns None for refs whose
+    type has no file translation.
+    """
+    prefix, sep, resource = ref.partition(":")
+    if not sep or not resource or any(ord(c) < 32 for c in ref):
+        return None
+    file_roots = {
+        "bin": "/usr/bin",
+        "sbin": "/usr/sbin",
+        "libexec": "/usr/bin",
+        "gir": "/usr/share/gir-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 [exact(file_roots[prefix] + "/" + resource)]
+    if prefix == "typelib":
+        return [exact("/usr/lib/%s/girepository-1.0/%s" % (TRIPLE, resource))]
+    if prefix == "vapi":
+        return [
+            exact("/usr/share/vala/vapi/" + resource),
+            vapi_versioned(resource),
+        ]
+    if prefix == "lib":
+        return [
+            exact("/usr/lib/%s/%s" % (TRIPLE, resource)),
+            exact("/lib/%s/%s" % (TRIPLE, resource)),
+        ]
+    if prefix == "gio":
+        return [exact("/usr/lib/%s/gio/modules/%s" % (TRIPLE, resource))]
+    if prefix == "pc":
+        return [
+            exact("/usr/lib/%s/pkgconfig/%s" % (TRIPLE, resource)),
+            exact("/usr/share/pkgconfig/" + resource),
+        ]
+    if prefix == "inc":
+        return [prefixed("/usr/include/" + resource)]
+    if prefix == "rootpath":
+        return [exact("/" + resource.lstrip("/"))]
+    if prefix == "tag":
+        return [exact("/usr/share/usm-tags/" + resource.replace(".", "/") + ".tag")]
+    return None
+
+
+def first_error_line(text):
+    for line in text.splitlines():
+        if ERROR_LINE.match(line):
+            return line.strip()
+    return None
+
+
+def parse_owner_lines(text):
+    """Extract (package, path) pairs from apt-file's "package: path" lines.
+
+    apt-file's progress chatter and anything that does not look like an
+    absolute path after "package: " is ignored.
+    """
+    owners = []
+    for line in text.splitlines():
+        package, sep, path = line.strip().partition(": ")
+        if not sep or not path.startswith("/") or " " in package:
+            continue
+        owners.append((plain_name(package), path))
+    return owners
+
+
+def run_apt_file(arguments):
+    if shutil.which("apt-file") is None:
+        raise HelperError(
+            "apt-file is not installed; the SPM bootstrap must install it "
+            "and run apt-file update", EXIT_RESOLVE)
+    return subprocess.run(
+        ["apt-file", "search"] + arguments,
+        stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+        universal_newlines=True,
+    )
+
+
+def option_unsupported(stderr):
+    lowered = stderr.lower()
+    return any(marker in lowered for marker in
+               ("unknown option", "unrecognized", "usage:", "usage error"))
+
+
+def apt_file_search(mode, patterns):
+    """Search the apt-file Contents index for every pattern in one mode."""
+    results = []
+    if not patterns:
+        return results
+    flags = ["--fixed-string"] if mode == "exact" else ["--substring-match"]
+    handle = tempfile.NamedTemporaryFile(
+        "w", suffix=".usm-spm-apt-patterns", delete=False)
+    try:
+        handle.write("\n".join(patterns) + "\n")
+        handle.close()
+        process = run_apt_file(flags + ["--from-file", handle.name])
+        if process.returncode == 0:
+            return parse_owner_lines(process.stdout)
+        if option_unsupported(process.stderr):
+            for pattern in patterns:
+                process = run_apt_file(flags + [pattern])
+                if process.returncode != 0 and first_error_line(
+                        process.stderr + process.stdout) is not None:
+                    raise HelperError(
+                        first_error_line(process.stderr + process.stdout),
+                        EXIT_RESOLVE)
+                results.extend(parse_owner_lines(process.stdout))
+            return results
+        error = first_error_line(process.stderr + process.stdout)
+        if error is not None:
+            raise HelperError(error, EXIT_RESOLVE)
+        return results
+    finally:
+        os.unlink(handle.name)
+
+
+def dpkg_installed_names():
+    """Names of every installed package, from the dpkg status database."""
+    try:
+        process = subprocess.run(
+            ["dpkg-query", "--show", "--showformat", "${Package}\n"],
+            stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+            universal_newlines=True)
+    except OSError:
+        return set()
+    if process.returncode != 0:
+        return set()
+    return set(process.stdout.split())
+
+
+def build_solo_cache():
+    """An apt cache whose dpkg status is empty (nothing installed).
+
+    The DNF helper builds its solo-install estimates against a repository-
+    only sack; pointing Dir::State::status at /dev/null is apt's analogue,
+    so every dependency of a marked package resolves as an install.
+    """
+    apt_pkg.init()
+    apt_pkg.config.set("Dir::State::status", os.devnull)
+    return apt_pkg.Cache(apt.progress.base.OpProgress())
+
+
+def solo_closure(cache, installed_names, name, closure_cache):
+    """(dependency-count, installed-dependency-count) for a solo install."""
+    if name in closure_cache:
+        return closure_cache[name]
+
+    counts = (1, 1 if name in installed_names else 0)
+    if cache is None:
+        sys.stderr.write(
+            "usm-spm-apt: no python3-apt cache available, estimating "
+            "dependency counts of %s as installed-only\n" % name)
+        closure_cache[name] = counts
+        return counts
+
+    try:
+        package = cache[name]
+    except KeyError:
+        package = None
+    if package is None:
+        sys.stderr.write(
+            "usm-spm-apt: %s not found in enabled repositories, "
+            "estimating dependency counts as installed-only\n" % name)
+        closure_cache[name] = counts
+        return counts
+
+    depcache = apt_pkg.DepCache(cache)
+    depcache.init()
+    if depcache.get_candidate_ver(package) is None:
+        sys.stderr.write(
+            "usm-spm-apt: %s has no installation candidate, "
+            "estimating dependency counts as installed-only\n" % name)
+        closure_cache[name] = counts
+        return counts
+
+    try:
+        depcache.mark_install(package, True, True)
+        closure = [p.name for p in cache.packages if depcache.marked_install(p)]
+        counts = (
+            len(closure),
+            sum(1 for member in closure if member in installed_names),
+        )
+    except SystemError as e:
+        sys.stderr.write(
+            "usm-spm-apt: could not resolve solo install of %s: %s\n"
+            % (name, e))
+    closure_cache[name] = counts
+    return counts
+
+
+def soname_exception_package(soname):
+    """The cached package for a {@link SONAME_PACKAGES} soname, or None.
+
+    The mapping is only trusted when the apt cache actually offers the
+    package, so a stale table entry degrades to not-found.
+    """
+    name = SONAME_PACKAGES.get(soname)
+    if name is None:
+        return None
+    try:
+        cache = apt.Cache()
+        if name in cache and cache[name].candidate is not None:
+            return name
+    except Exception:
+        pass
+    return None
+
+
+def cached_package_name(name):
+    """The remapped {@link PACKAGE_REMAPS} target for an owner, or None.
+
+    The remap is only trusted when the apt cache actually offers the
+    target, so a stale table entry keeps the index-reported owner.
+    """
+    target = PACKAGE_REMAPS.get(name)
+    if target is None:
+        return None
+    try:
+        cache = apt.Cache()
+        if target in cache and cache[target].candidate is not None:
+            return target
+    except Exception:
+        pass
+    return None
+
+
+def run_query(args):
+    refs = list(dict.fromkeys(args.refs))
+    translated = {}
+    patterns = {"exact": set(), "substring": set()}
+    for ref in refs:
+        descriptors = translate_ref(ref)
+        if descriptors is not None:
+            translated[ref] = descriptors
+            for mode, pattern, _ in descriptors:
+                patterns[mode].add(pattern)
+
+    index = []
+    for mode in ("exact", "substring"):
+        index.extend(apt_file_search(mode, sorted(patterns[mode])))
+
+    not_found = []
+    candidates = {}
+    for ref in refs:
+        descriptors = translated.get(ref)
+        if descriptors is None:
+            sys.stderr.write(
+                "usm-spm-apt: resource type of \"%s\" has no "
+                "system-package-manager translation\n" % ref)
+            not_found.append(ref)
+            continue
+        matched = set()
+        for mode, pattern, predicate in descriptors:
+            for name, path in index:
+                if predicate(path):
+                    matched.add(name)
+        remapped = set()
+        for name in sorted(matched):
+            remapped.add(cached_package_name(name) or name)
+        matched = remapped
+        if not matched and ref.startswith("lib:"):
+            mapped = soname_exception_package(ref[len("lib:"):])
+            if mapped is not None:
+                matched.add(mapped)
+        if not matched:
+            not_found.append(ref)
+            continue
+        for name in matched:
+            entry = candidates.get(name)
+            if entry is None:
+                entry = {"resources": []}
+                candidates[name] = entry
+            entry["resources"].append(ref)
+
+    installed_names = dpkg_installed_names()
+    solo_cache = build_solo_cache() if candidates else None
+    closure_cache = {}
+    packages = []
+    for name in sorted(candidates):
+        dependency_count, installed_dependency_count = solo_closure(
+            solo_cache, installed_names, name, closure_cache)
+        packages.append({
+            "name": name,
+            "resources": candidates[name]["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
+
+
+def apt_environment():
+    environment = dict(os.environ)
+    environment["DEBIAN_FRONTEND"] = "noninteractive"
+    environment["LC_ALL"] = "C"
+    return environment
+
+
+def classify_apt_failure(output):
+    lowered = output.lower()
+    if "failed to fetch" in lowered or "unable to fetch" in lowered:
+        return EXIT_DOWNLOAD
+    if ("unable to locate" in lowered or "unmet dependencies" in lowered
+            or "no installation candidate" in lowered):
+        return EXIT_RESOLVE
+    return EXIT_TRANSACTION
+
+
+def collect_error_messages(output, limit=5):
+    messages = [line.strip() for line in output.splitlines()
+                if ERROR_LINE.match(line)]
+    return "; ".join(messages[-limit:])
+
+
+def simulate_install(names, environment):
+    """Ordered package list of the would-be transaction, from -s output."""
+    process = subprocess.run(
+        ["apt-get", "--simulate", "install"] + names,
+        stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+        universal_newlines=True, env=environment)
+    if process.returncode != 0:
+        combined = process.stderr + process.stdout
+        raise HelperError(
+            collect_error_messages(combined)
+            or "apt-get simulation failed for %s" % " ".join(names),
+            classify_apt_failure(combined))
+    order = []
+    for line in process.stdout.splitlines():
+        match = INST_LINE.match(line)
+        if match:
+            name = plain_name(match.group(1))
+            if name not in order:
+                order.append(name)
+    return order
+
+
+def cmd_plan(args):
+    environment = apt_environment()
+    try:
+        order = simulate_install(args.names, environment)
+    except HelperError as e:
+        fail_plain(str(e), e.exit_code)
+    sys.stdout.write(json.dumps({"packages": order}) + "\n")
+    sys.stdout.flush()
+    return EXIT_OK
+
+
+def cmd_query(args):
+    try:
+        return run_query(args)
+    except SystemExit:
+        raise
+    except HelperError as e:
+        fail_plain(str(e), e.exit_code)
+    except Exception as e:
+        fail_plain("query failed: %s" % e, EXIT_RESOLVE)
+
+
+class InstallProgress:
+    """Maps apt-get output lines to contract package/complete events."""
+
+    def __init__(self, order):
+        self.position = {name: index for index, name in enumerate(order, 1)}
+        self.total = len(order)
+        self.completed = 0
+        self.last = None
+
+    def emit_package(self, name, progress):
+        if name not in self.position:
+            return
+        event = ("package", name, self.position[name], self.total,
+                 round(progress, 4))
+        if event == self.last:
+            return
+        self.last = event
+        emit_event({
+            "type": "package",
+            "name": name,
+            "current": self.position[name],
+            "total": self.total,
+            "progress": round(progress, 4),
+        })
+
+    def feed(self, line):
+        match = GET_LINE.match(line)
+        if match:
+            for token in line.split()[1:]:
+                candidate = plain_name(token)
+                if candidate in self.position:
+                    self.emit_package(candidate, 0.0)
+                    break
+            return
+        match = UNPACK_LINE.match(line)
+        if match:
+            self.emit_package(plain_name(match.group(1)), 0.5)
+            return
+        match = SETUP_LINE.match(line)
+        if match:
+            name = plain_name(match.group(1))
+            if name in self.position:
+                self.emit_package(name, 1.0)
+                emit_event({"type": "package-complete", "name": name})
+                self.completed += 1
+
+
+def cmd_install(args):
+    environment = apt_environment()
+    try:
+        order = simulate_install(args.names, environment)
+    except HelperError as e:
+        fail(str(e), e.exit_code)
+
+    emit_event({"type": "begin", "total": len(order)})
+    progress = InstallProgress(order)
+
+    process = subprocess.Popen(
+        ["apt-get", "install", "-y"] + args.names,
+        stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+        universal_newlines=True, env=environment)
+    output = []
+    for line in process.stdout:
+        output.append(line)
+        progress.feed(line.rstrip("\r\n"))
+    process.stdout.close()
+    returncode = process.wait()
+
+    transcript = "".join(output)
+    if returncode != 0:
+        fail(collect_error_messages(transcript)
+             or "apt-get install failed with exit code %d" % returncode,
+             classify_apt_failure(transcript))
+
+    emit_event({
+        "type": "complete",
+        "status": "ok",
+        "installed": progress.completed,
+    })
+    return EXIT_OK
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        prog="usm-spm-apt",
+        description="USM system-package-manager helper for APT")
+    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)
+
+    plan_parser = subparsers.add_parser(
+        "plan", help="resolve the material install set for names "
+                     "(chosen packages plus all dependencies)")
+    plan_parser.add_argument(
+        "names", nargs="+", metavar="NAME",
+        help="native system package name")
+    plan_parser.set_defaults(handler=cmd_plan)
+
+    install_parser = subparsers.add_parser(
+        "install", help="install system packages, streaming progress events")
+    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())

+ 495 - 0
spm/dnf/usm-spm-dnf

@@ -0,0 +1,495 @@
+#!/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 plan <name>...          -> contract JSON: the material install set
+    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 == "gio":
+        return [("file", "/usr/lib64/gio/modules/" + 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_plan(args):
+    """Resolve the material install set without touching the system.
+
+    Marks every name for install, resolves the goal, and prints one
+    contract JSON object listing every package the manager would
+    install — the chosen names plus every dependency of a dependency —
+    so the caller can plan and show the whole native transaction.
+    """
+    try:
+        base = make_base(load_system_repo=True)
+        for name in args.names:
+            try:
+                base.install(name)
+            except Exception as e:
+                fail_plain("could not mark \"%s\" for install: %s" % (name, e),
+                           EXIT_RESOLVE)
+        try:
+            base.resolve()
+        except Exception as e:
+            fail_plain("dependency resolution failed: %s" % e, EXIT_RESOLVE)
+        names = [member.name for member in base.transaction.install_set]
+        sys.stdout.write(json.dumps({"packages": names}) + "\n")
+        sys.stdout.flush()
+        return EXIT_OK
+    except SystemExit:
+        raise
+    except Exception as e:
+        fail_plain("plan failed: %s" % e, EXIT_RESOLVE)
+
+
+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, 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,
+            "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
+        # ts_done counts finished packages, so the in-flight package is
+        # the zero-based position ts_done (clamped into the transaction)
+        current = min(ts_done, total - 1) if ts_total else 0
+        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)
+
+    plan_parser = subparsers.add_parser(
+        "plan", help="resolve the material install set for names "
+                     "(chosen packages plus all dependencies)")
+    plan_parser.add_argument(
+        "names", nargs="+", metavar="NAME",
+        help="native system package name")
+    plan_parser.set_defaults(handler=cmd_plan)
+
+    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())

+ 701 - 0
spm/emerge/usm-spm-emerge

@@ -0,0 +1,701 @@
+#!/usr/bin/env python3
+"""USM system-package-manager helper for Portage (Gentoo) systems.
+
+Implements the USM SPM contract (see usm/README.md, "System package manager
+integration") for Gentoo systems:
+
+    usm-spm-emerge query <usm-ref>...         -> contract JSON on STDOUT
+    usm-spm-emerge plan <atom>...             -> contract JSON on STDOUT
+    usm-spm-emerge install [--test] <atom>... -> contract JSONL events on STDOUT
+
+API choice: a Gentoo stage3 ships Portage and python3, so this helper reads
+the installed-package database through the portage Python API (with a raw
+/var/db/pkg CONTENTS scan as fallback) and shells out to `emerge` for
+dependency resolution (pretend) and installation. There is no binary
+metadata to bind against beyond that: ebuild repositories carry no file
+lists, so unlike the dnf/apt/apk helpers this one cannot search "which
+package contains file X" for packages that are not installed yet.
+
+File-backed refs are therefore resolved in two documented stages:
+
+  1. INSTALLED files (exact): the ref is translated to candidate filesystem
+     paths (gentoo layout: /usr/bin, /usr/sbin, /usr/lib64, /usr/share, ...)
+     and matched against the portage vdb (/var/db/pkg/*/CONTENTS). A ref
+     resolves to the installed package that owns the file.
+  2. NOT-installed files (exception table only): Gentoo has no offline
+     file-provides (PFL, the portage file list, is an online service), so a
+     small curated table maps the USM stack's build/platform refs to Gentoo
+     package atoms (valac -> dev-lang/vala, glib-2.0.pc -> dev-libs/glib,
+     ...). Refs outside the table land in not-found; anything else would be
+     guesswork.
+
+dependency-count / installed-dependency-count are honest numbers parsed
+from `emerge --pretend --quiet=y --ask=n <atom>`: dependency-count is the
+number of packages in the merge list including the package itself,
+installed-dependency-count is the subset of the merge list already
+installed (action letters without N, e.g. [ebuild R]/[ebuild U] reinstalls
+and upgrades; a fresh stage3 typically shows an all-N list). If the pretend
+run fails (atom missing from the tree, masked, unresolvable) an
+exception-table candidate falls back to not-found.
+
+Install maps `emerge --ask=n --quiet=y <atoms>` onto the JSONL contract,
+linking the unversioned tool names Gentoo ships versioned (UNVERSIONED_TOOL_LINKS):
+`begin`/`total` come from a pretend run first, a `package` event is emitted
+per ">>> Emerging (n of N)" status line, `package-complete` per
+">>> Installing (n of N)" line (the moment a merge lands in the vdb),
+`complete` carries the number of completed merges, and any failure emits a
+terminal `error` event before a non-zero exit (resolution failures map to
+exit 3, download failures to 4, build/merge failures to 5).
+
+Documented Gentoo limitations:
+  * No offline file-provides for not-yet-installed packages (see stage 2
+    above); installed-file queries are exact.
+  * USE flags are never touched: emerge runs with the system's configured
+    defaults. The stack's platform libraries (glib, json-glib, libgee, ...)
+    enable their introspection USE flag by default, but a system that
+    disabled it (package.use/make.conf) will lack the matching .gir/
+    .typelib/.vapi artifacts after install; re-enable the flag and re-emerge
+    if a build needs them.
+  * Packages are compiled from source; even small installs take minutes.
+    Progress granularity is per package; emerge exposes no useful
+    intra-package progress, so `package` events carry progress 0.0 and the
+    completion signal is the ">>> Installing" line.
+  * The query subcommand never modifies system state (at most Portage
+    regenerates its dependency cache); install must run as root and never
+    prompts (--ask=n answers Portage's own prompt for it).
+"""
+
+import argparse
+import glob
+import json
+import os
+import re
+import subprocess
+import sys
+import threading
+
+EMERGE = "/usr/bin/emerge"
+
+EXIT_OK = 0
+EXIT_FAILURE = 1
+EXIT_RESOLVE = 3
+EXIT_DOWNLOAD = 4
+EXIT_TRANSACTION = 5
+
+FILE_ROOTS = {
+    "sbin": ["/usr/sbin", "/sbin"],
+    "libexec": ["/usr/libexec"],
+    "gir": ["/usr/share/gir-1.0"],
+    "typelib": [
+        "/usr/lib64/girepository-1.0",
+        "/usr/lib/girepository-1.0",
+        "/lib64/girepository-1.0",
+        "/lib/girepository-1.0",
+    ],
+    "gio": [
+        "/usr/lib64/gio/modules",
+        "/usr/lib/gio/modules",
+    ],
+    "res": ["/usr/share"],
+    "cfg": ["/etc"],
+    "man": ["/usr/share/man"],
+    "info": ["/usr/share/info"],
+    "locale": ["/usr/share/locale"],
+    "app": ["/usr/share/applications"],
+    "opt": ["/opt"],
+    "rootpath": [""],
+    "tag": ["/usr/share/usm-tags"],
+}
+
+LIB_ROOTS = ["/usr/lib64", "/lib64", "/usr/lib", "/lib"]
+PC_ROOTS = ["/usr/lib64/pkgconfig", "/usr/share/pkgconfig", "/usr/lib/pkgconfig"]
+
+TRANSLATED_PREFIXES = set(FILE_ROOTS) | {
+    "bin", "lib", "libres", "pc", "vapi", "inc"}
+
+USR_MERGE_ALIASES = [
+    ("/lib64/", "/usr/lib64/"),
+    ("/lib/", "/usr/lib/"),
+    ("/bin/", "/usr/bin/"),
+    ("/sbin/", "/usr/sbin/"),
+]
+
+EXCEPTIONS = {
+    "bin:valac": "dev-lang/vala",
+    "bin:vapigen": "dev-lang/vala",
+    "bin:meson": "dev-build/meson",
+    "bin:ninja": "dev-build/ninja",
+    "bin:pkg-config": "dev-util/pkgconf",
+    "bin:pkgconf": "dev-util/pkgconf",
+    "bin:g-ir-scanner": "dev-libs/gobject-introspection",
+    "bin:g-ir-compiler": "dev-libs/gobject-introspection",
+    "bin:gcc": "sys-devel/gcc",
+    "bin:g++": "sys-devel/gcc",
+    "bin:cc": "sys-devel/gcc",
+    "bin:c++": "sys-devel/gcc",
+    "bin:ld": "sys-devel/binutils",
+    "bin:make": "dev-build/make",
+    "bin:python3": "dev-lang/python",
+    "bin:bash": "app-shells/bash",
+    "bin:git": "dev-vcs/git",
+    "bin:curl": "net-misc/curl",
+    "bin:tar": "app-arch/tar",
+    "bin:xz": "app-arch/xz-utils",
+    "bin:gzip": "app-arch/gzip",
+    "bin:sed": "sys-apps/sed",
+    "bin:awk": "sys-apps/gawk",
+    "bin:grep": "sys-apps/grep",
+    "bin:ldconfig": "sys-libs/glibc",
+    "pc:glib-2.0.pc": "dev-libs/glib",
+    "pc:gobject-2.0.pc": "dev-libs/glib",
+    "pc:gio-2.0.pc": "dev-libs/glib",
+    "pc:gio-unix-2.0.pc": "dev-libs/glib",
+    "pc:gmodule-2.0.pc": "dev-libs/glib",
+    "pc:gmodule-no-export-2.0.pc": "dev-libs/glib",
+    "pc:gthread-2.0.pc": "dev-libs/glib",
+    "pc:json-glib-1.0.pc": "dev-libs/json-glib",
+    "pc:gee-0.8.pc": "dev-libs/libgee",
+    "pc:libsodium.pc": "dev-libs/libsodium",
+    "pc:libarchive.pc": "app-arch/libarchive",
+    "pc:sqlite3.pc": "dev-db/sqlite",
+    "pc:libxml-2.0.pc": "dev-libs/libxml2",
+    "pc:libsoup-3.0.pc": "net-libs/libsoup",
+    "pc:libmicrohttpd.pc": "net-libs/libmicrohttpd",
+    "pc:gobject-introspection-1.0.pc": "dev-libs/gobject-introspection",
+    "pc:zlib.pc": "sys-libs/zlib",
+    "pc:libzstd.pc": "app-arch/zstd",
+    "pc:libbrotlienc.pc": "app-arch/brotli",
+    "pc:libbrotlidec.pc": "app-arch/brotli",
+    "pc:libbrotlicommon.pc": "app-arch/brotli",
+    "pc:libffi.pc": "dev-libs/libffi",
+    "pc:pcre2.pc": "dev-libs/libpcre2",
+    "vapi:glib-2.0.vapi": "dev-lang/vala",
+    "vapi:gobject-2.0.vapi": "dev-lang/vala",
+    "vapi:gio-2.0.vapi": "dev-lang/vala",
+    "vapi:gio-unix-2.0.vapi": "dev-lang/vala",
+    "vapi:posix.vapi": "dev-lang/vala",
+    "vapi:json-glib-1.0.vapi": "dev-lang/vala",
+    "vapi:libxml-2.0.vapi": "dev-lang/vala",
+    "vapi:sqlite3.vapi": "dev-lang/vala",
+    "vapi:gee-0.8.vapi": "dev-libs/libgee",
+    "gir:GLib-2.0.gir": "dev-libs/gobject-introspection",
+    "gir:GObject-2.0.gir": "dev-libs/gobject-introspection",
+    "gir:Gio-2.0.gir": "dev-libs/gobject-introspection",
+    "gir:GioUnix-2.0.gir": "dev-libs/gobject-introspection",
+    "typelib:GLib-2.0.typelib": "dev-libs/gobject-introspection",
+    "typelib:GObject-2.0.typelib": "dev-libs/gobject-introspection",
+    "typelib:Gio-2.0.typelib": "dev-libs/gobject-introspection",
+    "typelib:GioUnix-2.0.typelib": "dev-libs/gobject-introspection",
+    "lib:libglib-2.0.so.0": "dev-libs/glib",
+    "lib:libgobject-2.0.so.0": "dev-libs/glib",
+    "lib:libgio-2.0.so.0": "dev-libs/glib",
+    "lib:libgmodule-2.0.so.0": "dev-libs/glib",
+    "lib:libgthread-2.0.so.0": "dev-libs/glib",
+    "lib:libjson-glib-1.0.so.0": "dev-libs/json-glib",
+    "lib:libgee-0.8.so.2": "dev-libs/libgee",
+    "lib:libsodium.so.26": "dev-libs/libsodium",
+    "lib:libarchive.so.13": "app-arch/libarchive",
+    "lib:libsqlite3.so.0": "dev-db/sqlite",
+    "lib:libxml2.so.2": "dev-libs/libxml2",
+    "lib:libsoup-3.0.so.0": "net-libs/libsoup",
+    "lib:libmicrohttpd.so.12": "net-libs/libmicrohttpd",
+    "lib:libz.so.1": "sys-libs/zlib",
+    "lib:libzstd.so.1": "app-arch/zstd",
+    "lib:libbrotlienc.so.1": "app-arch/brotli",
+    "lib:libbrotlidec.so.1": "app-arch/brotli",
+    "lib:libbrotlicommon.so.1": "app-arch/brotli",
+    "lib:libffi.so.8": "dev-libs/libffi",
+    "lib:libpcre2-8.so.0": "dev-libs/libpcre2",
+    "gio:libgiognutls.so": "net-libs/glib-networking",
+    "lib:libc.so.6": "sys-libs/glibc",
+    "lib:libm.so.6": "sys-libs/glibc",
+    "lib:libblkid.so.1": "sys-apps/util-linux",
+    "lib:libmount.so.1": "sys-apps/util-linux",
+    "lib:libuuid.so.1": "sys-apps/util-linux",
+    "inc:glib-2.0": "dev-libs/glib",
+    "inc:json-glib-1.0": "dev-libs/json-glib",
+    "inc:gee-0.8": "dev-libs/libgee",
+    "inc:libsodium.h": "dev-libs/libsodium",
+    "inc:archive.h": "app-arch/libarchive",
+    "inc:sqlite3.h": "dev-db/sqlite",
+}
+
+
+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-emerge: %s\n" % message)
+    sys.exit(exit_code)
+
+
+def category_package(cpv):
+    """Strip version and ::repository suffix from a cpv -> category/package."""
+    atom = cpv.split("::", 1)[0]
+    try:
+        import portage
+
+        key = portage.cpv_getkey(atom)
+        if key:
+            return key
+    except Exception:
+        pass
+    category, sep, package_version = atom.partition("/")
+    if not sep:
+        return atom
+    return re.sub(r"-\d.*$", "", package_version) or package_version
+
+
+def parse_contents_line(line):
+    """Path recorded on one CONTENTS line, or None for junk lines."""
+    fields = line.split()
+    if len(fields) >= 2 and fields[0] in ("obj", "dir", "sym", "dev", "fif"):
+        return fields[1]
+    return None
+
+
+def build_vdb_index():
+    """Map every installed file path -> cpv via the portage vdb.
+
+    Prefers the portage API (aux_get CONTENTS, present on any portage
+    system); falls back to a raw /var/db/pkg/<cat>/<pkg>/CONTENTS scan.
+    """
+    index = {}
+    try:
+        import portage
+
+        root = portage.settings.get("ROOT", "/")
+        vdb = portage.db[root]["vartree"].dbapi
+        for cpv in vdb.cpv_all():
+            raw = vdb.aux_get(cpv, ["CONTENTS"])[0] or ""
+            for line in raw.splitlines():
+                path = parse_contents_line(line)
+                if path is not None:
+                    index.setdefault(path, cpv)
+        if index:
+            return index
+    except Exception as e:
+        sys.stderr.write("usm-spm-emerge: portage API vdb read failed (%s), "
+                         "falling back to /var/db/pkg scan\n" % e)
+    for contents_path in glob.glob("/var/db/pkg/*/*/CONTENTS"):
+        category = os.path.basename(os.path.dirname(os.path.dirname(contents_path)))
+        package_version = os.path.basename(os.path.dirname(contents_path))
+        cpv = category + "/" + package_version
+        try:
+            with open(contents_path, "r", errors="replace") as handle:
+                for line in handle:
+                    path = parse_contents_line(line)
+                    if path is not None:
+                        index.setdefault(path, cpv)
+        except OSError:
+            continue
+    return index
+
+
+def path_aliases(path):
+    """path plus its /usr-merge variant(s) (/lib64/x -> /usr/lib64/x...)."""
+    aliases = [path]
+    for merged, unmerged in USR_MERGE_ALIASES:
+        if path.startswith(merged):
+            variant = unmerged + path[len(merged):]
+        elif path.startswith(unmerged):
+            variant = merged + path[len(unmerged):]
+        else:
+            continue
+        if variant not in aliases:
+            aliases.append(variant)
+    return aliases
+
+
+def bin_candidates(resource):
+    """PATH directories in order (plus /usr/bin) as candidate directories."""
+    directories = [d for d in os.environ.get("PATH", "").split(os.pathsep) if d]
+    if "/usr/bin" not in directories:
+        directories.append("/usr/bin")
+    return [os.path.join(d, resource) for d in directories]
+
+
+def translate_paths(prefix, resource):
+    """Candidate filesystem paths for a file-backed ref prefix."""
+    if prefix == "bin":
+        return bin_candidates(resource)
+    if prefix == "tag":
+        return ["/usr/share/usm-tags/" + resource.replace(".", "/") + ".tag"]
+    if prefix in FILE_ROOTS:
+        return ["/".join(FILE_ROOTS[prefix] + [resource])]
+    if prefix in ("lib", "libres"):
+        return [os.path.join(d, resource) for d in LIB_ROOTS]
+    if prefix == "pc":
+        return [os.path.join(d, resource) for d in PC_ROOTS]
+    return None
+
+
+def resolve_installed(ref, index):
+    """cpv of the installed package owning the ref's file, or None.
+
+    Candidate paths (PATH order for bin:, gentoo root table otherwise) are
+    matched exactly against the vdb index, with /usr-merge aliases tried on
+    both sides; files present on disk but owned by no package do not count
+    as provided by the system package manager.
+    """
+    prefix, sep, resource = ref.partition(":")
+    if not sep or not resource:
+        return None
+    resource = resource.strip("/")
+    if prefix == "vapi":
+        pattern = re.compile(
+            r"^/usr/share/vala[^/]*/vapi/" + re.escape(resource) + "$")
+        for path, cpv in index.items():
+            if pattern.match(path):
+                return cpv
+        return None
+    if prefix == "inc":
+        directory = "/usr/include/" + resource.strip("/")
+        for path, cpv in index.items():
+            if path == directory or path.startswith(directory + "/"):
+                return cpv
+        return None
+    candidates = translate_paths(prefix, resource)
+    if candidates is None:
+        return None
+    for path in candidates:
+        for alias in path_aliases(path):
+            if alias in index:
+                return index[alias]
+    return None
+
+
+def emerge_pretend(atoms):
+    """Run emerge --pretend; returns (returncode, stdout, stderr)."""
+    command = [EMERGE, "--pretend", "--quiet=y", "--ask=n"] + list(atoms)
+    try:
+        proc = subprocess.run(
+            command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+            text=True, errors="replace")
+    except OSError as e:
+        return EXIT_FAILURE, "", str(e)
+    return proc.returncode, proc.stdout, proc.stderr
+
+
+MERGE_LINE = re.compile(r"^\[(ebuild|binary)\s+([A-Za-z*]+)\s*\]\s+(\S+)")
+
+
+def parse_merge_list(output):
+    """(total, already_installed, atoms) from pretend output merge lines."""
+    total = 0
+    installed = 0
+    atoms = []
+    for line in output.splitlines():
+        match = MERGE_LINE.match(line)
+        if not match:
+            continue
+        total += 1
+        if "N" not in match.group(2):
+            installed += 1
+        atoms.append(match.group(3))
+    return total, installed, atoms
+
+
+def pretend_counts(atom, cache):
+    """(dependency-count, installed-dependency-count) for a solo emerge.
+
+    None means the atom cannot be resolved against the tree at all (no
+    ebuilds, masked, conflicting): exception-table candidates then fall
+    back to not-found; installed candidates degrade to (1, 1).
+    """
+    if atom in cache:
+        return cache[atom]
+    returncode, stdout, stderr = emerge_pretend([atom])
+    if returncode != 0:
+        cache[atom] = None
+        return None
+    total, installed, _ = parse_merge_list(stdout)
+    counts = (total, installed) if total else (1, 1)
+    cache[atom] = 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):
+    index = build_vdb_index()
+    refs = list(dict.fromkeys(args.refs))
+
+    candidates = {}
+    resolved_ref = {}
+    for ref in refs:
+        cpv = resolve_installed(ref, index)
+        if cpv is not None:
+            name = category_package(cpv)
+        else:
+            name = EXCEPTIONS.get(ref)
+            if name is None:
+                if ref.partition(":")[0] not in TRANSLATED_PREFIXES:
+                    sys.stderr.write(
+                        "usm-spm-emerge: resource type of \"%s\" has no "
+                        "system-package-manager translation\n" % ref)
+                continue
+        resolved_ref[ref] = name
+        entry = candidates.setdefault(name, {"resources": [], "installed": False})
+        entry["resources"].append(ref)
+        if cpv is not None:
+            entry["installed"] = True
+
+    counts_cache = {}
+    package_counts = {}
+    for name, entry in candidates.items():
+        counts = pretend_counts(name, counts_cache)
+        if counts is None and entry["installed"]:
+            sys.stderr.write(
+                "usm-spm-emerge: %s is installed but emerge cannot resolve "
+                "its atom; estimating dependency counts as installed-only\n"
+                % name)
+            counts = (1, 1)
+        package_counts[name] = counts
+
+    not_found = []
+    for ref in refs:
+        name = resolved_ref.get(ref)
+        if name is None or package_counts[name] is None:
+            not_found.append(ref)
+
+    packages = []
+    for name in sorted(candidates):
+        if package_counts[name] is None:
+            continue
+        dependency_count, installed_dependency_count = package_counts[name]
+        packages.append({
+            "name": name,
+            "resources": candidates[name]["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
+
+
+EMERGING_LINE = re.compile(r">>> Emerging \((\d+) of (\d+)\) (\S+)")
+INSTALLING_LINE = re.compile(r">>> Installing \((\d+) of (\d+)\) (\S+)")
+
+
+def classify_failure(output):
+    """Map an emerge failure transcript onto a contract exit code."""
+    lowered = output.lower()
+    if ("all ebuilds that could satisfy" in lowered
+            or "dependency conflict" in lowered
+            or "conflicting requests" in lowered
+            or "blocked" in lowered
+            or "no ebuilds to satisfy" in lowered):
+        return EXIT_RESOLVE
+    if ("couldn't download" in lowered
+            or "fetch instructions" in lowered
+            or "failed to fetch" in lowered):
+        return EXIT_DOWNLOAD
+    return EXIT_TRANSACTION
+
+
+NOISE_LINE = re.compile(r"^\s*\*\s+(IMPORTANT:|Use eselect news)")
+
+
+def failure_message(lines, limit=15):
+    """Last few meaningful output lines as a terminal error message."""
+    meaningful = [line.strip() for line in lines
+                  if line.strip() and not NOISE_LINE.match(line)]
+    tail = meaningful[-limit:]
+    message = " | ".join(tail)
+    return message[:800] if message else "emerge exited non-zero"
+
+
+# Gentoo installs some tool binaries under versioned names only (vala's
+# ebuild ships valac-0.56 without an unversioned valac), while USM
+# manifests reference the unversioned bin:. After a transaction that
+# installed one of these atoms, link each unversioned name to the newest
+# versioned binary so bin: refs resolve on the filesystem.
+UNVERSIONED_TOOL_LINKS = {
+    "dev-lang/vala": ["valac", "vala-gen-introspect", "vapigen"],
+}
+
+
+def link_unversioned_tools(atoms):
+    """Ensure unversioned tool symlinks exist for versioned ebuild tools."""
+    wanted = set()
+    for atom in atoms:
+        for prefix, tools in UNVERSIONED_TOOL_LINKS.items():
+            if atom == prefix or atom.startswith(prefix + ":"):
+                wanted.update(tools)
+    for tool in sorted(wanted):
+        target = os.path.join("/usr/bin", tool)
+        if os.path.exists(target):
+            continue
+        candidates = sorted(glob.glob("/usr/bin/%s-[0-9]*" % tool))
+        if candidates:
+            try:
+                os.symlink(os.path.basename(candidates[-1]), target)
+            except OSError:
+                pass
+
+
+def cmd_plan(args):
+    """Resolve the material install set without touching the system.
+
+    One emerge --pretend for every atom; the merge list's names (in
+    emerge's own transaction order) print as one contract JSON object,
+    matching the names the query contract reports.
+    """
+    returncode, stdout, stderr = emerge_pretend(args.names)
+    if returncode != 0:
+        fail_plain("dependency resolution failed: %s"
+                   % failure_message((stdout + "\n" + stderr).splitlines()),
+                   EXIT_RESOLVE)
+    _, _, atoms = parse_merge_list(stdout)
+    sys.stdout.write(json.dumps(
+        {"packages": [category_package(atom) for atom in atoms]}) + "\n")
+    sys.stdout.flush()
+    return EXIT_OK
+
+
+def cmd_install(args):
+    returncode, stdout, stderr = emerge_pretend(args.names)
+    if returncode != 0:
+        fail("dependency resolution failed: %s"
+             % failure_message((stdout + "\n" + stderr).splitlines()),
+             EXIT_RESOLVE)
+    total, _, atoms = parse_merge_list(stdout)
+    emit_event({"type": "begin", "total": total})
+    if args.test:
+        emit_event({"type": "complete", "status": "ok", "installed": 0})
+        return EXIT_OK
+
+    command = [EMERGE, "--ask=n", "--quiet=y"] + list(args.names)
+    try:
+        proc = subprocess.Popen(
+            command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+            text=True, errors="replace", bufsize=1)
+    except OSError as e:
+        fail("could not run emerge: %s" % e, EXIT_FAILURE)
+
+    transcript = []
+
+    def drain_stderr():
+        for line in proc.stderr:
+            transcript.append(line)
+            sys.stderr.write(line)
+            sys.stderr.flush()
+
+    reader = threading.Thread(target=drain_stderr)
+    reader.daemon = True
+    reader.start()
+
+    completed = []
+    for line in proc.stdout:
+        transcript.append(line)
+        sys.stderr.write(line)
+        sys.stderr.flush()
+        emerging = EMERGING_LINE.match(line)
+        if emerging:
+            emit_event({
+                "type": "package",
+                "name": category_package(emerging.group(3)),
+                "current": int(emerging.group(1)),
+                "total": int(emerging.group(2)),
+                "progress": 0.0,
+            })
+            continue
+        installing = INSTALLING_LINE.match(line)
+        if installing:
+            name = category_package(installing.group(3))
+            completed.append(name)
+            emit_event({"type": "package-complete", "name": name})
+
+    returncode = proc.wait()
+    reader.join(timeout=5)
+    if returncode != 0:
+        fail("emerge failed (%s): %s" % (returncode, failure_message(transcript)),
+             classify_failure("".join(transcript)))
+
+    link_unversioned_tools(args.names)
+
+    emit_event({
+        "type": "complete",
+        "status": "ok",
+        "installed": len(completed),
+    })
+    return EXIT_OK
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        prog="usm-spm-emerge",
+        description="USM system-package-manager helper for Portage (Gentoo)")
+    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 pc:glib-2.0.pc")
+    query_parser.set_defaults(handler=cmd_query)
+
+    plan_parser = subparsers.add_parser(
+        "plan", help="resolve the material install set for atoms")
+    plan_parser.add_argument(
+        "names", nargs="+", metavar="ATOM",
+        help="native gentoo package atom, e.g. dev-build/meson")
+    plan_parser.set_defaults(handler=cmd_plan)
+
+    install_parser = subparsers.add_parser(
+        "install", help="install system packages, streaming progress events")
+    install_parser.add_argument(
+        "--test", action="store_true",
+        help="resolve only (emerge --pretend); no packages are built")
+    install_parser.add_argument(
+        "names", nargs="+", metavar="ATOM",
+        help="native gentoo package atom, e.g. dev-build/meson")
+    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:
+        message = "unexpected failure: %s" % e
+        if args.command == "install":
+            fail(message, EXIT_FAILURE)
+        fail_plain(message, EXIT_FAILURE)
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 612 - 0
spm/pacman/usm-spm-pacman

@@ -0,0 +1,612 @@
+#!/bin/sh
+# USM system-package-manager helper for pacman-based systems (Arch).
+#
+# Implements the USM SPM contract (see usm/README.md, "System package
+# manager integration") for Arch-style systems:
+#
+#     usm-spm-pacman query <usm-ref>...        -> contract JSON on STDOUT
+#     usm-spm-pacman install <native-name>...  -> contract JSONL events on STDOUT
+#
+# API choice: pure POSIX shell on purpose -- the arch base group ships
+# neither python3 nor jq, and the SPM bootstrap must stay at zero extra
+# packages. bsdtar (libarchive, a hard pacman dependency) decompresses
+# the repository databases under /var/lib/pacman/sync so provides and
+# file lists are parsed directly instead of shelling pattern queries
+# through pacman:
+#
+#     lib: X  ->  the sync dbs' %PROVIDES% entries; makepkg auto-
+#                 generates soname provides ("libfoo.so=2-64"), so the
+#                 "=major-arch" suffix is stripped before matching;
+#                 file ownership of /usr/lib/X is the fallback
+#     everything file-backed ->
+#                 the opt-in files databases (<repo>.files, fetched by
+#                 `pacman -Fy` -- the arch analogue of apt-file update)
+#                 are parsed into a path -> package map; without them,
+#                 only files of INSTALLED packages resolve (via
+#                 `pacman -Qo`), the same offline stance as the emerge
+#                 helper. No package-name heuristics are used.
+#
+# dependency-count / installed-dependency-count model "a solo install on
+# a from-scratch machine": the closure comes from `pacman -S --print`
+# run against a throwaway --dbpath whose local database is empty and
+# whose sync databases symlink the system's, so every dependency
+# resolves as an install; the overlap with the installed set
+# (`pacman -Qq`) is installed-dependency-count. If that run cannot
+# resolve, the counts degrade to a (1, n) estimate with a warning.
+#
+# pacman exposes no per-download progress on a non-tty stdout; it names
+# each package once ("(k/N) installing pkg" in older versions,
+# "installing pkg..." since pacman 7), so "package" events are emitted
+# at package granularity (progress 1.0) and begin.total comes from a
+# `pacman -S --print` pass against the current system. Install runs
+# with --needed, making repeated installs idempotent.
+#
+# No INSTALL_COMPANIONS table is needed: arch does not split toolchain
+# packages, glibc already ships the crt objects.
+#
+# The query subcommand never modifies system state: databases are read
+# from their normal locations, and the only writes land in a throwaway
+# temp directory. The install subcommand must run as root; --noconfirm
+# guarantees pacman never prompts interactively: unresolved targets or
+# bad signatures fail instead.
+#
+# Arch path table (merged-usr layout, no multiarch): /usr/bin (sbin is
+# a symlink), /usr/lib (lib64 is a symlink), /usr/include, /usr/share
+# (+/usr/share/pkgconfig), /usr/share/vala*/vapi.
+
+set -eu
+
+export LC_ALL=C
+
+PROG=usm-spm-pacman
+
+EXIT_OK=0
+EXIT_FAILURE=1
+EXIT_USAGE=2
+EXIT_RESOLVE=3
+EXIT_DOWNLOAD=4
+EXIT_TRANSACTION=5
+
+WORK=""
+
+SYNC_DIR=/var/lib/pacman/sync
+
+REPO_INDEXED=0
+
+cleanup() {
+    if [ -n "$WORK" ]; then
+        rm -rf "$WORK"
+    fi
+}
+
+warn() {
+    printf '%s: %s\n' "$PROG" "$*" >&2
+}
+
+die_usage() {
+    printf 'usage: %s query <usm-ref>...\n' "$PROG" >&2
+    printf '       %s plan <native-name>...\n' "$PROG" >&2
+    printf '       %s install <native-name>...\n' "$PROG" >&2
+    exit "$EXIT_USAGE"
+}
+
+emit() {
+    printf '%s\n' "$1"
+}
+
+json_escape() {
+    printf '%s' "$1" | awk '
+        {
+            s = $0
+            gsub(/\\/, "\\\\", s)
+            gsub(/"/, "\\\"", s)
+            gsub(/\t/, "\\t", s)
+            gsub(/[[:cntrl:]]/, "", s)
+            print s
+        }'
+}
+
+mkwork() {
+    WORK=$(mktemp -d "${TMPDIR:-/tmp}/usm-spm-pacman.XXXXXX" 2>/dev/null) || {
+        WORK="${TMPDIR:-/tmp}/usm-spm-pacman.$$"
+        (umask 077 && mkdir "$WORK")
+    }
+    trap cleanup EXIT
+}
+
+not_found_add() {
+    printf '%s\n' "$1" >>"$WORK/nf"
+}
+
+map_add() {
+    printf '%s\t%s\n' "$1" "$2" >>"$WORK/map"
+}
+
+# Parse the sync/files databases ONCE per invocation into flat lookup
+# tables: $WORK/provides maps "soname-or-virtual<TAB>package" (the
+# "=major-arch" suffix is stripped from soname provides so lib: refs
+# carrying the full soname match exactly), $WORK/filemap maps
+# "path-without-leading-slash<TAB>package" from the %FILES% sections of
+# the .files databases. Both stay empty (and the lookups degrade to the
+# installed database) when the databases are missing.
+build_repo_index() {
+    [ "$REPO_INDEXED" = 0 ] || return 0
+    REPO_INDEXED=1
+    : >"$WORK/provides"
+    : >"$WORK/filemap"
+    if ! command -v bsdtar >/dev/null 2>&1; then
+        warn "bsdtar not found in PATH; repository lookups are unavailable"
+        return 0
+    fi
+    _ri_any=0
+    for _ri_db in "$SYNC_DIR"/*.db; do
+        [ -f "$_ri_db" ] || continue
+        _ri_any=1
+        bsdtar -xOf "$_ri_db" 2>>"$WORK/index.err" | awk '
+            /^%NAME%$/     { mode = "name"; next }
+            /^%PROVIDES%$/ { mode = "prov";  next }
+            /^%/           { mode = "";      next }
+            NF == 0        { next }
+            mode == "name" { pkg = $1; mode = ""; next }
+            mode == "prov" && pkg != "" {
+                p = $1
+                sub(/=.*$/, "", p)
+                print pkg "\t" p
+            }
+        ' >>"$WORK/provides" || true
+    done
+    for _ri_db in "$SYNC_DIR"/*.files; do
+        [ -f "$_ri_db" ] || continue
+        _ri_any=1
+        bsdtar -xOf "$_ri_db" 2>>"$WORK/index.err" | awk '
+            /^%NAME%$/      { mode = "name";  next }
+            /^%FILES%$/     { mode = "files"; next }
+            /^%/            { mode = "";      next }
+            NF == 0         { next }
+            mode == "name"  { pkg = $1; mode = ""; next }
+            mode == "files" && pkg != "" { print pkg "\t" $1 }
+        ' >>"$WORK/filemap" || true
+    done
+    if [ "$_ri_any" != 1 ]; then
+        warn "no databases under $SYNC_DIR (run pacman -Sy as root); repository lookups are unavailable"
+    elif [ ! -s "$WORK/filemap" ]; then
+        warn "no files databases under $SYNC_DIR (run pacman -Fy as root); only installed files resolve"
+    fi
+}
+
+# makepkg turns soname "libc.so.6" into the provide "libc.so=6-64", so a
+# lib: ref's trailing ".<major>" is optionally stripped before matching:
+# "lib:libc.so.6" matches "libc.so" exactly as an unversioned
+# "lib:libc.so" would.
+repo_provider_owners() {
+    awk -F'\t' -v p="$1" '
+        {
+            r = p
+            sub(/\.[0-9]+$/, "", r)
+            if ($2 == p || ($2 == r && r != p)) { print $1 }
+        }' "$WORK/provides" 2>/dev/null
+}
+
+repo_file_owners() {
+    awk -F'\t' -v p="$1" '$2 == p { print $1 }' "$WORK/filemap" 2>/dev/null
+}
+
+repo_file_prefix_owners() {
+    # exact file OR any file below the directory prefix
+    awk -F'\t' -v p="$1" '$2 == p || index($2, p "/") == 1 { print $1 }' \
+        "$WORK/filemap" 2>/dev/null
+}
+
+# Map every owner name read from STDIN onto _om_ref; succeeds when at
+# least one name arrived.
+owners_to_map() {
+    _om_ref=$1
+    _om_any=0
+    while IFS= read -r _om_nm; do
+        [ -n "$_om_nm" ] || continue
+        map_add "$_om_nm" "$_om_ref"
+        _om_any=1
+    done
+    [ "$_om_any" = 1 ]
+}
+
+pacman_diag_line() {
+    [ -r "$1" ] || return 0
+    sed -n 's/^[[:space:]]*error:[[:space:]]*//p' "$1" | head -n 1
+}
+
+record_providers() {
+    _rp_ref=$1
+    _rp_prov=$2
+    build_repo_index
+    if repo_provider_owners "$_rp_prov" | sort -u | owners_to_map "$_rp_ref"; then
+        return 0
+    fi
+    not_found_add "$_rp_ref"
+}
+
+record_file_owner() {
+    _fo_ref=$1
+    shift
+    build_repo_index
+    _fo_hit=0
+    for _fo_p in "$@"; do
+        _fo_names=
+        if [ -s "$WORK/filemap" ]; then
+            _fo_names=$(repo_file_owners "${_fo_p#/}" | sort -u)
+        fi
+        if [ -z "$_fo_names" ]; then
+            # installed-database fallback, exact for present files only
+            _fo_f=
+            if [ -d "$_fo_p" ]; then
+                _fo_f=$(find "$_fo_p" -type f 2>/dev/null | head -n 1) || _fo_f=
+            elif [ -f "$_fo_p" ]; then
+                _fo_f=$_fo_p
+            fi
+            if [ -n "$_fo_f" ]; then
+                _fo_names=$(pacman -Qo -- "$_fo_f" 2>/dev/null \
+                    | awk '{ print $(NF-1) }') || _fo_names=
+            fi
+        fi
+        if [ -n "$_fo_names" ]; then
+            printf '%s\n' "$_fo_names" | owners_to_map "$_fo_ref" && _fo_hit=1
+        fi
+    done
+    if [ "$_fo_hit" != 1 ]; then
+        not_found_add "$_fo_ref"
+    fi
+}
+
+record_dir_owner() {
+    # Like record_file_owner, but the ref names a directory (inc:), so
+    # any file at or below the prefix satisfies it.
+    _do_ref=$1
+    _do_p=$2
+    build_repo_index
+    if [ -s "$WORK/filemap" ]; then
+        if repo_file_prefix_owners "${_do_p#/}" | sort -u | owners_to_map "$_do_ref"; then
+            return 0
+        fi
+    fi
+    record_file_owner "$_do_ref" "$_do_p"
+}
+
+handle_ref() {
+    _hr_ref=$1
+    _hr_type=${_hr_ref%%:*}
+    _hr_res=${_hr_ref#*:}
+    if [ "$_hr_type" = "$_hr_ref" ] || [ -z "$_hr_res" ]; then
+        warn "resource type of \"$_hr_ref\" has no system-package-manager translation"
+        not_found_add "$_hr_ref"
+        return
+    fi
+    case $_hr_res in
+        *[!A-Za-z0-9._+/-]*)
+            warn "resource name of \"$_hr_ref\" has no system-package-manager translation"
+            not_found_add "$_hr_ref"
+            return
+            ;;
+    esac
+    case $_hr_type in
+        bin)
+            record_file_owner "$_hr_ref" "/usr/bin/$_hr_res"
+            ;;
+        sbin)
+            record_file_owner "$_hr_ref" "/usr/bin/$_hr_res" "/usr/sbin/$_hr_res"
+            ;;
+        lib|libres)
+            # soname provides first (exact, suffix-stripped), file second
+            build_repo_index
+            if ! repo_provider_owners "$_hr_res" | sort -u | owners_to_map "$_hr_ref"; then
+                record_file_owner "$_hr_ref" "/usr/lib/$_hr_res"
+            fi
+            ;;
+        pc)
+            record_file_owner "$_hr_ref" \
+                "/usr/lib/pkgconfig/$_hr_res" "/usr/share/pkgconfig/$_hr_res"
+            ;;
+        libexec)
+            record_file_owner "$_hr_ref" "/usr/libexec/$_hr_res"
+            ;;
+        gir)
+            record_file_owner "$_hr_ref" "/usr/share/gir-1.0/$_hr_res"
+            ;;
+        typelib)
+            record_file_owner "$_hr_ref" "/usr/lib/girepository-1.0/$_hr_res"
+            ;;
+        gio)
+            record_file_owner "$_hr_ref" "/usr/lib/gio/modules/$_hr_res"
+            ;;
+        res)
+            record_file_owner "$_hr_ref" "/usr/share/$_hr_res"
+            ;;
+        cfg)
+            record_file_owner "$_hr_ref" "/etc/$_hr_res"
+            ;;
+        man)
+            record_file_owner "$_hr_ref" "/usr/share/man/$_hr_res"
+            ;;
+        info)
+            record_file_owner "$_hr_ref" "/usr/share/info/$_hr_res"
+            ;;
+        locale)
+            record_file_owner "$_hr_ref" "/usr/share/locale/$_hr_res"
+            ;;
+        inc)
+            record_dir_owner "$_hr_ref" "/usr/include/$_hr_res"
+            ;;
+        vapi)
+            build_repo_index
+            if [ -s "$WORK/filemap" ]; then
+                : >"$WORK/vown"
+                repo_file_owners "usr/share/vala/vapi/$_hr_res" >>"$WORK/vown"
+                awk -F'\t' -v suf="/vapi/$_hr_res" \
+                    'substr($2, 1, 15) == "usr/share/vala" &&
+                     substr($2, length($2) - length(suf) + 1) == suf { print $1 }' \
+                    "$WORK/filemap" >>"$WORK/vown"
+                if sort -u "$WORK/vown" | owners_to_map "$_hr_ref"; then
+                    return 0
+                fi
+            fi
+            set -- "/usr/share/vala/vapi/$_hr_res"
+            for _hr_d in /usr/share/vala-*/vapi/"$_hr_res"; do
+                if [ -f "$_hr_d" ]; then
+                    set -- "$@" "$_hr_d"
+                fi
+            done
+            record_file_owner "$_hr_ref" "$@"
+            ;;
+        rootpath)
+            record_file_owner "$_hr_ref" "/$_hr_res"
+            ;;
+        tag)
+            _hr_t=${_hr_res%.tag}
+            record_file_owner "$_hr_ref" \
+                "/usr/share/usm-tags/$(printf '%s' "$_hr_t" | tr '.' '/').tag"
+            ;;
+        *)
+            warn "resource type of \"$_hr_ref\" has no system-package-manager translation"
+            not_found_add "$_hr_ref"
+            return
+            ;;
+    esac
+}
+
+counts_for() {
+    _cf_name=$1
+    _cf_cached=
+    if [ -s "$WORK/counts" ]; then
+        _cf_cached=$(awk -F'\t' -v n="$_cf_name" '$1 == n { print $2 " " $3; exit }' "$WORK/counts") || _cf_cached=
+    fi
+    if [ -n "$_cf_cached" ]; then
+        printf '%s\n' "$_cf_cached"
+        return
+    fi
+    # Repo-only closure: a throwaway database whose empty local db makes
+    # every package resolve as an install and whose sync symlink reuses
+    # the system's repositories.
+    _cf_root="$WORK/rootdb"
+    if [ ! -d "$_cf_root" ]; then
+        mkdir -p "$_cf_root/local"
+        ln -s "$SYNC_DIR" "$_cf_root/sync" 2>/dev/null || true
+    fi
+    _cf_dep=0
+    _cf_inst=0
+    _cf_cl=$(pacman --dbpath "$_cf_root" -S --print --print-format '%n' \
+        --noconfirm -- "$_cf_name" 2>"$WORK/cnt.err" | grep -E '^[A-Za-z0-9@._+-]+$' || true)
+    if [ -n "$_cf_cl" ]; then
+        printf '%s\n' "$_cf_cl" >"$WORK/cl"
+        _cf_dep=$(awk 'END { print NR }' "$WORK/cl")
+        if [ -s "$WORK/installed" ]; then
+            _cf_inst=$(grep -x -F -f "$WORK/installed" "$WORK/cl" | awk 'END { print NR }') || _cf_inst=0
+        fi
+    else
+        warn "could not resolve solo install of $_cf_name, estimating dependency counts"
+        if grep -qx -F "$_cf_name" "$WORK/installed" 2>/dev/null; then
+            _cf_dep=1
+            _cf_inst=1
+        else
+            _cf_dep=1
+            _cf_inst=0
+        fi
+    fi
+    printf '%s\t%s\t%s\n' "$_cf_name" "$_cf_dep" "$_cf_inst" >>"$WORK/counts"
+    printf '%s %s\n' "$_cf_dep" "$_cf_inst"
+}
+
+cmd_query() {
+    [ $# -ge 1 ] || die_usage
+    mkwork
+    pacman -Qq >"$WORK/installed" 2>/dev/null || : >"$WORK/installed"
+    : >"$WORK/map"
+    : >"$WORK/nf"
+    : >"$WORK/counts"
+    : >"$WORK/seen"
+    for _q_ref in "$@"; do
+        if [ -n "$_q_ref" ] && grep -x -F -q "$_q_ref" "$WORK/seen" 2>/dev/null; then
+            continue
+        fi
+        printf '%s\n' "$_q_ref" >>"$WORK/seen"
+        handle_ref "$_q_ref"
+    done
+    _q_out='{"not-found":['
+    _q_first=1
+    while IFS= read -r _q_r; do
+        [ -n "$_q_r" ] || continue
+        if [ "$_q_first" = 1 ]; then _q_first=0; else _q_out="$_q_out,"; fi
+        _q_out="$_q_out\"$(json_escape "$_q_r")\""
+    done <"$WORK/nf"
+    _q_out="$_q_out],\"packages\":["
+    _q_first=1
+    for _q_nm in $(awk -F'\t' '{ print $1 }' "$WORK/map" | sort -u); do
+        if [ "$_q_first" = 1 ]; then _q_first=0; else _q_out="$_q_out,"; fi
+        awk -F'\t' -v n="$_q_nm" '$1 == n { print $2 }' "$WORK/map" >"$WORK/tmpres"
+        _q_res=""
+        _q_rf=1
+        while IFS= read -r _q_rr; do
+            if [ "$_q_rf" = 1 ]; then _q_rf=0; else _q_res="$_q_res,"; fi
+            _q_res="$_q_res\"$(json_escape "$_q_rr")\""
+        done <"$WORK/tmpres"
+        _q_cnts=$(counts_for "$_q_nm")
+        _q_dep=${_q_cnts%% *}
+        _q_inst=${_q_cnts##* }
+        _q_out="$_q_out{\"name\":\"$(json_escape "$_q_nm")\",\"resources\":[${_q_res}],\"dependency-count\":$_q_dep,\"installed-dependency-count\":$_q_inst}"
+    done
+    _q_out="$_q_out]}"
+    emit "$_q_out"
+    exit "$EXIT_OK"
+}
+
+classify_failure() {
+    if grep -Eqi 'target not found|could not satisfy dependencies|conflicting|unresolvable|failed to prepare transaction|hold onto' "$1" 2>/dev/null; then
+        printf '%s\n' "$EXIT_RESOLVE"
+    elif grep -Eqi 'network|download|retriev|signature|keyring|gpg|invalid or corrupted|checksum|integrity|connection|timed out|mirror' "$1" 2>/dev/null; then
+        printf '%s\n' "$EXIT_DOWNLOAD"
+    else
+        printf '%s\n' "$EXIT_TRANSACTION"
+    fi
+}
+
+cmd_install() {
+    [ $# -ge 1 ] || die_usage
+    for _i_n in "$@"; do
+        case $_i_n in
+            -*) die_usage ;;
+        esac
+    done
+    mkwork
+    _i_simrc=0
+    _i_sim=$(pacman -S --needed --noconfirm --print --print-format '%n' \
+        -- "$@" 2>"$WORK/sim.err") || _i_simrc=$?
+    if [ "$_i_simrc" != 0 ]; then
+        _i_msg=$(pacman_diag_line "$WORK/sim.err")
+        [ -n "$_i_msg" ] || _i_msg="pacman --print failed with status $_i_simrc"
+        emit "{\"type\":\"error\",\"message\":\"$(json_escape "$_i_msg")\"}"
+        exit "$(classify_failure "$WORK/sim.err")"
+    fi
+    _i_total=$(printf '%s\n' "$_i_sim" | grep -Ec '^[A-Za-z0-9@._+-]+$') || _i_total=0
+    emit "{\"type\":\"begin\",\"total\":$_i_total}"
+    {
+        # || keeps set -e from aborting the pipeline group before the
+        # status reaches the rc file
+        _i_status=0
+        pacman -S --needed --noconfirm --noprogressbar -- "$@" \
+            2>"$WORK/inst.err" || _i_status=$?
+        printf '%s\n' "$_i_status" >"$WORK/rc"
+    } | tr '\r' '\n' | {
+        _i_done=0
+        _i_lastpkg=
+        while IFS= read -r _i_line || [ -n "$_i_line" ]; do
+            # pacman's transaction output names each package exactly
+            # once: older versions redraw "(k/N) installing pkg" lines
+            # (\r-flattened here), pacman 7 pipes plain "installing
+            # pkg..." lines. Dedupe on the package name either way.
+            case $_i_line in
+                \(*\)\ *)
+                    _i_kn=${_i_line#"("}
+                    _i_kn=${_i_kn%%")"*}
+                    _i_rest=${_i_line#*") "}
+                    _i_verb=${_i_rest%% *}
+                    _i_pkg=${_i_rest#* }
+                    _i_pkg=${_i_pkg%% *}
+                    _i_k=${_i_kn%%/*}
+                    _i_n=${_i_kn##*/}
+                    ;;
+                installing\ *|upgrading\ *|downgrading\ *|reinstalling\ *)
+                    _i_verb=${_i_line%% *}
+                    _i_pkg=${_i_line#* }
+                    _i_pkg=${_i_pkg%% *}
+                    _i_pkg=${_i_pkg%"..."}
+                    _i_k=$((_i_done + 1))
+                    _i_n=$_i_total
+                    ;;
+                *)
+                    continue
+                    ;;
+            esac
+            case $_i_verb in
+                installing|upgrading|downgrading|reinstalling)
+                    if [ "$_i_pkg" != "$_i_lastpkg" ]; then
+                        _i_lastpkg=$_i_pkg
+                        emit "{\"type\":\"package\",\"name\":\"$(json_escape "$_i_pkg")\",\"current\":$_i_k,\"total\":$_i_n,\"progress\":1.0}"
+                        emit "{\"type\":\"package-complete\",\"name\":\"$(json_escape "$_i_pkg")\"}"
+                        _i_done=$((_i_done + 1))
+                    fi
+                    ;;
+            esac
+        done
+        printf '%s\n' "$_i_done" >"$WORK/done"
+    }
+    _i_rc=$(cat "$WORK/rc" 2>/dev/null) || _i_rc=1
+    if [ "$_i_rc" = 0 ]; then
+        _i_done=$(cat "$WORK/done" 2>/dev/null) || _i_done=0
+        emit "{\"type\":\"complete\",\"status\":\"ok\",\"installed\":$_i_done}"
+        exit "$EXIT_OK"
+    fi
+    _i_msg=$(pacman_diag_line "$WORK/inst.err")
+    [ -n "$_i_msg" ] || _i_msg="pacman -S failed with status $_i_rc"
+    emit "{\"type\":\"error\",\"message\":\"$(json_escape "$_i_msg")\"}"
+    exit "$(classify_failure "$WORK/inst.err")"
+}
+
+cmd_plan() {
+    [ $# -ge 1 ] || die_usage
+    for _p_n in "$@"; do
+        case $_p_n in
+            -*) die_usage ;;
+        esac
+    done
+    mkwork
+    _p_rc=0
+    _p_names=$(pacman -S --needed --noconfirm --print --print-format '%n' \
+        -- "$@" 2>"$WORK/plan.err") || _p_rc=$?
+    if [ "$_p_rc" != 0 ]; then
+        _p_msg=$(pacman_diag_line "$WORK/plan.err")
+        [ -n "$_p_msg" ] || _p_msg="pacman --print failed with status $_p_rc"
+        printf '%s: %s\n' "$PROG" "$_p_msg" >&2
+        exit "$(classify_failure "$WORK/plan.err")"
+    fi
+    _p_json=$(printf '%s\n' "$_p_names" \
+        | sed -n '/^[A-Za-z0-9@._+-]\{1,\}$/s/.*/"&"/p' \
+        | awk 'NR>1{printf ","} {printf "%s", $0}')
+    printf '{"packages":[%s]}\n' "$_p_json"
+    exit "$EXIT_OK"
+}
+
+main() {
+    if [ $# -lt 1 ]; then
+        die_usage
+    fi
+    if ! command -v pacman >/dev/null 2>&1; then
+        if [ "$1" = install ]; then
+            emit '{"type":"error","message":"pacman not found in PATH"}'
+            exit "$EXIT_FAILURE"
+        fi
+        warn "pacman not found in PATH"
+        exit "$EXIT_RESOLVE"
+    fi
+    _m_cmd=$1
+    shift
+    case $_m_cmd in
+        query)
+            cmd_query "$@"
+            ;;
+        plan)
+            cmd_plan "$@"
+            ;;
+        install)
+            cmd_install "$@"
+            ;;
+        -h|--help|help)
+            printf 'usage: %s query <usm-ref>...\n' "$PROG"
+            printf '       %s plan <native-name>...\n' "$PROG"
+            printf '       %s install <native-name>...\n' "$PROG"
+            exit "$EXIT_OK"
+            ;;
+        *)
+            die_usage
+            ;;
+    esac
+}
+
+main "$@"

+ 231 - 0
src/cli/AddRepo.vala

@@ -0,0 +1,231 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+/** Entry point wired into Cli.vala's dispatch; delegates to {@link Usm.Cli.AddRepo.run}. */
+private int add_repo_main(string[] args) {
+    return Usm.Cli.AddRepo.run(args);
+}
+
+namespace Usm.Cli {
+
+    /**
+     * `usm add-repo <url-or-path>` — install a repository definition into
+     * the configured repos.d.
+     *
+     * The descriptor is an HTTPS/HTTP URL (downloaded through curl, the
+     * same `curl -fsSL --max-time 30 -o <tmp> <url>` pattern `usm deploy`
+     * uses) or a path to a local `.usmr` file. The file must parse as a
+     * {@link Usm.Repository} carrying a name, url and signing key; it is
+     * copied to `<config dir>/repos.d/<name>.usmr`. A repository with the
+     * same name already configured is replaced after a prompt (`-y`/`--yes`
+     * replaces without asking). The listing is refreshed after adding so
+     * the repository's packages are installable immediately; a refresh
+     * that fails (an unenrolled system, a unreachable host) only warns —
+     * the definition itself is already in place.
+     */
+    public class AddRepo {
+
+        /**
+         * Runs the command against `args` (`args[0]` is the program name,
+         * `args[1]` the command); exactly one URL or path is expected.
+         * Returns 0 when the repository was added, 1 on validation,
+         * download or copy failures and user aborts, and 255 on usage
+         * errors.
+         */
+        public static int run(string[] args) {
+            var assume_yes = false;
+            string? target = null;
+            for(int i = 2; i < args.length; i++) {
+                var argument = args[i];
+                if(argument == "-y" || argument == "--yes") {
+                    assume_yes = true;
+                }
+                else if(argument.has_prefix("-")) {
+                    printerr(@"Unknown option \"$argument\"\n");
+                    return add_repo_usage();
+                }
+                else if(target != null) {
+                    printerr("Expected exactly one repository URL or path\n");
+                    return add_repo_usage();
+                }
+                else {
+                    target = argument;
+                }
+            }
+            if(target == null) {
+                return add_repo_usage();
+            }
+            if(!target.has_suffix(".usmr")) {
+                printerr("Expected a .usmr repository definition\n");
+                return 1;
+            }
+
+            string? temporary_directory = null;
+            try {
+                string source_path;
+                if(target.has_prefix("https://") || target.has_prefix("http://")) {
+                    temporary_directory = DirUtils.make_tmp("usm-add-repo-XXXXXX");
+                    source_path = Path.build_filename(temporary_directory, "repo.usmr");
+                    printerr(@"Downloading repository definition from \"$target\"...\n");
+                    var download = new Subprocess.newv(
+                        new string[] { "curl", "-fsSL", "--max-time", "30", "-o", source_path, target },
+                        SubprocessFlags.INHERIT_FDS);
+                    download.wait_check();
+                }
+                else {
+                    source_path = target;
+                    if(!FileUtils.test(source_path, FileTest.EXISTS)) {
+                        printerr(@"\"$source_path\" does not exist\n");
+                        return 1;
+                    }
+                }
+
+                Usm.Repository repository;
+                try {
+                    repository = new Usm.Repository.from_file(source_path);
+                }
+                catch(Error e) {
+                    printerr(@"\"$target\" is not a valid repository definition: $(e.message)\n");
+                    return 1;
+                }
+                if(repository.name == null || repository.name.length == 0
+                    || repository.url == null || repository.url.length == 0
+                    || repository.key == null) {
+                    printerr(@"\"$target\" is not a valid repository definition: expected a name, url and key\n");
+                    return 1;
+                }
+                // The name becomes a filename inside repos.d, so a slash
+                // would write outside it
+                if(repository.name.contains("/")) {
+                    printerr(@"\"$(repository.name)\" is not a valid repository name\n");
+                    return 1;
+                }
+
+                var repos_directory = Path.build_filename(paths.usm_config_dir, "repos.d");
+                var destination = Path.build_filename(repos_directory, @"$(repository.name).usmr");
+                try {
+                    var directory = File.new_for_path(repos_directory);
+                    if(!directory.query_exists()) {
+                        directory.make_directory_with_parents();
+                    }
+                }
+                catch(Error e) {
+                    printerr(@"Unable to create \"$repos_directory\": $(e.message)\n");
+                    return 1;
+                }
+
+                var existing = add_repo_existing_path(repos_directory, repository.name);
+                if(existing != null) {
+                    printerr(@"A repository named \"$(repository.name)\" is already configured ($existing)\n");
+                    if(!assume_yes && !add_repo_confirm()) {
+                        print("Aborted.\n");
+                        return 1;
+                    }
+                }
+
+                try {
+                    File.new_for_path(source_path).copy(File.new_for_path(destination), FileCopyFlags.OVERWRITE);
+                }
+                catch(Error e) {
+                    printerr(@"Unable to copy the repository definition to \"$destination\": $(e.message)\n");
+                    return 1;
+                }
+                print(@"Added repository \"$(repository.name)\" to $destination\n");
+
+                try {
+                    var state = new Usm.SystemState(paths);
+                    var added = new Usm.Repository.from_file(destination);
+                    state.refresh_list(added);
+                    var listing = state.get_latest_list(added);
+                    var packages = listing == null ? 0 : listing.entries.count();
+                    print(@"Refreshed: $packages packages available\n");
+                }
+                catch(Error e) {
+                    printerr(@"Warning: could not refresh the repository listing: $(e.message)\n");
+                    printerr("Repositories refresh again on the next usm install or search\n");
+                }
+                print("Install packages with: usm install <package-name>\n");
+            }
+            catch(Error e) {
+                printerr(@"Error: $(e.message)\n");
+                return 1;
+            }
+            finally {
+                if(temporary_directory != null) {
+                    try {
+                        Usm.Util.delete_tree(temporary_directory);
+                    }
+                    catch(Error e) {
+                    }
+                }
+            }
+
+            return 0;
+        }
+
+        private static int add_repo_usage() {
+            printerr("USAGE:\n\tusm add-repo [-y|--yes] <url-or-path>\n");
+            return 255;
+        }
+
+        /**
+         * The path of the already-configured repository with this name —
+         * the conventional `<name>.usmr` filename, or any descriptor in
+         * repos.d whose parsed name matches — or null when none exists.
+         */
+        private static string? add_repo_existing_path(string repos_directory, string name) {
+            var direct = Path.build_filename(repos_directory, @"$name.usmr");
+            if(FileUtils.test(direct, FileTest.EXISTS)) {
+                return direct;
+            }
+            try {
+                foreach(var file in Iterate.directory(repos_directory)) {
+                    if(!file.has_suffix(".usmr")) {
+                        continue;
+                    }
+                    var path = Path.build_filename(repos_directory, file);
+                    try {
+                        if(new Usm.Repository.from_file(path).name == name) {
+                            return path;
+                        }
+                    }
+                    catch(Error e) {
+                        // An unreadable descriptor cannot be name-matched;
+                        // the filename check above covers the conventional
+                        // layout
+                    }
+                }
+            }
+            catch(Error e) {
+                // No repos.d contents to conflict with
+            }
+            return null;
+        }
+
+        /** The replace confirmation; anything but an explicit yes (or EOF) declines. */
+        private static bool add_repo_confirm() {
+            print("Replace it? [y/N] ");
+            stdout.flush();
+            var reply = add_repo_read_line();
+            if(reply == null) {
+                return false;
+            }
+            var answer = reply.strip().down();
+            return answer == "y" || answer == "yes";
+        }
+
+        /** One line from stdin, or null at EOF; FileStream exposes no read_line. */
+        private static string? add_repo_read_line() {
+            var builder = new StringBuilder();
+            var character = stdin.getc();
+            while(character != -1 && character != '\n') {
+                builder.append_c((char)character);
+                character = stdin.getc();
+            }
+            if(character == -1 && builder.len == 0) {
+                return null;
+            }
+            return builder.str;
+        }
+    }
+}

+ 213 - 0
src/cli/Clean.vala

@@ -0,0 +1,213 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+/** Byte units for {@link clean_format_bytes}, ascending. */
+private const string[] CLEAN_UNITS = { "B", "KiB", "MiB", "GiB", "TiB" };
+
+/**
+ * `usm clean [lists|builds|sources|all]` / `usm clean [build|source] <package>`
+ * — reclaim disk space from the managed state tree.
+ *
+ * `usm clean lists` clears the downloaded PACKAGES.usml caches under the
+ * state tree's `lists` directory; `usm clean builds` clears every
+ * package's build directory and build.tar.xz; `usm clean sources` clears
+ * every package's extracted source directory; `usm clean all` does all
+ * three. `usm clean build <package>` and `usm clean source <package>`
+ * clear one package's build artifacts or sources across its cached
+ * versions.
+ *
+ * No confirmation: everything here is regenerable (the next install
+ * re-downloads or re-extracts). Idempotent — missing paths are skipped
+ * silently — and reports the bytes freed per category and in total.
+ */
+private int clean_main(string[] args) {
+
+    if(args.length == 3 && (args[2] == "all" || args[2] == "lists" || args[2] == "builds" || args[2] == "sources")) {
+        return clean_categories(args[2]);
+    }
+    if(args.length == 4 && (args[2] == "build" || args[2] == "source")) {
+        return clean_one(args[2], args[3]);
+    }
+    return clean_usage();
+}
+
+private int clean_categories(string category) {
+    Usm.SystemState state = null;
+    try {
+        state = new Usm.SystemState(paths);
+    }
+    catch(Error e) {
+        printerr(@"This system is not managed by usm: $(e.message)\n");
+        return 245;
+    }
+
+    try {
+        uint64 total = 0;
+        if(category == "lists" || category == "all") {
+            total += clean_report("repository lists", clean_lists(state));
+        }
+        if(category == "builds" || category == "all") {
+            total += clean_report("build caches", clean_builds(state, null));
+        }
+        if(category == "sources" || category == "all") {
+            total += clean_report("source directories", clean_sources(state, null));
+        }
+
+        if(total == 0) {
+            print("Nothing to clean.\n");
+        }
+        else {
+            print(@"Total: $(clean_format_bytes(total)) ($total bytes) freed\n");
+        }
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return 238;
+    }
+
+    return 0;
+}
+
+private int clean_one(string kind, string package_name) {
+    Usm.SystemState state = null;
+    try {
+        state = new Usm.SystemState(paths);
+    }
+    catch(Error e) {
+        printerr(@"This system is not managed by usm: $(e.message)\n");
+        return 245;
+    }
+
+    try {
+        uint64 freed = kind == "build"
+            ? clean_builds(state, package_name)
+            : clean_sources(state, package_name);
+        var label = kind == "build" ? @"$package_name's build cache" : @"$package_name's sources";
+        if(freed == 0) {
+            print("Nothing to clean.\n");
+        }
+        else {
+            clean_report(label, freed);
+            print(@"Total: $(clean_format_bytes(freed)) ($freed bytes) freed\n");
+        }
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return 238;
+    }
+
+    return 0;
+}
+
+private uint64 clean_report(string label, uint64 freed) {
+    print(@"Freed $(clean_format_bytes(freed)) from $label\n");
+    return freed;
+}
+
+/** Deletes every per-repository listing directory under `<state>/lists/`. */
+private uint64 clean_lists(Usm.SystemState state) throws Error {
+    uint64 freed = 0;
+    var lists_path = Path.build_filename(state.state_path, "lists");
+    if(File.new_for_path(lists_path).query_exists()) {
+        foreach(var child in Iterate.directory(lists_path)) {
+            freed += clean_delete(Path.build_filename(lists_path, child));
+        }
+    }
+    return freed;
+}
+
+/**
+ * Deletes build directories and archives. Without {@link name} every
+ * package's; with it, only {@link name}'s version directories (other
+ * packages sharing the name prefix are never touched).
+ */
+private uint64 clean_builds(Usm.SystemState state, string? name) throws Error {
+    uint64 freed = 0;
+    foreach(var package_dir in clean_package_directories(state, name)) {
+        freed += clean_delete(Path.build_filename(package_dir, "build"));
+        freed += clean_delete(Path.build_filename(package_dir, "build.tar.xz"));
+    }
+    return freed;
+}
+
+/** Deletes extracted source directories, every package's or {@link name}'s only. */
+private uint64 clean_sources(Usm.SystemState state, string? name) throws Error {
+    uint64 freed = 0;
+    foreach(var package_dir in clean_package_directories(state, name)) {
+        freed += clean_delete(Path.build_filename(package_dir, "package"));
+    }
+    return freed;
+}
+
+/**
+ * The package cache directories to act on: every directory under
+ * `<state>/packages/`, or — when {@link name} is given — only its
+ * `<name>-<version>` directories (see
+ * {@link Usm.SystemState.get_cached_versions}).
+ */
+private Vector<string> clean_package_directories(Usm.SystemState state, string? name) throws Error {
+    var directories = new Vector<string>();
+    if(name == null) {
+        var packages_path = Path.build_filename(state.state_path, "packages");
+        if(File.new_for_path(packages_path).query_exists()) {
+            foreach(var child in Iterate.directory(packages_path)) {
+                directories.add(Path.build_filename(packages_path, child));
+            }
+        }
+    }
+    else {
+        foreach(var version in state.get_cached_versions(name)) {
+            directories.add(version.state_path);
+        }
+    }
+    return directories;
+}
+
+/** Deletes {@link path} when it exists, returning the bytes it occupied. */
+private uint64 clean_delete(string path) throws Error {
+    if(!File.new_for_path(path).query_exists()) {
+        return 0;
+    }
+    uint64 size = clean_path_size(path);
+    Usm.Util.delete_tree(path);
+    return size;
+}
+
+/** Recursive on-disk size of {@link path}; unreadable entries count as zero. */
+private uint64 clean_path_size(string path) {
+    try {
+        var info = File.new_for_path(path).query_info(
+            FileAttribute.STANDARD_SIZE + "," + FileAttribute.STANDARD_TYPE, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+        if(info.get_file_type() != FileType.DIRECTORY) {
+            return (uint64)info.get_size();
+        }
+
+        uint64 size = 0;
+        var enumerator = File.new_for_path(path).enumerate_children(FileAttribute.STANDARD_NAME, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+        FileInfo child_info = null;
+        while((child_info = enumerator.next_file()) != null) {
+            size += clean_path_size(Path.build_filename(path, child_info.get_name()));
+        }
+        return size;
+    }
+    catch(Error e) {
+        return 0;
+    }
+}
+
+/** Human-readable byte count, for example `12.0 MiB` or `512 B`. */
+private string clean_format_bytes(uint64 bytes) {
+    double size = bytes;
+    foreach(var unit in CLEAN_UNITS) {
+        if(size < 1024.0 || unit == CLEAN_UNITS[CLEAN_UNITS.length - 1]) {
+            return unit == "B" ? @"$bytes B" : "%.1f %s".printf(size, unit);
+        }
+        size /= 1024.0;
+    }
+    assert_not_reached();
+}
+
+private int clean_usage() {
+    printerr("USAGE:\n\tusm clean [lists|builds|sources|all]\n\tusm clean [build|source] <package>\n");
+    return 255;
+}

+ 113 - 17
src/cli/Cli.vala

@@ -2,37 +2,133 @@ Usm.Paths paths;
 
 
 public static int main(string[] args) {
-    paths = new Usm.Paths.usm_environ();
+    paths = new Usm.Paths();
 
     if(args.length < 2) {
         usage();
         return 255;
     }
 
-    var command = args[1];
-    if(command == "manifest") {
-        var m_args = new Invercargill.DataStructures.Vector<string>();
-        m_args.add_all(Invercargill.Wrap.array(args));
-        m_args.remove_at(1);
-        return manifest_main(m_args.to_array());
-    }
-    if(command == "info") {
-        return info(args);
-    }
-    if(command == "repository") {
-        return repository_main(args);
+    try {
+        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();
+
+        // `--help` with no subcommand (and a bare `help` command) prints the
+        // overview; with a subcommand the option flows to that command's own usage
+        if((command == null && (arguments.contains("--help") || arguments.contains("-h"))) || command == "help") {
+            Usm.Cli.print_help(false);
+            return 0;
+        }
+
+        // `--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);
+            if(root_path == null) {
+                printerr("Expected path after --root\n");
+                return 255;
+            }
+
+            
+            if(command == "manifest") {
+                var wd = File.new_build_filename(root_path, "usm-wd");
+                if(!wd.query_exists()) {
+                    wd.make_directory();
+                }
+                Linux.umount(wd.get_path());
+                var res = Linux.mount(Environment.get_current_dir(), wd.get_path(), "", Linux.MountFlags.BIND);
+                if(res != 0) {
+                    printerr(@"Unable to bind mount working directory\n");
+                }
+            }
+
+            var result = Posix.chroot(root_path);
+            if(result != 0) {
+                printerr(@"Unable to chroot to \"$root_path\": $(Posix.strerror(result))\n");
+                return result;
+            }
+
+            if(command == "manifest") {
+                Environment.set_current_dir("/usm-wd");
+            }
+    
+            arguments.remove("--root");
+            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(dispatch_args);
+        }
+        if(command == "repository") {
+            return repository_main(dispatch_args);
+        }
+        if(command == "install") {
+            return install_main(dispatch_args);
+        }
+        if(command == "update") {
+            return update_main(dispatch_args);
+        }
+        if(command == "remove") {
+            return remove_main(dispatch_args);
+        }
+        if(command == "downgrade") {
+            return downgrade_main(dispatch_args);
+        }
+        if(command == "rebuild") {
+            return rebuild_main(dispatch_args);
+        }
+        if(command == "clean") {
+            return clean_main(dispatch_args);
+        }
+        if(command == "search") {
+            return search_main(dispatch_args);
+        }
+        if(command == "provides") {
+            return provides_main(dispatch_args);
+        }
+        if(command == "add-repo") {
+            return add_repo_main(dispatch_args);
+        }
+        if(command == "deploy") {
+            return deploy_main(dispatch_args);
+        }
+        if(command == "scaffold") {
+            return scaffold_main(dispatch_args);
+        }
+        if(command == "genconfig") {
+            return genconfig_main();
+        }
+        if(command == "enroll") {
+            return enroll_main(dispatch_args);
+        }
     }
-    if(command == "install") {
-        return install_main(args);
+    catch(Error e) {
+        printerr(@"Unhandled error: $(e.message)\n");
+        return -1;
     }
 
-
     usage();
     return 255;
 }
 
 private void usage() {
-    printerr("USAGE:\n\tusm manifest\n\tusm info\n\tusm repository\n\tusm install\n");
+    Usm.Cli.print_help(true);
 }
 
 

+ 40 - 0
src/cli/Confirm.vala

@@ -0,0 +1,40 @@
+namespace Usm.Cli {
+
+    /**
+     * Displays a {@link Usm.TransactionSummary} and asks the user to
+     * confirm it: the one plan + prompt shared by `usm install`, `update`,
+     * `remove` and `downgrade`. Returns true when the user answers y/yes
+     * (or when {@link assume_yes} — the `-y`/`--yes` flag — skips the
+     * prompt entirely); anything else, including EOF, declines.
+     */
+    public bool confirm_transaction(Usm.TransactionSummary summary, bool assume_yes) {
+        if(assume_yes) {
+            return true;
+        }
+
+        stdout.printf("%s\n", summary.describe(Posix.isatty(Posix.STDOUT_FILENO)));
+        stdout.printf("Proceed? [y/N] ");
+        stdout.flush();
+
+        var reply = confirm_read_line();
+        if(reply == null) {
+            return false;
+        }
+        var answer = reply.strip().down();
+        return answer == "y" || answer == "yes";
+    }
+
+    /** One line from stdin, or null at EOF; FileStream exposes no read_line. */
+    private string? confirm_read_line() {
+        var builder = new StringBuilder();
+        var character = stdin.getc();
+        while(character != -1 && character != '\n') {
+            builder.append_c((char)character);
+            character = stdin.getc();
+        }
+        if(character == -1 && builder.len == 0) {
+            return null;
+        }
+        return builder.str;
+    }
+}

+ 824 - 0
src/cli/Deploy.vala

@@ -0,0 +1,824 @@
+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), hosted at packages.astrologue.nz; override it per deploy
+     * with `--installer-url` (whose `file://` form is the sanctioned
+     * local-testing path).
+     */
+    public const string CANONICAL_URL = "https://packages.astrologue.nz/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";
+
+
+/**
+ * The system package managers `deploy --spm` can wire into the image, one
+ * per shim the installer ships; {@link DeploySpm.NONE} wires none and the
+ * image resolves from USM repositories alone.
+ */
+private enum DeploySpm {
+    DNF,
+    APT,
+    APK,
+    EMERGE,
+    PACMAN,
+    NONE;
+
+    /**
+     * Whether a `--spm` value names a choice, setting {@link parsed} to it
+     * (and reporting the valid values) — {@link DeploySpm.NONE} on failure.
+     */
+    public static bool parse(string value, out DeploySpm parsed) {
+        parsed = DeploySpm.NONE;
+        switch(value) {
+            case "dnf":
+                parsed = DeploySpm.DNF;
+                return true;
+            case "apt":
+                parsed = DeploySpm.APT;
+                return true;
+            case "apk":
+                parsed = DeploySpm.APK;
+                return true;
+            case "emerge":
+                parsed = DeploySpm.EMERGE;
+                return true;
+            case "pacman":
+                parsed = DeploySpm.PACMAN;
+                return true;
+            case "none":
+                parsed = DeploySpm.NONE;
+                return true;
+            default:
+                printerr(@"\"$value\" is not a valid --spm value (expected dnf, apt, apk, emerge, pacman or none)\n");
+                return false;
+        }
+    }
+
+    /** The lowercase name completing `usm-spm-` in shim paths. */
+    public string shim_name() {
+        switch(this) {
+            case DeploySpm.DNF:
+                return "dnf";
+            case DeploySpm.APT:
+                return "apt";
+            case DeploySpm.APK:
+                return "apk";
+            case DeploySpm.EMERGE:
+                return "emerge";
+            case DeploySpm.PACMAN:
+                return "pacman";
+            default:
+                return "none";
+        }
+    }
+
+    /**
+     * The library directory the generated config installs `lib:`
+     * resources under: apk targets musl/Alpine, apt targets Debian
+     * multiarch and pacman targets the merged-usr Arch layout, none of
+     * which searches /usr/lib64 as its native directory (their loaders
+     * and pkg-config defaults resolve /usr/lib); the rpm and portage
+     * targets follow the lib64 convention.
+     */
+    public string lib_path() {
+        return this == DeploySpm.APK || this == DeploySpm.APT || this == DeploySpm.PACMAN ? "lib" : "lib64";
+    }
+}
+
+
+/**
+ * `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}).
+ * - `--spm dnf|apt|apk|emerge|pacman|none`: the system package manager wired into
+ *   the image via the matching `usm-spm-<spm>` helper and a per-SPM
+ *   bootstrap RUN in the Containerfile. Default `none`: no
+ *   `system_package_manager` section and no bootstrap RUN, so the image
+ *   resolves everything from USM repositories alone.
+ * - `--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;
+    DeploySpm deploy_spm = DeploySpm.NONE;
+    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 "--spm":
+                var spm_value = deploy_option_value(args, ref i, argument, inline_value);
+                if(spm_value == null) {
+                    return deploy_usage();
+                }
+                if(!DeploySpm.parse((!)spm_value, out deploy_spm)) {
+                    return deploy_usage();
+                }
+                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, deploy_spm);
+
+        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,
+            deploy_spm);
+        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 {
+        // Stream podman save stdout directly into xz stdin (no intermediate
+        // .tar on disk); xz -T0 uses every available CPU core/thread
+        var xz_launcher = new SubprocessLauncher(SubprocessFlags.STDIN_PIPE);
+        xz_launcher.set_stdout_file_path(@"$artifact_base.xz");
+        var xz_proc = xz_launcher.spawnv(new string[] { "xz", "-T0" });
+
+        var save_launcher = new SubprocessLauncher(SubprocessFlags.STDOUT_PIPE);
+        var save_proc = save_launcher.spawnv(new string[] { "podman", "save", tag });
+
+        var source = save_proc.get_stdout_pipe();
+        var sink = xz_proc.get_stdin_pipe();
+        var buffer = new uint8[65536];
+        while (true) {
+            var read = source.read(buffer);
+            if (read <= 0) {
+                break;
+            }
+            sink.write(buffer[0:read]);
+        }
+        sink.close();
+        source.close();
+
+        save_proc.wait_check();
+        xz_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] [--spm dnf|apt|apk|emerge|pacman|none] [--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 == "--spm" || 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) {
+            if(path.has_prefix("https://") || path.has_prefix("http://")) {
+                // Remote .usmr URL: download into the context's repos/ dir
+                // so the container resolves from the remote host directly
+                var destination = Path.build_filename(context_dir, "repos");
+                DirUtils.create_with_parents(destination, 0755);
+                var filename = path.substring(path.last_index_of("/") + 1);
+                var local = Path.build_filename(destination, filename);
+                try {
+                    printerr(@"Downloading repository descriptor from \"$path\"...\n");
+                    var download = new Subprocess.newv(
+                        new string[] { "curl", "-fsSL", "--max-time", "30", "-o", local, path },
+                        SubprocessFlags.INHERIT_FDS);
+                    download.wait_check();
+                }
+                catch(Error e) {
+                    printerr(@"Failed to download repository from \"$path\": $(e.message)\n");
+                    return 237;
+                }
+            }
+            else {
+                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, unless {@link spm} is {@link DeploySpm.NONE}, the system package
+ * manager wired to the matching helper the installer ships so in-container
+ * resolution asks the SPM 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, DeploySpm spm) throws Error {
+    var spm_section = "";
+    if(spm != DeploySpm.NONE) {
+        var helper = @"$(DEPLOY_USM_PREFIX)/bin/usm-spm-$(spm.shim_name())";
+        spm_section = @",
+    \"system_package_manager\": {
+        \"query\": [\"$helper\", \"query\"],
+        \"install\": [\"$helper\", \"install\"]
+    }";
+    }
+    var config = @"{
+    \"is_managed\": true,
+    \"managed\": {
+        \"state_path\": \"/var/usm\"
+    },
+    \"paths\": {
+        \"lib\": \"$(spm.lib_path())\"
+    }$spm_section
+}
+";
+    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.
+ * A {@link spm} other than {@link DeploySpm.NONE} contributes the SPM's
+ * bootstrap RUN (emerge needs none — the stage3 base carries portage).
+ */
+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, DeploySpm spm) {
+    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");
+    }
+    switch(spm) {
+        case DeploySpm.DNF:
+            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");
+            break;
+        case DeploySpm.APT:
+            builder.append("# curl (absent from the debian base) fetches the installer; python3-apt bindings plus the apt-file contents index serve the usm SPM helper\n");
+            builder.append("RUN apt-get update && apt-get install -y curl python3 python3-apt apt-file && apt-file update && apt-get clean\n\n");
+            break;
+        case DeploySpm.APK:
+            builder.append("# The shell SPM helper needs nothing beyond busybox; bash serves the package's manage: scripts and curl (absent from the alpine base) fetches the installer\n");
+            builder.append("RUN apk add --no-cache bash curl\n\n");
+            break;
+        case DeploySpm.PACMAN:
+            builder.append("# curl (absent from the base image) fetches the installer; the files databases fetched by pacman -Fy serve the shell SPM helper's file queries\n");
+            builder.append("RUN pacman -Sy --noconfirm curl && pacman -Fy --noconfirm && rm -rf /var/cache/pacman/pkg/*\n\n");
+            break;
+        case DeploySpm.EMERGE:
+        case DeploySpm.NONE:
+            break;
+    }
+    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(" && bash /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 -y %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;
+}

+ 203 - 0
src/cli/Downgrade.vala

@@ -0,0 +1,203 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+/**
+ * `usm downgrade [-y|--yes] <package> [<version>]` — replace an
+ * installed package with an older version.
+ *
+ * Without a version the most recent previous version is auto-picked from
+ * the sibling state directories updates keep behind (an update never
+ * deletes the old version's directory precisely so this works); with an
+ * explicit version the state cache is checked first and the configured
+ * repositories second (downloading and verifying the package). The
+ * current version's resources are removed — keeping its state directory,
+ * so the downgrade back stays possible — and the old version installed
+ * through the standard build-cache pipeline.
+ */
+private int downgrade_main(string[] args) {
+
+    var assume_yes = false;
+    string? package_name = null;
+    string? requested_version = null;
+    for(int i = 2; i < args.length; i++) {
+        var argument = args[i];
+        if(argument == "-y" || argument == "--yes") {
+            assume_yes = true;
+        }
+        else if(argument.has_prefix("-")) {
+            printerr(@"Unknown option \"$argument\"\n");
+            return downgrade_usage();
+        }
+        else if(package_name == null) {
+            package_name = argument;
+        }
+        else if(requested_version == null) {
+            requested_version = argument;
+        }
+        else {
+            return downgrade_usage();
+        }
+    }
+    if(package_name == null) {
+        return downgrade_usage();
+    }
+
+    var progress = new Usm.Cli.ProgressBar();
+
+    Usm.SystemState state = null;
+    try {
+        state = new Usm.SystemState(paths);
+    }
+    catch(Error e) {
+        printerr(@"This system is not managed by usm: $(e.message)\n");
+        return 245;
+    }
+
+    try {
+        var current = state.find_installed(package_name);
+        if(current == null) {
+            printerr(@"No installed package named \"$package_name\"\n");
+            return 254;
+        }
+        var current_version = current.get_manifest().version;
+
+        Usm.CachedPackage? target = null;
+        if(requested_version == null) {
+            target = downgrade_most_recent_previous(state, current, current_version);
+            if(target == null) {
+                printerr("No previous version available for downgrade\n");
+                return 253;
+            }
+        }
+        else {
+            target = downgrade_explicit(state, package_name, requested_version);
+            if(target == null) {
+                printerr(@"No version \"$requested_version\" of \"$package_name\" found in the state cache or repositories\n");
+                return 253;
+            }
+            if(!target.get_manifest().version.less_than(current_version)) {
+                printerr(@"Version \"$(target.get_manifest().version)\" of \"$package_name\" is not older than the installed version $(current_version)\n");
+                return 253;
+            }
+        }
+
+        var downgrades = new Vector<Usm.DowngradeEntry>();
+        downgrades.add(new Usm.DowngradeEntry() {
+            target = target,
+            current = current
+        });
+
+        var downgrade_order = new Vector<string>();
+        downgrade_order.add(package_name);
+
+        var transaction = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(paths),
+            to_downgrade = downgrades,
+            install_order = downgrade_order,
+            state = state
+        };
+
+        // Planning before the prompt populates the rebuildDependants
+        // entries so the summary shows the whole transaction
+        try {
+            transaction.strategise();
+        }
+        catch(Usm.TransactionError e) {
+            printerr(@"$(e.message)\n");
+            return 240;
+        }
+
+        var summary = new Usm.TransactionSummary() {
+            to_downgrade = downgrades,
+            to_rebuild = transaction.rebuilds
+        };
+        if(!Usm.Cli.confirm_transaction(summary, assume_yes)) {
+            print("Aborted.\n");
+            return 1;
+        }
+
+        printerr("\nRunning transaction...\n");
+        transaction.progress_updated.connect(progress.on_transaction_progress);
+        transaction.run();
+        progress.finish();
+    }
+    catch(Error e) {
+        progress.fail();
+        printerr(@"Error: $(e.message)\n");
+        return 238;
+    }
+
+    return 0;
+}
+
+/**
+ * The highest cached version of {@link current}'s package that is lower
+ * than {@link current_version} — the "most recent previous" — or null
+ * when no sibling version exists.
+ */
+private Usm.CachedPackage? downgrade_most_recent_previous(Usm.SystemState state,
+        Usm.CachedPackage current, Usm.Version current_version) throws Error {
+    Usm.CachedPackage? best = null;
+    Usm.Version? best_version = null;
+    foreach(var candidate in state.get_cached_versions(current.get_manifest().name)) {
+        if(candidate.state_path == current.state_path) {
+            continue;
+        }
+        var candidate_version = candidate.get_manifest().version;
+        if(candidate_version.compare(current_version) >= 0) {
+            continue;
+        }
+        if(best == null || candidate_version.greater_than((!)best_version)) {
+            best = candidate;
+            best_version = candidate_version;
+        }
+    }
+    return best;
+}
+
+/**
+ * The cached {@link Usm.CachedPackage} for {@link version}: the state
+ * cache directory when it holds a valid package.usmc, otherwise a
+ * repository download (refreshing listings first); null when neither
+ * source has it.
+ */
+private Usm.CachedPackage? downgrade_explicit(Usm.SystemState state, string package_name, string version) throws Error {
+    var cached_path = Path.build_filename(state.state_path, "packages", @"$package_name-$version");
+    if(File.new_for_path(Path.build_filename(cached_path, "package.usmc")).query_exists()) {
+        return new Usm.CachedPackage(cached_path);
+    }
+
+    printerr("Refreshing repositories...\n");
+    foreach(var repo in state.get_repositories()) {
+        state.refresh_list(repo, (f, c, t) => printerr(@"Refreshing list for $(repo.name): downloading $f $c/$t bytes\r"));
+        printerr("\n");
+        var listing = state.get_latest_list(repo);
+        if(listing == null) {
+            continue;
+        }
+        foreach(var entry in listing.entries) {
+            if(entry.manifest.name != package_name || entry.manifest.version.to_string() != version) {
+                continue;
+            }
+
+            var path = state.generate_cache_path(entry.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");
+            var client = repo.get_client(state.config);
+            client.download_package(package_path, entry, (f, c, t) => printerr(@"Downloading $f $c/$t bytes\r"));
+            client.verify_package(package_path, entry, (f, c, t) => printerr(@"Verifying $f $c/$t bytes\r"));
+            printerr("\n");
+            return new Usm.CachedPackage(path);
+        }
+    }
+    return null;
+}
+
+private int downgrade_usage() {
+    printerr("USAGE:\n\tusm downgrade [-y|--yes] <package> [<version>]\n");
+    return 255;
+}

+ 392 - 0
src/cli/Enroll.vala

@@ -0,0 +1,392 @@
+using Invercargill.DataStructures;
+using InvercargillJson;
+
+/** Default managed state root, matching the layout `usm deploy` writes into images. */
+private const string ENROLL_DEFAULT_STATE_PATH = "/var/usm";
+
+/** Directory mode for every directory enrollment creates. */
+private const int ENROLL_DIRECTORY_MODE = 0755;
+
+
+/**
+ * `usm enroll [-y|--yes] [--state-path <path>]` — transition a system from
+ * unmanaged to managed by USM.
+ *
+ * Shows the exact directories that will be created and the usm.config
+ * change, asks for confirmation (skipped with `-y`/`--yes`), sets
+ * `is_managed: true` plus `managed.state_path` in usm.config (creating or
+ * updating the file, honouring USM_CONFIGDIR) and creates every directory
+ * USM references at runtime: the managed state tree (`packages/`,
+ * `installed/`, `lists/` — see {@link Usm.SystemState}), the configuration
+ * tree (`usm.config`, `repos.d/`) and the destination/prefix directory
+ * layout behind {@link Usm.Paths.get_suggested_base_path_for_type} and
+ * {@link Usm.Paths.set_envs}.
+ *
+ * Idempotent: existing directories are left alone, and a system whose
+ * config is already managed with the same state path reports "already
+ * enrolled" after re-ensuring the directories exist. Without root the
+ * default state path and the system directory tree usually cannot be
+ * written; enrollment warns and suggests a user-local
+ * `--state-path ~/.local/state/usm` instead.
+ */
+public int enroll_main(string[] args) throws Error {
+    var assume_yes = false;
+    string? state_path_flag = null;
+    for(int i = 2; i < args.length; i++) {
+        var argument = args[i];
+        if(argument == "-y" || argument == "--yes") {
+            assume_yes = true;
+        }
+        else if(argument.has_prefix("--state-path=")) {
+            state_path_flag = argument.split("=", 2)[1];
+        }
+        else if(argument == "--state-path") {
+            if(i + 1 >= args.length) {
+                printerr("Expected a path after --state-path\n");
+                return enroll_usage();
+            }
+            state_path_flag = args[++i];
+        }
+        else {
+            printerr(@"Unexpected argument \"$argument\"\n");
+            return enroll_usage();
+        }
+    }
+    if(state_path_flag != null && !Path.is_absolute(state_path_flag)) {
+        state_path_flag = Path.build_filename(Environment.get_current_dir(), state_path_flag);
+    }
+
+    var config_file = Path.build_filename(paths.usm_config_dir, "usm.config");
+    Usm.Configuration? existing_config = null;
+    if(File.new_for_path(config_file).query_exists()) {
+        try {
+            existing_config = new Usm.Configuration.from_paths(paths);
+        }
+        catch(Error e) {
+            printerr(@"The existing usm.config could not be read: $(e.message)\n");
+            return 1;
+        }
+    }
+
+    // An explicit --state-path re-enrols to a new root; otherwise an
+    // already-managed config keeps its own state path
+    var state_path = state_path_flag
+        ?? existing_config?.managed_config?.state_path
+        ?? ENROLL_DEFAULT_STATE_PATH;
+
+    if(existing_config != null && existing_config.is_managed
+            && existing_config.managed_config != null
+            && existing_config.managed_config.state_path == state_path) {
+        print(@"This system is already enrolled; managed state lives in \"$state_path\".\n");
+        var plan = enroll_plan_directories(state_path);
+        var outcome = enroll_ensure_directories(plan);
+        enroll_report(outcome, null);
+        return enroll_report_failures(plan, outcome);
+    }
+
+    // Rendered before the mutation below: updated_config aliases
+    // existing_config, so the "old" side of the diff must be captured first
+    string? existing_config_text = null;
+    if(existing_config != null) {
+        existing_config_text = enroll_render_config(existing_config);
+    }
+
+    var updated_config = existing_config ?? new Usm.Configuration();
+    updated_config.is_managed = true;
+    if(updated_config.managed_config == null) {
+        updated_config.managed_config = new Usm.ManagedConfiguration();
+    }
+    updated_config.managed_config.state_path = state_path;
+
+    var plan = enroll_plan_directories(state_path);
+    print("Enrolling this system with USM will:\n\n");
+    print("Create these directories (existing ones are left alone):\n");
+    enroll_print_directories("State", plan.state_directories);
+    enroll_print_directories("Configuration", plan.config_directories);
+    enroll_print_directories("System", plan.system_directories);
+
+    print("\nChange the configuration:\n");
+    if(existing_config == null) {
+        print(@"  create $config_file:\n");
+        foreach(var line in enroll_render_config(updated_config).split("\n")) {
+            print(@"  + $line\n");
+        }
+    }
+    else {
+        print(@"  $config_file:\n");
+        foreach(var line in enroll_diff_lines(existing_config_text, enroll_render_config(updated_config))) {
+            print(@"  $line\n");
+        }
+    }
+
+    if(Posix.geteuid() != 0) {
+        if(state_path == ENROLL_DEFAULT_STATE_PATH) {
+            printerr(@"\nWarning: not running as root — the default state path \"$state_path\" and the system directories usually require root.\n");
+            printerr("Re-run under sudo, or enrol user-local instead: usm enroll --state-path ~/.local/state/usm\n");
+        }
+        else {
+            printerr("\nWarning: not running as root — the system directories above usually require root and may fail to create.\n");
+        }
+    }
+
+    if(!assume_yes && !enroll_confirm()) {
+        print("Aborted.\n");
+        return 1;
+    }
+
+    var result = enroll_ensure_directories(plan);
+    // A config claiming a state path that could not be created would leave
+    // the system half-managed: fail before writing it
+    var essential_failed = result.failed.any(d => plan.state_directories.contains(d) || plan.config_directories.contains(d));
+    if(essential_failed) {
+        enroll_report(result, null);
+        return enroll_report_failures(plan, result);
+    }
+    try {
+        new JsonElement.from_properties(Usm.Configuration.get_mapper().map_from(updated_config)).write_to_file(config_file);
+    }
+    catch(Error e) {
+        printerr(@"Unable to write \"$config_file\": $(e.message)\n");
+        enroll_report(result, null);
+        return 1;
+    }
+    enroll_report(result, existing_config == null
+        ? @"Created configuration $config_file"
+        : @"Updated configuration $config_file");
+    return enroll_report_failures(plan, result);
+}
+
+private int enroll_usage() {
+    printerr("USAGE:\n\tusm enroll [-y|--yes] [--state-path <path>]\n");
+    return 255;
+}
+
+
+/**
+ * The complete directory plan: managed state tree, configuration tree and
+ * the destination/prefix layout, in creation order.
+ */
+private class EnrollPlan {
+    public Vector<string> state_directories = new Vector<string>();
+    public Vector<string> config_directories = new Vector<string>();
+    public Vector<string> system_directories = new Vector<string>();
+
+    public Vector<string> all() {
+        var every = new Vector<string>();
+        every.add_all(state_directories);
+        every.add_all(config_directories);
+        every.add_all(system_directories);
+        return every;
+    }
+}
+
+/**
+ * Every directory USM references at runtime for the given state path: the
+ * {@link Usm.SystemState} layout (`packages`, `installed`, `lists`),
+ * `usm.config`'s directory plus `repos.d`, and one directory per
+ * {@link Usm.Paths.get_suggested_base_path_for_type} branch plus the
+ * LOCALSTATEDIR/SHAREDSTATEDIR roots {@link Usm.Paths.set_envs} exports.
+ * Equal paths (lib == canonlib, for instance) collapse to one entry.
+ */
+private EnrollPlan enroll_plan_directories(string state_path) {
+    var plan = new EnrollPlan();
+
+    plan.state_directories.add(state_path);
+    foreach(var part in new string[] { "packages", "installed", "lists" }) {
+        plan.state_directories.add(Path.build_filename(state_path, part));
+    }
+
+    plan.config_directories.add(paths.usm_config_dir);
+    plan.config_directories.add(Path.build_filename(paths.usm_config_dir, "repos.d"));
+
+    var system = plan.system_directories;
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.ROOT_PATH));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.PATH));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.OPTIONAL));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.RESOURCE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.CONFIGURATION));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.BINARY));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.SUPER_BINARY));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.LIBRARY));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.LIBRARY_RESOURCE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.LIBRARY_EXECUTABLE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.CANONICAL_LIBRARY));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.CANONICAL_LIBRARY_RESOURCE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.GIO_MODULE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.INFO_PAGE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.MANUAL_PAGE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.LOCALE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.APPLICATION));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.INCLUDE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.PKG_CONFIG));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.VALA_API));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.GOBJECT_IR));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.TYPELIB));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.TAG));
+    enroll_add_unique(system, Path.build_filename(paths.destination, paths.local_state));
+    enroll_add_unique(system, Path.build_filename(paths.destination, paths.shared_state));
+    return plan;
+}
+
+private void enroll_add_unique(Vector<string> directories, string directory) {
+    if(!directories.contains(directory)) {
+        directories.add(directory);
+    }
+}
+
+/** What a directory-creation pass did: created, skipped and failed paths. */
+private class EnrollOutcome {
+    public Vector<string> created = new Vector<string>();
+    public Vector<string> existed = new Vector<string>();
+    public Vector<string> failed = new Vector<string>();
+}
+
+private EnrollOutcome enroll_ensure_directories(EnrollPlan plan) {
+    var outcome = new EnrollOutcome();
+    foreach(var directory in plan.all()) {
+        if(File.new_for_path(directory).query_exists()) {
+            outcome.existed.add(directory);
+            continue;
+        }
+        try {
+            DirUtils.create_with_parents(directory, ENROLL_DIRECTORY_MODE);
+            outcome.created.add(directory);
+        }
+        catch(Error e) {
+            outcome.failed.add(directory);
+        }
+    }
+    return outcome;
+}
+
+
+/**
+ * Exit-code half of the outcome report: failures in the state and
+ * configuration trees are fatal (enrollment is not functional without
+ * them), while system-tree failures — typically needing root on a live
+ * system — only warn.
+ */
+private int enroll_report_failures(EnrollPlan plan, EnrollOutcome outcome) {
+    if(outcome.failed.count() == 0) {
+        return 0;
+    }
+    printerr("\nFailed to create:\n");
+    foreach(var directory in outcome.failed) {
+        printerr(@"  $directory\n");
+    }
+    var essential = outcome.failed.any(d => plan.state_directories.contains(d) || plan.config_directories.contains(d));
+    if(essential) {
+        printerr("The state and configuration directories are required; re-run as root or pass a writable --state-path.\n");
+        return 1;
+    }
+    printerr("These system directories need root; the rest of the enrollment is in place.\n");
+    return 0;
+}
+
+private void enroll_report(EnrollOutcome outcome, string? config_action) {
+    foreach(var directory in outcome.created) {
+        print("%-9s %s\n", "Created", directory);
+    }
+    if(outcome.existed.count() > 0) {
+        print(@"$(outcome.existed.count()) directories already existed (left alone)\n");
+    }
+    if(config_action != null) {
+        print(@"$config_action\n");
+    }
+}
+
+private void enroll_print_directories(string title, Vector<string> directories) {
+    print(@"$title:\n");
+    foreach(var directory in directories) {
+        var suffix = File.new_for_path(directory).query_exists() ? " (already exists)" : "";
+        print(@"  $directory$suffix\n");
+    }
+}
+
+/** The yes/no confirmation; anything but an explicit yes (or EOF) declines. */
+private bool enroll_confirm() {
+    print("Enrol this system? [y/N] ");
+    stdout.flush();
+    var reply = enroll_read_line();
+    if(reply == null) {
+        return false;
+    }
+    var answer = reply.strip().down();
+    return answer == "y" || answer == "yes";
+}
+
+/** One line from stdin, or null at EOF; FileStream exposes no read_line. */
+private string? enroll_read_line() {
+    var builder = new StringBuilder();
+    var character = stdin.getc();
+    while(character != -1 && character != '\n') {
+        builder.append_c((char)character);
+        character = stdin.getc();
+    }
+    if(character == -1 && builder.len == 0) {
+        return null;
+    }
+    return builder.str;
+}
+
+/** A {@link Usm.Configuration} rendered exactly as it is written to usm.config. */
+private string enroll_render_config(Usm.Configuration config) throws Error {
+    var properties = Usm.Configuration.get_mapper().map_from(config);
+    return new JsonElement.from_properties(properties).stringify_pretty();
+}
+
+
+/**
+ * Unified-style line diff (" " shared, "-" old-only, "+" new-only) via
+ * longest common subsequence — both sides are mapper-rendered, so the
+ * diff shows semantic changes rather than formatting churn.
+ */
+private string[] enroll_diff_lines(string old_text, string new_text) {
+    var old_lines = enroll_split_lines(old_text);
+    var new_lines = enroll_split_lines(new_text);
+    var old_count = old_lines.length;
+    var new_count = new_lines.length;
+
+    var table = new int[old_count + 1, new_count + 1];
+    for(var i = old_count - 1; i >= 0; i--) {
+        for(var j = new_count - 1; j >= 0; j--) {
+            table[i, j] = old_lines[i] == new_lines[j]
+                ? table[i + 1, j + 1] + 1
+                : int.max(table[i + 1, j], table[i, j + 1]);
+        }
+    }
+
+    var diff = new Vector<string>();
+    var a = 0;
+    var b = 0;
+    while(a < old_count && b < new_count) {
+        if(old_lines[a] == new_lines[b]) {
+            diff.add(@"  $(old_lines[a])");
+            a++;
+            b++;
+        }
+        else if(table[a + 1, b] >= table[a, b + 1]) {
+            diff.add(@"- $(old_lines[a])");
+            a++;
+        }
+        else {
+            diff.add(@"+ $(new_lines[b])");
+            b++;
+        }
+    }
+    while(a < old_count) {
+        diff.add(@"- $(old_lines[a++])");
+    }
+    while(b < new_count) {
+        diff.add(@"+ $(new_lines[b++])");
+    }
+    return diff.to_array();
+}
+
+private string[] enroll_split_lines(string text) {
+    var lines = text.split("\n", -1);
+    if(lines.length > 0 && lines[lines.length - 1] == "") {
+        lines.resize(lines.length - 1);
+    }
+    return lines;
+}

+ 143 - 0
src/cli/GenConfig.vala

@@ -0,0 +1,143 @@
+using Invercargill.DataStructures;
+using Invercargill.Mapping;
+using InvercargillJson;
+
+public int genconfig_main() {
+    // Create a configuration with is_managed = false and autodiscovered paths
+    var config = new Usm.Configuration();
+    config.is_managed = false;
+    config.managed_config = null;
+    
+    // Start with defaults and autodetect system-specific paths
+    var paths = new Usm.Paths.defaults();
+    
+    // Autodetect the correct lib directory for this system
+    // Uses ldconfig to find the system's primary library directory
+    // canonlib remains "lib" as it's for architecture-independent libraries
+    paths.lib = detect_lib_dir();
+    
+    config.paths = paths;
+
+    // Wire the detected system package manager to its shim; the installer
+    // has already copied the shim to /usr/bin by the time it runs genconfig
+    var spm_shim_name = detect_spm();
+    if (spm_shim_name != null) {
+        var shim_path = Path.build_filename("/", "usr", "bin", @"usm-spm-$spm_shim_name");
+        var query = new Vector<string>();
+        query.add(shim_path);
+        query.add("query");
+        var install = new Vector<string>();
+        install.add(shim_path);
+        install.add("install");
+        config.system_package_manager = new Usm.SystemPackageManagerConfig() {
+            query = query,
+            install = install
+        };
+    }
+
+    // Map to properties and create JSON
+    var properties = Usm.Configuration.get_mapper().map_from(config);
+    var json_element = new JsonElement.from_properties(properties);
+    
+    // Output pretty-printed JSON to stdout
+    print("%s\n", json_element.stringify_pretty());
+    
+    return 0;
+}
+
+private string detect_lib_dir() {
+    try {
+        // Use ldconfig -p to get the library cache and find the primary lib directory
+        // The first entry's path tells us where the system stores its libraries
+        var proc = new Subprocess.newv(
+            new string[] { "ldconfig", "-p" },
+            SubprocessFlags.STDOUT_PIPE
+        );
+        
+        var stdout_pipe = proc.get_stdout_pipe();
+        var data_input = new DataInputStream(stdout_pipe);
+        
+        // Skip the header line (e.g., "3426 libs found in cache `/etc/ld.so.cache'")
+        var header = data_input.read_line();
+        if (header == null) {
+            // Drain remaining output before wait
+            while (data_input.read_line() != null) {}
+            proc.wait();
+            return "lib";
+        }
+        
+        // Read the first library entry to get the path
+        var first_entry = data_input.read_line();
+        
+        // Drain remaining output to avoid deadlock
+        while (data_input.read_line() != null) {}
+        
+        proc.wait();
+        
+        if (first_entry == null) {
+            return "lib";
+        }
+        
+        // Parse the entry: "	libname.so (arch) => /lib64/libname.so"
+        // We want to extract the directory from the path after "=> "
+        var arrow_pos = first_entry.index_of("=> ");
+        if (arrow_pos == -1) {
+            return "lib";
+        }
+        
+        var full_path = first_entry.substring(arrow_pos + 3).strip();
+        // full_path is now "/lib64/lib3mf.so.2"
+        
+        // Find the directory part (everything before the last "/")
+        var last_slash = full_path.last_index_of("/");
+        if (last_slash <= 0) {
+            return "lib";
+        }
+        
+        // Extract directory path (e.g., "/lib64" from "/lib64/lib3mf.so.2")
+        var dir_path = full_path.substring(0, last_slash);
+        // dir_path is now "/lib64"
+        
+        // Get the directory name (e.g., "lib64" from "/lib64")
+        var prev_slash = dir_path.last_index_of("/");
+        var lib_dir = prev_slash >= 0 ? dir_path.substring(prev_slash + 1) : dir_path;
+        
+        // Verify it's actually a lib directory
+        if (!lib_dir.has_prefix("lib")) {
+            return "lib";
+        }
+        
+        return lib_dir;
+    }
+    catch (Error e) {
+        // Fallback to checking for directory existence if ldconfig fails
+        return detect_lib_dir_fallback();
+    }
+}
+
+private string detect_lib_dir_fallback() {
+    var usr_lib64 = File.new_for_path("/usr/lib64");
+    if (usr_lib64.query_exists()) {
+        return "lib64";
+    }
+    
+    // Default fallback
+    return "lib";
+}
+
+/**
+ * Detects the system package manager by which supported executable is on
+ * PATH (dnf, apt-get, apk, emerge, pacman — in that order), returning the
+ * matching usm-spm-<name> shim name or null when no supported manager is
+ * installed (in which case the config omits the system_package_manager
+ * section).
+ */
+private string? detect_spm() {
+    foreach (var executable in new string[] { "dnf", "apt-get", "apk", "emerge", "pacman" }) {
+        if (Environment.find_program_in_path(executable) != null) {
+            // apt-get systems use the "apt" shim; the others share their name
+            return executable == "apt-get" ? "apt" : executable;
+        }
+    }
+    return null;
+}

+ 94 - 0
src/cli/Help.vala

@@ -0,0 +1,94 @@
+namespace Usm.Cli {
+
+    private struct HelpEntry {
+        public string command;
+        public string description;
+    }
+
+    private struct HelpSection {
+        public string title;
+        public HelpEntry[] entries;
+    }
+
+    private static HelpSection[] help_sections() {
+        return {
+        { "INSTALLATION & MANAGEMENT", {
+            { "install <packages>", "Install packages (plan + confirm; -y to skip)" },
+            { "update [packages]", "Update installed packages (or all if none named)" },
+            { "remove <packages>", "Remove packages (cascades + sweeps orphaned deps)" },
+            { "downgrade <pkg> [ver]", "Downgrade to a previous version" },
+            { "rebuild <package>", "Rebuild a package from cached sources" },
+            { "clean [target]", "Clean caches: lists, builds, sources, or all" }
+        } },
+        { "REPOSITORY", {
+            { "repository init <name>", "Initialise a new repository (keys, layout)" },
+            { "repository add <pkg>", "Add a package (.usmc) to this repository" },
+            { "repository remove <name>", "Remove a package from this repository" },
+            { "repository list", "List packages in a repository" },
+            { "repository verify", "Verify repository signature + package checksums" },
+            { "repository publish <dir>", "Publish repository files to a directory" },
+            { "repository build-list", "Rebuild the signed repository listing" },
+            { "add-repo <url>", "Add a remote repository to the configuration" }
+        } },
+        { "SEARCH & QUERY", {
+            { "search <query>", "Search repositories and installed packages" },
+            { "provides <resource>", "Find which package provides a resource" },
+            { "info", "Show installed package information" }
+        } },
+        { "DEPLOYMENT", {
+            { "deploy <pkg|dir>", "Deploy a package as a container image" },
+            { "manifest deploy", "Deploy from a manifest directory" }
+        } },
+        { "MANIFEST & CONFIG", {
+            { "manifest build", "Build a package from its manifest" },
+            { "manifest install", "Install from a manifest directory" },
+            { "manifest package", "Create a .usmc archive" },
+            { "manifest validate", "Validate a manifest" },
+            { "manifest bump [ver]", "Bump version (+ optional --git tag)" },
+            { "manifest acquire", "Run the acquire script" },
+            { "scaffold", "Scaffold a new package" },
+            { "genconfig", "Generate a USM configuration" },
+            { "enroll", "Enroll this system for USM management" }
+        } },
+        { "OPTIONS", {
+            { "-y, --yes", "Skip confirmation prompts" },
+            { "--verbose", "Show detailed output" },
+            { "--spm <name>", "Override system package manager for deploy" },
+            { "--system <name>", "Shorthand for --base + --spm (deploy)" }
+        } }
+        };
+    }
+
+    /**
+     * Prints the grouped command overview — every command with a
+     * one-line description, aligned, category headers in bold cyan when
+     * the destination stream is a terminal and plain text when piped.
+     * {@link to_stderr} selects the stream (and the stream whose
+     * terminal-ness is probed): `usm --help` writes to stdout, misuse
+     * (`usm` with no arguments, unknown commands) to stderr.
+     */
+    public void print_help(bool to_stderr) {
+        var colour = Posix.isatty(to_stderr ? Posix.STDERR_FILENO : Posix.STDOUT_FILENO);
+        unowned var stream = to_stderr ? stderr : stdout;
+        var title_colour = colour ? "\x1b[1m" : "";
+        var header_colour = colour ? "\x1b[1;36m" : "";
+        var reset = colour ? "\x1b[0m" : "";
+
+        var column = 0;
+        foreach(var section in help_sections()) {
+            foreach(var entry in section.entries) {
+                column = int.max(column, entry.command.length);
+            }
+        }
+
+        stream.printf("%sUSM — Universal Source Manifest package manager%s\n\n", title_colour, reset);
+        foreach(var section in help_sections()) {
+            stream.printf("%s%s%s\n", header_colour, section.title, reset);
+            foreach(var entry in section.entries) {
+                stream.printf("  %s%s  %s\n", entry.command, string.nfill(column - entry.command.length, ' '), entry.description);
+            }
+            stream.printf("\n");
+        }
+        stream.printf("Run 'usm <command> --help' for command-specific options.\n");
+    }
+}

+ 344 - 35
src/cli/Install.vala

@@ -3,55 +3,364 @@ using Invercargill.DataStructures;
 
 private int install_main(string[] args) {
 
-    if(args.length < 3) {
+    var assume_yes = false;
+    var package_names = new Vector<string>();
+    for(int i = 2; i < args.length; i++) {
+        var argument = args[i];
+        if(argument == "-y" || argument == "--yes") {
+            assume_yes = true;
+        }
+        else if(argument.has_prefix("-")) {
+            printerr(@"Unknown option \"$argument\"\n");
+            return install_usage();
+        }
+        else {
+            package_names.add(argument);
+        }
+    }
+    if(package_names.length == 0) {
         return install_usage();
     }
 
-    var state = new Usm.SystemState(paths);
-    
-    printerr("Refreshing repositories...\n");
-    var resolver = new Usm.Resolver();
-    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);
+    var progress = new Usm.Cli.ProgressBar();
+
+    Usm.SystemState state = null;
+    try {
+        state = new Usm.SystemState(paths);
+    }
+    catch(Error e) {
+        printerr(@"This system is not managed by usm: $(e.message)\n");
+        return 245;
+    }
+
+    try {
+        var local_finder = new Usm.ResourceFinder();
+        var resolver = new Usm.Resolver(local_finder);
+        var repos = state.get_repositories();
+        foreach (var repo in repos) {
+            progress.begin_indeterminate(@"Refreshing $(repo.name)");
+            state.refresh_list(repo, (f, c, t) => {
+                if(t > 0) {
+                    if(progress.phase_is_indeterminate) {
+                        progress.begin_phase(@"Refreshing $(repo.name)");
+                    }
+                    progress.update_phase((float)c / (float)t);
+                }
+            });
+            progress.phase_log(@"Refreshed $(repo.name) listing");
+            progress.end_phase();
+            var listing = state.get_latest_list(repo);
+            if(listing != null) {
+                resolver.load_listing(repo, listing);
+            }
+        }
+
+        // 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>();
+        foreach(var package_name in package_names) {
+            var target = resolver.find_package(package_name);
+            if(target == null) {
+                printerr(@"No package named \"$package_name\" 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);
+        progress.begin_indeterminate("Querying system packages");
+        bool spm_done = false;
+        resolver.resolution_progress = (fraction) => {
+            if(!spm_done) {
+                spm_done = true;
+                progress.begin_phase("Resolving dependencies");
+            }
+            progress.update_phase(fraction);
+        };
+        Usm.ResolutionResult resolution;
+        try {
+            resolution = resolver.resolve(roots, spm);
+        }
+        catch(Usm.ResolverError e) {
+            progress.end_phase();
+            printerr("%s\n".printf(e.message));
+            return 240;
+        }
+        // The bar erases here; the transaction plan is the next display
+        progress.end_phase();
+
+        // Build the transaction set WITHOUT downloading: repository
+        // packages join as their (possibly not-yet-existing) cache
+        // directories carrying the listing manifest, so the plan can be
+        // confirmed before a single byte moves. Cache-supplied packages
+        // are used in place from their cache directories.
+        var cached_packages = new HashSet<Usm.CachedPackage>();
+        uint download_count = 0;
+        int64 download_bytes = 0;
+        var download_sizes_known = true;
+        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 path = state.generate_cache_path(package.manifest);
+            cached_packages.add(new Usm.CachedPackage(path) {
+                known_manifest = package.manifest
+            });
+            if(File.new_for_path(Path.build_filename(path, "package.usmc")).query_exists()) {
+                continue;
+            }
+            download_count++;
+            var size = install_package_download_size(package.repository, package.repository_entry);
+            if(size == null) {
+                download_sizes_known = false;
+            }
+            else {
+                download_bytes += size;
+            }
+        }
+
+        var install_order = new Vector<string>();
+        foreach(var package in resolution.install_order) {
+            install_order.add(package.manifest.name);
+        }
+
+        // Resources the chosen system packages will install before this
+        // transaction's first lot runs; the scheduler counts them as
+        // present so system-provided build dependencies do not look unmet
+        // at planning time
+        var preinstalled_resources = new HashSet<Usm.ResourceRef>();
+        foreach(var system_package in resolution.system_packages) {
+            foreach(var resource in system_package.resources) {
+                preinstalled_resources.add(resource);
+            }
+        }
+
+        // The material system install set — chosen packages plus every
+        // native dependency of a dependency — so the plan preview lists
+        // the whole native transaction and every material package gets
+        // its own progress action
+        var system_install_plan = new Vector<string>();
+        if(spm.enabled && resolution.system_packages.any()) {
+            var chosen_system_names = new Vector<string>();
+            foreach(var system_package in resolution.system_packages) {
+                chosen_system_names.add(system_package.name);
+            }
+            system_install_plan = spm.plan_install(chosen_system_names);
+        }
+        var chosen_system_names_set = new HashSet<string>();
+        foreach(var system_package in resolution.system_packages) {
+            chosen_system_names_set.add(system_package.name);
+        }
+        var system_dependencies = new Vector<string>();
+        foreach(var name in system_install_plan) {
+            if(!chosen_system_names_set.contains(name)) {
+                system_dependencies.add(name);
+            }
+        }
+
+        var transaction = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(paths),
+            preinstalled_resources = preinstalled_resources,
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            to_install = cached_packages,
+            install_order = install_order,
+            explicit_packages = package_names,
+            state = state,
+            system_package_manager = spm,
+            system_packages = resolution.system_packages,
+            system_install_plan = system_install_plan
+        };
+
+        // Planning before the prompt populates the rebuildDependants
+        // entries so the summary shows the whole transaction
+        try {
+            transaction.strategise();
+        }
+        catch(Usm.TransactionError e) {
+            printerr(@"$(e.message)\n");
+            return 240;
+        }
+
+        var summary = new Usm.TransactionSummary() {
+            to_install = cached_packages.to_vector(),
+            system_installs = resolution.system_packages,
+            system_dependencies = system_dependencies,
+            to_rebuild = transaction.rebuilds,
+            download_count = download_count,
+            download_bytes = download_sizes_known ? download_bytes : -1
+        };
+        if(!Usm.Cli.confirm_transaction(summary, assume_yes)) {
+            print("Aborted.\n");
+            return 1;
+        }
+
+        // Only now that the plan is confirmed do the packages transfer
+        install_download_packages(state, resolution, new HashSet<string>(), progress);
+
+        printerr("\nRunning transaction...\n");
+        transaction.progress_updated.connect(progress.on_transaction_progress);
+        transaction.run();
+        progress.finish();
+
+
+        // todo cleanup
+
+    }
+    catch(Error e) {
+        progress.fail();
+        printerr(@"Error: $(e.message)\n");
+        return 238;
+    }
+
+    return 0;
+}
+
+/**
+ * Downloads every repository-backed package of {@link resolution}'s
+ * install order that is not already cached — creating its cache
+ * directory, downloading and checksum-verifying the .usmc. Names in
+ * {@link skip_names} are left alone entirely (update uses this for
+ * dependencies already installed at the resolved version); a cached
+ * package.usmc is never re-transferred. Called only after the plan is
+ * confirmed, so a decline downloads nothing.
+ *
+ * With a {@link progress} bar every transfer shares one grouped
+ * display: overall progress is `(completed downloads + current
+ * download fraction) / total downloads`, the client's byte-level
+ * callback feeding the current fraction, and each finished package
+ * logs a `✓ Downloaded <name> (<size>)` line above the bar. Without
+ * one, each transfer prints its own byte-level line (the
+ * pre-grouping behaviour, kept for callers without a bar).
+ */
+private void install_download_packages(Usm.SystemState state, Usm.ResolutionResult resolution, HashSet<string> skip_names, Usm.Cli.ProgressBar? progress = null) throws Error {
+    uint total_downloads = 0;
+    if(progress != null) {
+        foreach(var package in resolution.install_order) {
+            if(install_needs_download(state, package, skip_names)) {
+                total_downloads++;
+            }
+        }
+        if(total_downloads > 0) {
+            progress.begin_phase("Downloading packages");
         }
-        printerr("\n");
     }
 
-    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();
+    uint completed_downloads = 0;
+    foreach(var package in resolution.install_order) {
+        if(package.repository == null || skip_names.contains(package.manifest.name)) {
+            continue;
+        }
+        var path = state.generate_cache_path(package.manifest);
+        if(File.new_for_path(Path.build_filename(path, "package.usmc")).query_exists()) {
+            continue;
+        }
+        var client = package.repository.get_client(state.config);
+        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, 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");
+        if(progress == null) {
+            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"));
+            printerr("\n");
+            continue;
+        }
+        client.download_package(package_path, package.repository_entry, (f, c, t) => {
+            var fraction = t > 0 ? (float)((double)c / (double)t) : 0.0f;
+            progress.update_phase(((float)completed_downloads + fraction) / (float)total_downloads);
+        });
+        client.verify_package(package_path, package.repository_entry);
+        completed_downloads++;
+        var size = File.new_for_path(package_path).query_info(FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NONE).get_size();
+        progress.phase_log(@"Downloaded $(Path.get_basename(package.repository_entry.path)) ($(install_format_size(size)))");
     }
 
-    var transaction = new Usm.Transaction() {
-        paths = paths,
-        to_remove = new HashSet<Usm.CachedPackage>(),
-        to_install = cached_packages,
-        state = state
-    };
+    if(progress != null && total_downloads > 0) {
+        progress.end_phase();
+    }
+}
 
-    printerr("\nRunning transaction...\n");
-    transaction.progress_updated.connect(transaction.print_progress);
-    transaction.run();
+/**
+ * Whether {@link package} still needs its .usmc transferred: a
+ * repository-backed package neither skipped by {@link skip_names} nor
+ * already sitting in its cache directory.
+ */
+private bool install_needs_download(Usm.SystemState state, Usm.AbstractPackage package, HashSet<string> skip_names) {
+    if(package.repository == null || skip_names.contains(package.manifest.name)) {
+        return false;
+    }
+    var path = state.generate_cache_path(package.manifest);
+    return !File.new_for_path(Path.build_filename(path, "package.usmc")).query_exists();
+}
 
+/**
+ * Human-readable byte count for download log lines — `388 B`,
+ * `1.2 MiB` — climbing binary multiples so the figure matches what
+ * ls/du report for the same file.
+ */
+private string install_format_size(int64 bytes) {
+    var size = (double)bytes;
+    var unit = "B";
+    foreach(var next in new string[] { "KiB", "MiB", "GiB", "TiB" }) {
+        if(size < 1024.0) {
+            break;
+        }
+        size /= 1024.0;
+        unit = next;
+    }
+    return unit == "B" ? @"$bytes B" : "%.1f %s".printf(size, unit);
+}
 
-    // todo cleanup
+/**
+ * The download size in bytes of {@link entry} from {@link repository},
+ * or null when it cannot be determined: the size recorded in the
+ * listing is authoritative, while listings built before the field
+ * existed (size 0) fall back to probing — `file` repositories stat the
+ * package, remote ones read Content-Length off a curl HEAD request (the
+ * last header wins, so redirects resolve to the final transfer). Used
+ * to show a plan's total download size before any transfer starts.
+ */
+private int64? install_package_download_size(Usm.Repository repository, Usm.RepositoryListingEntry entry) {
+    if(entry.size > 0) {
+        return (int64)entry.size;
+    }
+    var uri = repository.url.has_suffix("/") ? repository.url + entry.path : repository.url + "/" + entry.path;
+    try {
+        var scheme = Uri.parse(uri, UriFlags.NONE).get_scheme();
+        if(scheme == "file") {
+            return File.new_for_uri(uri).query_info(FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NONE).get_size();
+        }
 
-    return 0;
+        var proc = new Subprocess.newv(new string[] { "curl", "-sIL", "-m", "10", uri }, SubprocessFlags.STDOUT_PIPE);
+        var output = new DataInputStream(proc.get_stdout_pipe());
+        string? size_text = null;
+        string line;
+        while((line = output.read_line()) != null) {
+            if(line.down().has_prefix("content-length:")) {
+                size_text = line.split(":", 2)[1].strip();
+            }
+        }
+        proc.wait();
+        if(proc.get_successful() && size_text != null) {
+            return int64.parse(size_text);
+        }
+    }
+    catch(Error e) {
+        return null;
+    }
+    return null;
 }
 
 private int install_usage() {
-    printerr("USAGE:\n\tusm install <packages>\n");
+    printerr("USAGE:\n\tusm install [-y|--yes] <packages>\n");
     return 255;
-}
+}

+ 794 - 117
src/cli/Manifest.vala

@@ -1,9 +1,17 @@
 Usm.Manifest manifest = null;
 string? build_path = null;
+bool replace_provides = false;
+bool debug_mode = false;
+Usm.ResourceFinder resfinder = null;
+Usm.ResourceFinder destresfinder = null;
 
 
 public static int manifest_main(string[] args) {
-    paths = new Usm.Paths.usm_environ();
+    paths = new Usm.Paths();
+    var resfinder_paths = paths.clone();
+    resfinder_paths.destination = "/";
+    resfinder = new Usm.ResourceFinder(resfinder_paths);
+    destresfinder = new Usm.ResourceFinder(paths);
 
     if(args.length < 2) {
         manifest_usage();
@@ -12,7 +20,29 @@ public static int manifest_main(string[] args) {
 
     var verb = args[1];
 
-    if(verb != "remove" && verb != "acquire" && verb != "install" && verb != "package" && verb != "autoprovides") {
+    // bump rewrites MANIFEST.usm's own version/flags as raw JSON and drives
+    // git tagging, so it dispatches before the mapped-manifest machinery
+    if(verb == "bump") {
+        return manifest_bump(args);
+    }
+
+    // Check for --replace and --debug flags for autoprovides
+    if(verb == "autoprovides") {
+        for(int i = 2; i < args.length; i++) {
+            if(args[i] == "--replace") {
+                replace_provides = true;
+            } else if(args[i] == "--debug") {
+                debug_mode = true;
+            } else if(build_path == null) {
+                build_path = args[i];
+            }
+        }
+    } 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;
@@ -28,17 +58,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;
     }
 
-    if(verb == "build") {
-        return build();
+    // 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;
     }
 
-    if(verb == "install" || verb == "autoprovides") {
+    // Automatically create a temporary directory for these commands if one was not provided.
+    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 {
@@ -52,6 +87,25 @@ public static int manifest_main(string[] args) {
         }
     }
 
+    // Automatically create the specified directory for these commands if it doesn't exist
+    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");
+            try {
+                build_dir.make_directory_with_parents();
+            }
+            catch(Error e) {
+                printerr(@"Could not create build directory: $(e.message)\n");
+                return 255;
+            }
+        }
+    }
+
+    if(verb == "build") {
+        return build();
+    }
+
     if(verb == "install") {
         return install();
     }
@@ -68,37 +122,84 @@ public static int manifest_main(string[] args) {
         return package();
     }
 
+    if(verb == "deploy") {
+        return manifest_deploy(args);
+    }
+
     if(verb == "autoprovides") {
         return autoprovides();
     }
 
+    if(verb == "test") {
+        return test();
+    }
+
+    if(verb == "validate") {
+        return validate();
+    }
 
     manifest_usage();
     return 255;
 }
 
 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\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]\nusm manifest bump [major|minor|patch|<version>] [--git] [--push] [--rebuild-dependants] [--no-rebuild-dependants]\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;
+    }
 
-private int build() {
-    var missing_management_deps = manifest.dependencies.manage.where(d => !d.is_satisfied());
-    var missing_build_deps = manifest.dependencies.build.where(d => !d.is_satisfied());
+    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;
+    }
 
-    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");
@@ -118,41 +219,41 @@ private int build() {
 }
 
 private int install() {
-    var missing_management_deps = manifest.dependencies.manage.where(d => !d.is_satisfied());
-    var missing_build_deps = manifest.dependencies.build.where(d => !d.is_satisfied());
-    var missing_runtime_deps = manifest.dependencies.runtime.where(d => !d.is_satisfied());
-
-    var sane = true;
-    if(missing_management_deps.any()) {
-        sane = false;
-        foreach (var item in missing_management_deps) {
-            printerr(@"Missing management dependency \"$item\".\n");
-        }
+    // 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();
     }
-    if(missing_build_deps.any()) {
-        sane = false;
-        foreach (var item in missing_build_deps) {
-            printerr(@"Missing build dependency \"$item\".\n");
+
+    // Create build directory if it doesn't exist
+    var build_dir = File.new_for_path(build_path);
+    if(!build_dir.query_exists()) {
+        printerr(@"Creating build directory: $build_path\n");
+        try {
+            build_dir.make_directory_with_parents();
         }
-    }
-    if(missing_runtime_deps.any()) {
-        sane = false;
-        foreach (var item in missing_runtime_deps) {
-            printerr(@"Missing runtime dependency \"$item\".\n");
+        catch(Error e) {
+            printerr(@"Could not create build directory: $(e.message)\n");
+            return 255;
         }
     }
 
+    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");
         return 252;
     }
 
     try {
-        var proc = manifest.run_build(build_path, paths, SubprocessFlags.INHERIT_FDS);
+        var proc = manifest.run_build(build_path, paths, SubprocessFlags.INHERIT_FDS, frac => printerr(@"Building '$(manifest.name)': $((int)(frac*100))%\r"));
         proc.wait_check();
+        printerr("\n");
     }
     catch(Error e) {
-        printerr(@"Error running build exec: $(e.message)\n");
+        printerr(@"\nError running build exec: $(e.message)\n");
         return 251;
     }
 
@@ -205,22 +306,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 => !d.is_satisfied());
-        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 {
@@ -236,13 +357,7 @@ private int acquire() {
 }
 
 private int uininstall() {
-    var missing_management_deps = manifest.dependencies.manage.where(d => !d.is_satisfied());
-
-    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;
     }
@@ -260,7 +375,7 @@ private int uininstall() {
 
     // Do files and symlinks first
     foreach (var resource in manifest.provides.where(r => r.value.file_type != Usm.ManifestFileType.DIRECTORY)) {
-        var path = paths.get_suggested_path(resource.key);
+        var path = paths.get_suggested_path_for_resource(resource.key);
         printerr(@"Deleting resource $(resource.key) from $path...");
         try {
             var file = File.new_for_path(path);
@@ -280,7 +395,7 @@ private int uininstall() {
 
     // Do directories last
     foreach (var resource in manifest.provides.where(r => r.value.file_type == Usm.ManifestFileType.DIRECTORY)) {
-        var path = paths.get_suggested_path(resource.key);
+        var path = paths.get_suggested_path_for_resource(resource.key);
         printerr(@"Deleting resource $(resource.key) from $path...");
         try {
             var file = File.new_for_path(path);
@@ -305,23 +420,35 @@ private int uininstall() {
 }
 
 private int package() {
-    if(manifest.executables.acquire != null) {
-        printerr("Running acquire...\n");
-        var result = acquire();
-        if(result != 0) {
-            printerr(@"Acquire failed, code: $(result)\n");
-            return result;
-        }
-    }
-    else {
-        printerr("Warning: Manifest does not specify an acquire script, assuming we have everything we need already and packaging anyway.\n");
-    }
-
     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;
     }
@@ -331,47 +458,80 @@ private int package() {
     }
 }
 
-private int autoprovides() {
-    var missing_management_deps = manifest.dependencies.manage.where(d => !d.is_satisfied());
-    var missing_build_deps = manifest.dependencies.build.where(d => !d.is_satisfied());
-    var missing_runtime_deps = manifest.dependencies.runtime.where(d => !d.is_satisfied());
+/**
+ * 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);
 
-    if(manifest.executables.install == null) {
-        printerr(@"Autoprovides command only works with manifests that specify an installation executable.\n");
-        return 1;
-    }
+    while(true) {
+        FileInfo? info = enumerator.next_file();
+        if(info == null) {
+            break;
+        }
 
-    var sane = true;
-    if(missing_management_deps.any()) {
-        sane = false;
-        foreach (var item in missing_management_deps) {
-            printerr(@"Missing management dependency \"$item\".\n");
+        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;
         }
-    }
-    if(missing_build_deps.any()) {
-        sane = false;
-        foreach (var item in missing_build_deps) {
-            printerr(@"Missing build dependency \"$item\".\n");
+        entries.add(relative_path);
+        if(is_directory) {
+            package_collect(relative_path, ignore, entries);
         }
     }
-    if(missing_runtime_deps.any()) {
-        sane = false;
-        foreach (var item in missing_runtime_deps) {
-            printerr(@"Missing runtime dependency \"$item\".\n");
+}
+
+private int autoprovides() {
+    // Create build directory if it doesn't exist
+    var build_dir = File.new_for_path(build_path);
+    if(!build_dir.query_exists()) {
+        printerr(@"Creating build directory: $build_path\n");
+        try {
+            build_dir.make_directory_with_parents();
+        }
+        catch(Error e) {
+            printerr(@"Could not create build directory: $(e.message)\n");
+            return 255;
         }
     }
 
+    if(manifest.executables.install == null) {
+        printerr(@"Autoprovides command only works with manifests that specify an installation executable.\n");
+        return 1;
+    }
+
+    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");
         return 252;
     }
 
     try {
-        var proc = manifest.run_build(build_path, paths, SubprocessFlags.STDOUT_SILENCE);
+        Usm.ProgressDelegate? progress_callback = null;
+        SubprocessFlags build_flags = SubprocessFlags.STDOUT_SILENCE;
+        
+        if (!debug_mode) {
+            progress_callback = frac => printerr(@"Building '$(manifest.name)': $((int)(frac*100))%\r");
+        } else {
+            build_flags = SubprocessFlags.INHERIT_FDS;
+        }
+        
+        var proc = manifest.run_build(build_path, paths, build_flags, progress_callback);
         proc.wait_check();
+        
+        if (!debug_mode) {
+            printerr("\n");
+        }
     }
     catch(Error e) {
-        printerr(@"Error running build exec: $(e.message)\n");
+        printerr(@"\nError running build exec: $(e.message)\n");
         return 251;
     }
 
@@ -385,25 +545,63 @@ private int autoprovides() {
     }
 
     try {
-        manifest.run_install(build_path, install_dir.get_path(), paths, Usm.InstallType.FRESH, SubprocessFlags.STDOUT_SILENCE).wait_check();
+        SubprocessFlags install_flags = debug_mode ? SubprocessFlags.INHERIT_FDS : SubprocessFlags.STDOUT_SILENCE;
+        manifest.run_install(build_path, install_dir.get_path(), paths, Usm.InstallType.FRESH, install_flags).wait_check();
     }
     catch(Error e) {
-        printerr(@"Error running install exec: $(e.message)\n");
+        printerr(@"Error running install exec: $(e.message)\n"
+);
         return 249;
     }
 
     try {
         var provides = autoprovides_scan_tree(install_dir.get_path(), install_dir.get_path());
 
-        var json = "\"provides\": {\n";
-        foreach (var item in provides) {
-            json += @"    \"$item\": \"as-expected\",\n";
-        }
-        if(provides.any()) {
-            json = json.substring(0, json.length - 2);
+        // Create a JSON representation of the provides dictionary
+        var json_object = new InvercargillJson.JsonObject();
+        foreach (var entry in provides) {
+            // Handle special case: when pathBase is as-expected, path is empty, and type is reg
+            // output string "as-expected" instead of full JSON object
+            if(entry.value.path_base == Usm.ManifestFilePathBase.AS_EXPECTED &&
+               entry.value.path == "" &&
+               entry.value.file_type == Usm.ManifestFileType.REGULAR) {
+                json_object.set_native(entry.key.to_string(), "as-expected");
+            } else {
+                var mapper = Usm.ManifestFile.get_mapper();
+                json_object.set_native(entry.key.to_string(), mapper.map_from(entry.value));
+            }
         }
+        
+        var json_string = json_object.as_element().stringify_pretty();
 
-        print(@"$json\n}\n");
+        if(replace_provides) {
+            try {
+                // Update the manifest's provides property
+                manifest.provides = provides;
+                
+                // Serialize the updated manifest back to JSON
+                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_pretty();
+                
+                // Write the updated manifest to file
+                var file = File.new_for_path("MANIFEST.usm");
+                var data_stream = new DataOutputStream(file.replace(null, false, FileCreateFlags.REPLACE_DESTINATION));
+                data_stream.put_string(manifest_json_string);
+                
+                printerr("Successfully updated 'provides' property in MANIFEST.usm\n");
+            }
+            catch(Error e) {
+                printerr(@"Error updating MANIFEST.usm: $(e.message)\n");
+                printerr("Original file left unchanged. Generated provides:\n");
+                print(@"$json_string\n");
+                return 249;
+            }
+        } else {
+            print(@"$json_string\n");
+        }
+        
         return 0;
     }
     catch(Error e) {
@@ -412,14 +610,14 @@ private int autoprovides() {
     }
 }
 
-public static Invercargill.DataStructures.Vector<string> autoprovides_scan_tree(string install_root, string path) throws Error {
+public static Invercargill.DataStructures.Dictionary<Usm.ResourceRef, Usm.ManifestFile> autoprovides_scan_tree(string install_root, string path) throws Error {
     // Creating a new Directory object for the given folder path
     var folder = File.new_for_path(path);
 
     // Getting a list of all files and folders inside the directory
-    var enumerator = folder.enumerate_children("*", FileQueryInfoFlags.NONE);
+    var enumerator = folder.enumerate_children("*", FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
 
-    var provides = new Invercargill.DataStructures.Vector<string>();
+    var provides = new Invercargill.DataStructures.Dictionary<Usm.ResourceRef, Usm.ManifestFile>();
 
     // Looping through each file/folder and removing them
     while (true) {
@@ -430,24 +628,503 @@ public static Invercargill.DataStructures.Vector<string> autoprovides_scan_tree(
 
         // Checking if the current item is a file or a folder
         if (info.get_file_type() == FileType.REGULAR) {
-            // Removing the file
             var file = folder.get_child(info.get_name());
             var true_path = file.get_path().substring(install_root.length);
-            var type = paths.get_suggested_resource_type(true_path);
-            if(type == Usm.ResourceType.ROOT_PATH) {
-                provides.add("rootpath:" + true_path);
+            var type = guess_resource_type(true_path);
+            
+            var relative_path = get_relative_path_for_type(true_path, type.to_string());
+            // Remove leading slash from relative_path if present
+            if (relative_path.has_prefix("/")) {
+                relative_path = relative_path.substring(1);
             }
-            else {
-                provides.add(@"$type:$(file.get_basename())");
+            
+            // Create ResourceRef and ManifestFile
+            var resource_ref = new Usm.ResourceRef.with_type(type, relative_path);
+            var manifest_file = new Usm.ManifestFile.from_string("as-expected");
+            provides[resource_ref] = manifest_file;
+        }
+        else if (info.get_file_type() == FileType.SYMBOLIC_LINK) {
+            var file = folder.get_child(info.get_name());
+            var true_path = file.get_path().substring(install_root.length);
+            var type = guess_resource_type(true_path);
+            
+            var relative_path = get_relative_path_for_type(true_path, type.to_string());
+            // Remove leading slash from relative_path if present
+            if (relative_path.has_prefix("/")) {
+                relative_path = relative_path.substring(1);
             }
-
+            
+            // Create ResourceRef and ManifestFile
+            var resource_ref = new Usm.ResourceRef.with_type(type, relative_path);
+            var manifest_file = new Usm.ManifestFile() {
+                path_base = Usm.ManifestFilePathBase.AS_EXPECTED,
+                file_type = Usm.ManifestFileType.SYMBOLIC_LINK,
+                destination = info.get_symlink_target()
+            };
+            provides[resource_ref] = manifest_file;
         }
         else if (info.get_file_type() == FileType.DIRECTORY) {
-            // Removing the folder recursively
             var subfolder = folder.get_child(info.get_name());
-            provides.add_all(autoprovides_scan_tree(install_root, subfolder.get_path()));
+            var sub_provides = autoprovides_scan_tree(install_root, subfolder.get_path());
+            // Add all entries from subdirectory
+            foreach (var entry in sub_provides) {
+                provides[entry.key] = entry.value;
+            }
         }
     }
     
     return provides;
+}
+
+private static Usm.ResourceType guess_resource_type(string path) {
+    // Remove leading slash if present
+    string clean_path = path.has_prefix("/") ? path.substring(1) : path;
+    
+    // Create a temporary Paths object to get the default paths
+    var default_paths = new Usm.Paths();
+    
+    // Find all matching resource types and calculate directory traversals
+    var matching_types = new Invercargill.DataStructures.Vector<Usm.ResourceType>();
+    var traversal_counts = new Invercargill.DataStructures.Vector<int>();
+    
+    // Define all resource types to check
+    var types_to_check = new Invercargill.Composition<Usm.ResourceType>();
+    types_to_check.append(Invercargill.Iterate.these<Usm.ResourceType>(
+        Usm.ResourceType.BINARY,
+        Usm.ResourceType.SUPER_BINARY,
+        Usm.ResourceType.LIBRARY,
+        Usm.ResourceType.LIBRARY_RESOURCE,
+        Usm.ResourceType.LIBRARY_EXECUTABLE,
+        Usm.ResourceType.INCLUDE,
+        Usm.ResourceType.APPLICATION,
+        Usm.ResourceType.INFO_PAGE,
+        Usm.ResourceType.MANUAL_PAGE,
+        Usm.ResourceType.LOCALE,
+        Usm.ResourceType.VALA_API,
+        Usm.ResourceType.GOBJECT_IR,
+        Usm.ResourceType.TAG,
+        Usm.ResourceType.RESOURCE,
+        Usm.ResourceType.PKG_CONFIG,
+        Usm.ResourceType.TYPELIB,
+        Usm.ResourceType.CONFIGURATION,
+        Usm.ResourceType.OPTIONAL
+    ));
+
+    // If the canonlib directory is different to the regular library path, add the canonical checks
+    if(paths.canonlib != paths.lib) {
+        types_to_check.append(Invercargill.Iterate.these<Usm.ResourceType>(
+            Usm.ResourceType.CANONICAL_LIBRARY,
+            Usm.ResourceType.CANONICAL_LIBRARY_RESOURCE
+        ));
+    }
+    
+    foreach (var type in types_to_check) {
+        var suggested_path = default_paths.get_suggested_base_path_for_type(type);
+        // Remove the destination prefix (usually "/") to get the relative path
+        var relative_suggested_path = suggested_path.has_prefix("/") ?
+            suggested_path.substring(1) : suggested_path;
+        
+        if (clean_path.has_prefix(relative_suggested_path)) {
+            var relative_path = clean_path.substring(relative_suggested_path.length);
+            // Count slashes (directory traversals) in the relative path
+            int traversal_count = 0;
+            for (int j = 0; j < relative_path.length; j++) {
+                if (relative_path[j] == '/') {
+                    traversal_count++;
+                }
+            }
+            
+            // Library and Library resource have the same path. Try to have actual libraries get 'lib:' type, and all others 'libres:'
+            var basename = Path.get_basename(path);
+            var is_library = basename.has_suffix(".so") || basename.contains(".so.") || basename.has_suffix(".a") || basename.has_suffix(".o");
+            if(!is_library && (type == Usm.ResourceType.LIBRARY || type == Usm.ResourceType.CANONICAL_LIBRARY)) {
+                continue;
+            }
+            if(is_library && (type == Usm.ResourceType.LIBRARY_RESOURCE || type == Usm.ResourceType.CANONICAL_LIBRARY_RESOURCE)) {
+                continue;
+            }
+            
+            matching_types.add(type);
+            traversal_counts.add(traversal_count);
+        }
+    }
+    
+    // If no matches found, default to "rootpath"
+    if (matching_types.peek_count() == 0) {
+        return Usm.ResourceType.ROOT_PATH;
+    }
+    
+    // Find the type with the fewest directory traversals
+    int min_traversals = traversal_counts[0];
+    Usm.ResourceType best_type = matching_types[0];
+    
+    for (int i = 1; i < traversal_counts.peek_count(); i++) {
+        if (traversal_counts[i] < min_traversals) {
+            min_traversals = traversal_counts[i];
+            best_type = matching_types[i];
+        }
+    }
+    
+    return best_type;
+}
+
+private static string get_relative_path_for_type(string path, string type) {
+    // Remove leading slash if present
+    string clean_path = path.has_prefix("/") ? path.substring(1) : path;
+    
+    // Create a temporary Paths object to get the default paths
+    var default_paths = new Usm.Paths();
+    
+    try {
+        // Convert string type to ResourceType enum
+        var resource_type = Usm.ResourceType.from_string(type);
+        var suggested_path = default_paths.get_suggested_base_path_for_type(resource_type);
+        // Remove the destination prefix (usually "/") to get the relative path
+        var relative_suggested_path = suggested_path.has_prefix("/") ?
+            suggested_path.substring(1) : suggested_path;
+        
+        if (clean_path.has_prefix(relative_suggested_path)) {
+            return clean_path.substring(relative_suggested_path.length);
+        }
+    } catch (Error e) {
+        // Type not found, fall through to default case
+    }
+    
+    // Default case - return the path as-is
+    return clean_path;
+}
+
+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 {
+            dir.make_directory();
+        }
+        catch(Error e) {
+            printerr(@"Could not create temporary build directory, try specifying one instead: $(e.message)\n");
+            return 255;
+        }
+        build_path = dir.get_path();
+    } else {
+        // Create build directory if it doesn't exist
+        var build_dir = File.new_for_path(build_path);
+        if(!build_dir.query_exists()) {
+            printerr(@"Creating build directory: $build_path\n");
+            try {
+                build_dir.make_directory_with_parents();
+            }
+            catch(Error e) {
+                printerr(@"Could not create build directory: $(e.message)\n");
+                return 255;
+            }
+        }
+    }
+
+    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");
+        return 252;
+    }
+
+    try {
+        // First build the package
+        var build_proc = manifest.run_build(build_path, paths, SubprocessFlags.INHERIT_FDS, frac => printerr(@"Building '$(manifest.name)': $((int)(frac*100))%\r"));
+        build_proc.wait_check();
+        printerr("\n");
+    }
+    catch(Error e) {
+        printerr(@"\nError running build exec: $(e.message)\n");
+        return 251;
+    }
+
+    if(manifest.executables.test == null) {
+        printerr(@"Manifest does not reference a test script\n");
+        return 248;
+    }
+
+    try {
+        var test_proc = manifest.run_test(build_path, SubprocessFlags.INHERIT_FDS);
+        test_proc.wait_check();
+    }
+    catch(Error e) {
+        printerr(@"Error running test exec: $(e.message)\n");
+        return 251;
+    }
+
+    return 0;
+}
+
+private int validate() {
+    if(build_path == null) {
+        var dir = File.new_build_filename("/tmp", Uuid.string_random());
+        try {
+            dir.make_directory();
+        }
+        catch(Error e) {
+            printerr(@"Could not create temporary build directory, try specifying one instead: $(e.message)\n");
+            return 255;
+        }
+        build_path = dir.get_path();
+    } else {
+        // Create build directory if it doesn't exist
+        var build_dir = File.new_for_path(build_path);
+        if(!build_dir.query_exists()) {
+            printerr(@"Creating build directory: $build_path\n");
+            try {
+                build_dir.make_directory_with_parents();
+            }
+            catch(Error e) {
+                printerr(@"Could not create build directory: $(e.message)\n");
+                return 255;
+            }
+        }
+    }
+
+    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");
+        return 252;
+    }
+
+    // Check for license
+    if(manifest.licences == null || manifest.licences.count() == 0) {
+        printerr("Warning: No licence has been provided in the manifest.\n");
+    }
+
+    // Check for markdown
+    if(manifest.markdown_path == null) {
+        printerr("Warning: No markdown description has been provided in the manifest.\n");
+    }
+
+    // Check for url
+    if(manifest.url == null) {
+        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"));
+        build_proc.wait_check();
+        printerr("\n");
+    }
+    catch(Error e) {
+        printerr(@"\nError running build exec: $(e.message)\n");
+        return 251;
+    }
+
+    var install_dir = File.new_build_filename("/tmp", Uuid.string_random());
+    if(manifest.executables.install != null) {
+        try {
+            install_dir.make_directory();
+        }
+        catch(Error e) {
+            printerr(@"Could not create temporary install directory: $(e.message)\n");
+            return 250;
+        }
+
+        try {
+            manifest.run_install(build_path, install_dir.get_path(), paths, Usm.InstallType.FRESH, SubprocessFlags.STDOUT_SILENCE).wait_check();
+        }
+        catch(Error e) {
+            printerr(@"Error running install exec: $(e.message)\n");
+            return 249;
+        }
+    }
+    else {
+        printerr("Warning: No install script specified, cannot validate installation.\n");
+        return 0;
+    }
+
+    // Validate each expected resource exists in its expected location
+    var missing_resources = new Invercargill.DataStructures.Vector<Usm.ResourceRef>();
+    foreach (var expected in manifest.provides) {
+        bool found = false;
+        string expected_path = "";
+        
+        try {
+            switch(expected.value.path_base) {
+                case Usm.ManifestFilePathBase.AS_EXPECTED:
+                    // For as-expected, check in install directory at suggested path
+                    var install_paths = paths.clone();
+                    install_paths.destination = install_dir.get_path();
+                    expected_path = install_paths.get_suggested_path_for_resource(expected.key);
+                    var file = File.new_for_path(expected_path);
+                    found = file.query_exists();
+                    break;
+                    
+                case Usm.ManifestFilePathBase.BUILD:
+                    // For build:, check in build directory at specified path
+                    expected_path = Path.build_filename(build_path, expected.value.path ?? "");
+                    var file = File.new_for_path(expected_path);
+                    found = file.query_exists();
+                    break;
+                    
+                case Usm.ManifestFilePathBase.SOURCE:
+                    // For source:, check in source directory at specified path
+                    expected_path = Path.build_filename(Environment.get_current_dir(), expected.value.path ?? "");
+                    var file = File.new_for_path(expected_path);
+                    found = file.query_exists();
+                    break;
+                    
+                case Usm.ManifestFilePathBase.INSTALL:
+                    // For install:, check in install directory at specified path
+                    expected_path = Path.build_filename(install_dir.get_path(), expected.value.path ?? "");
+                    var file = File.new_for_path(expected_path);
+                    found = file.query_exists();
+                    break;
+                    
+                default:
+                    assert_not_reached();
+            }
+        }
+        catch(Error e) {
+            printerr(@"Error validating resource $(expected.key.to_string()): $(e.message)\n");
+            found = false;
+        }
+
+        // A present pc provide must declare a matching version: the
+        // `==` declaration when given, the package version otherwise —
+        // a mismatch invalidates the provide the same way a missing
+        // file does
+        if(found && expected.key.resource_type == Usm.ResourceType.PKG_CONFIG) {
+            var declared = Usm.PcFile.read_version(expected_path);
+            Usm.Version expected_version = null;
+            if(expected.key.constraint != null) {
+                expected_version = expected.key.constraint.version;
+            }
+            else {
+                expected_version = manifest.version;
+            }
+            if(declared == null) {
+                printerr(@"Error: pc provide $(expected.key.base_name) at \"$expected_path\" declares no usable Version field, expected $(expected_version.to_string()).\n");
+                found = false;
+            }
+            else if(!declared.matches_numeric(expected_version)) {
+                printerr(@"Error: pc provide $(expected.key.base_name) at \"$expected_path\" declares version $(declared.to_string()), expected $(expected_version.to_string()).\n");
+                found = false;
+            }
+        }
+
+        if(!found) {
+            missing_resources.add(expected.key);
+        }
+    }
+    
+    if(missing_resources.any()) {
+        printerr("Error: Expected resources not found:\n");
+        foreach (var resource in missing_resources) {
+            var expected_resource = manifest.provides[resource];
+            string location_desc = "";
+            switch(expected_resource.path_base) {
+                case Usm.ManifestFilePathBase.AS_EXPECTED:
+                    location_desc = "at expected install location";
+                    break;
+                case Usm.ManifestFilePathBase.BUILD:
+                    location_desc = @"in build directory at \"$(expected_resource.path ?? "")\"";
+                    break;
+                case Usm.ManifestFilePathBase.SOURCE:
+                    location_desc = @"in source directory at \"$(expected_resource.path ?? "")\"";
+                    break;
+                case Usm.ManifestFilePathBase.INSTALL:
+                    location_desc = @"in install directory at \"$(expected_resource.path ?? "")\"";
+                    break;
+                default:
+                    location_desc = "at unknown location";
+                    break;
+            }
+            printerr(@"  - $(resource.to_string()) ($location_desc)\n");
+        }
+        return 248;
+    }
+    
+    // Scan for unexpected resources in the install directory
+    try {
+        var installed_resources = autoprovides_scan_tree(install_dir.get_path(), install_dir.get_path());
+        var expected_resources = manifest.provides;
+        
+        // Check for unexpected resources
+        var unexpected_resources = new Invercargill.DataStructures.Vector<Usm.ResourceRef>();
+        foreach (var installed in installed_resources) {
+            bool found = false;
+            foreach (var expected in expected_resources) {
+                if(installed.key.to_string() == expected.key.to_string()) {
+                    found = true;
+                    break;
+                }
+            }
+            if(!found) {
+                unexpected_resources.add(installed.key);
+            }
+        }
+        
+        if(unexpected_resources.any()) {
+            printerr("Warning: Unexpected resources found in install directory:\n");
+            foreach (var resource in unexpected_resources) {
+                printerr(@"  - $(resource.to_string())\n");
+            }
+        }
+    }
+    catch(Error e) {
+        printerr(@"Error validating installation result: $(e.message)\n");
+        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 ?? "");
+        var found = File.new_for_path(expected_path).query_exists();
+
+        // The same pc version contract as the built-package validator:
+        // a present pc file must declare the ==-declared or package version
+        if(found && expected.key.resource_type == Usm.ResourceType.PKG_CONFIG) {
+            var declared = Usm.PcFile.read_version(expected_path);
+            var expected_version = expected.key.constraint != null
+                ? expected.key.constraint.version
+                : manifest.version;
+            if(declared == null || !declared.matches_numeric(expected_version)) {
+                printerr(@"Error: pc provide $(expected.key.base_name) at \"$expected_path\" declares $(declared != null ? declared.to_string() : "no usable Version field"), expected $(expected_version.to_string()).\n");
+                found = false;
+            }
+        }
+
+        if(!found) {
+            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;
 }

+ 307 - 0
src/cli/ManifestBump.vala

@@ -0,0 +1,307 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+
+/** The manifest flags-array entry toggled by bump's rebuild flags. */
+private const string BUMP_REBUILD_DEPENDANTS_FLAG = "rebuildDependants";
+
+
+/**
+ * `usm manifest bump [major|minor|patch|<version>] [--git] [--push]
+ * [--rebuild-dependants] [--no-rebuild-dependants]` — version-bump and
+ * release-tag the MANIFEST.usm in the current directory.
+ *
+ * A bump kind or explicit `X.Y.Z` version rewrites the manifest's `version`
+ * (and nothing else); `--rebuild-dependants` / `--no-rebuild-dependants` add
+ * or remove the `rebuildDependants` flag from the `flags` array in the same
+ * pass. `--git` additionally commits the change (skipping the commit when
+ * nothing is staged) and tags `v<version>` annotated; with no version
+ * argument it releases the current version, committing whatever else is
+ * pending so the tag captures the release state. `--push` pushes branch and
+ * tag, implying `--git`.
+ *
+ * The manifest is parsed and rewritten as raw JSON rather than through
+ * {@link Usm.Manifest}'s mapper, so a bump neither depends on manifest
+ * validation nor rewrites flags the lib's flag enum has to know about.
+ */
+public int manifest_bump(string[] args) {
+    string? spec = null;
+    bool git = false;
+    bool push = false;
+    bool rebuild_dependants = false;
+    bool no_rebuild_dependants = false;
+
+    for(int i = 2; i < args.length; i++) {
+        switch(args[i]) {
+            case "--git":
+                git = true;
+                break;
+            case "--push":
+                push = true;
+                break;
+            case "--rebuild-dependants":
+                rebuild_dependants = true;
+                break;
+            case "--no-rebuild-dependants":
+                no_rebuild_dependants = true;
+                break;
+            default:
+                if(args[i].has_prefix("--") || spec != null) {
+                    printerr(@"Unknown or unexpected bump argument \"$(args[i])\"\n");
+                    return bump_usage();
+                }
+                spec = args[i];
+                break;
+        }
+    }
+
+    if(push) {
+        git = true;
+    }
+    if(rebuild_dependants && no_rebuild_dependants) {
+        printerr("--rebuild-dependants and --no-rebuild-dependants are mutually exclusive\n");
+        return bump_usage();
+    }
+    if(spec == null && !git) {
+        printerr("Expected a bump kind (major, minor, patch), a version, or --git\n");
+        return bump_usage();
+    }
+
+    if(!File.new_for_path("MANIFEST.usm").query_exists()) {
+        printerr("No MANIFEST.usm file found in current directory.\n");
+        return 254;
+    }
+
+    InvercargillJson.JsonElement element;
+    InvercargillJson.JsonObject root;
+    try {
+        element = new InvercargillJson.JsonElement.from_file("MANIFEST.usm");
+        root = element.as<InvercargillJson.JsonObject>();
+    }
+    catch(Error e) {
+        printerr(@"Could not read MANIFEST.usm: $(e.message)\n");
+        return 253;
+    }
+
+    var current_version = root.get_string("version");
+    if(current_version == null) {
+        printerr("MANIFEST.usm declares no version string.\n");
+        return 253;
+    }
+
+    string? new_version = null;
+    if(spec == "major" || spec == "minor" || spec == "patch") {
+        int major;
+        int minor;
+        int patch;
+        if(!bump_parse_version(current_version, out major, out minor, out patch)) {
+            printerr(@"Cannot bump: manifest version \"$current_version\" is not X.Y.Z\n");
+            return 252;
+        }
+        switch(spec) {
+            case "major":
+                new_version = @"$(major + 1).0.0";
+                break;
+            case "minor":
+                new_version = @"$major.$(minor + 1).0";
+                break;
+            default:
+                new_version = @"$major.$minor.$(patch + 1)";
+                break;
+        }
+    }
+    else if(spec != null) {
+        int major;
+        int minor;
+        int patch;
+        if(!bump_parse_version(spec, out major, out minor, out patch)) {
+            printerr(@"\"$spec\" is neither a bump kind (major, minor, patch) nor an X.Y.Z version\n");
+            return 252;
+        }
+        new_version = @"$major.$minor.$patch";
+    }
+
+    bool changed = false;
+    if(new_version != null && new_version != current_version) {
+        try {
+            root.set_native("version", new_version);
+        }
+        catch(Error e) {
+            printerr(@"Could not set the manifest version: $(e.message)\n");
+            return 249;
+        }
+        printerr(@"Version \"$current_version\" → \"$new_version\"\n");
+        changed = true;
+    }
+
+    if(rebuild_dependants || no_rebuild_dependants) {
+        var current_flags = new Vector<string>();
+        if(root.has("flags")) {
+            foreach(var flag in root.get_array("flags")) {
+                current_flags.add(flag.as<string>());
+            }
+        }
+        bool flags_changed = false;
+        if(rebuild_dependants) {
+            if(!current_flags.any(f => f == BUMP_REBUILD_DEPENDANTS_FLAG)) {
+                current_flags.add(BUMP_REBUILD_DEPENDANTS_FLAG);
+                flags_changed = true;
+                printerr(@"Added flag \"$BUMP_REBUILD_DEPENDANTS_FLAG\"\n");
+            }
+            else {
+                printerr(@"Flag \"$BUMP_REBUILD_DEPENDANTS_FLAG\" already present, left as-is\n");
+            }
+        }
+        else if(current_flags.any(f => f == BUMP_REBUILD_DEPENDANTS_FLAG)) {
+            var filtered_flags = new Vector<string>();
+            foreach(var flag in current_flags) {
+                if(flag != BUMP_REBUILD_DEPENDANTS_FLAG) {
+                    filtered_flags.add(flag);
+                }
+            }
+            current_flags = filtered_flags;
+            flags_changed = true;
+            printerr(@"Removed flag \"$BUMP_REBUILD_DEPENDANTS_FLAG\"\n");
+        }
+        else {
+            printerr(@"Flag \"$BUMP_REBUILD_DEPENDANTS_FLAG\" not present, left as-is\n");
+        }
+        if(flags_changed) {
+            try {
+                root.set_native("flags", current_flags.select<Element>(f => new NativeElement<string>(f)).to_elements());
+            }
+            catch(Error e) {
+                printerr(@"Could not rewrite the manifest flags: $(e.message)\n");
+                return 249;
+            }
+        }
+        changed = changed || flags_changed;
+    }
+
+    if(changed) {
+        try {
+            var stream = new DataOutputStream(File.new_for_path("MANIFEST.usm").replace(null, false, FileCreateFlags.REPLACE_DESTINATION));
+            stream.put_string(element.stringify_pretty());
+            stream.close();
+        }
+        catch(Error e) {
+            printerr(@"Could not write MANIFEST.usm: $(e.message)\n");
+            return 249;
+        }
+    }
+
+    if(!git) {
+        return 0;
+    }
+
+    var release_version = new_version ?? current_version;
+    var tag = @"v$release_version";
+
+    var existing_tag = bump_git_output(new string[] { "git", "tag", "-l", tag });
+    if(existing_tag == null) {
+        printerr("Could not list git tags (is the manifest directory a git repository?)\n");
+        return 250;
+    }
+    if(existing_tag.length > 0) {
+        printerr(@"Tag \"$tag\" already exists\n");
+        return 250;
+    }
+
+    // A version bump commits the manifest alone; a bare --git releases the
+    // current version, so every pending change belongs in the release commit
+    if(!bump_git_run(new string[] { "git", "add", spec != null ? "MANIFEST.usm" : "-A" })) {
+        printerr("Could not stage the release changes\n");
+        return 250;
+    }
+    var staged = bump_git_output(new string[] { "git", "diff", "--cached", "--name-only" });
+    if(staged == null) {
+        printerr("Could not inspect the staged changes\n");
+        return 250;
+    }
+    if(staged.length > 0) {
+        if(!bump_git_run(new string[] { "git", "commit", "-m", @"Version $release_version" })) {
+            printerr("Could not commit the release changes\n");
+            return 250;
+        }
+        printerr(@"Committed the release changes as \"Version $release_version\"\n");
+    }
+    else {
+        printerr("Nothing to commit, tagging the current state\n");
+    }
+
+    if(!bump_git_run(new string[] { "git", "tag", "-a", tag, "-m", @"Version $release_version" })) {
+        printerr(@"Could not create tag \"$tag\"\n");
+        return 250;
+    }
+    printerr(@"Tagged \"$tag\"\n");
+
+    if(push) {
+        if(!bump_git_run(new string[] { "git", "push" }) || !bump_git_run(new string[] { "git", "push", "origin", tag })) {
+            printerr(@"Could not push the branch and tag \"$tag\"\n");
+            return 250;
+        }
+        printerr(@"Pushed the branch and tag \"$tag\"\n");
+    }
+
+    return 0;
+}
+
+
+private int bump_usage() {
+    printerr("USAGE:\n\tusm manifest bump [major|minor|patch|<version>] [--git] [--push] [--rebuild-dependants] [--no-rebuild-dependants]\n");
+    return 255;
+}
+
+
+/**
+ * Splits {@link version} into strictly numeric major/minor/patch parts,
+ * returning false when it is not an `X.Y.Z` version — bumping demands
+ * exactly three numeric components.
+ */
+private bool bump_parse_version(string version, out int major, out int minor, out int patch) {
+    major = 0;
+    minor = 0;
+    patch = 0;
+    var parts = version.split(".");
+    return parts.length == 3
+        && int.try_parse(parts[0], out major)
+        && int.try_parse(parts[1], out minor)
+        && int.try_parse(parts[2], out patch);
+}
+
+
+/**
+ * Runs {@link command} in the current directory and returns its trimmed
+ * stdout, or null when it fails to run or exits non-zero (diagnostics reach
+ * the terminal through the inherited stderr).
+ */
+private string? bump_git_output(string[] command) {
+    try {
+        var proc = new Subprocess.newv(command, SubprocessFlags.STDOUT_PIPE);
+        string stdout;
+        proc.communicate_utf8(null, null, out stdout, null);
+        if(proc.get_exit_status() != 0) {
+            return null;
+        }
+        return stdout.chomp();
+    }
+    catch(Error e) {
+        printerr(@"Could not run \"$(command[0])\": $(e.message)\n");
+        return null;
+    }
+}
+
+
+/**
+ * Runs {@link command} in the current directory to success: output flows to
+ * the terminal unchanged and the call reports only whether it succeeded.
+ */
+private bool bump_git_run(string[] command) {
+    try {
+        return new Subprocess.newv(command, SubprocessFlags.INHERIT_FDS).wait_check();
+    }
+    catch(Error e) {
+        printerr(@"Could not run \"$(command[0])\": $(e.message)\n");
+        return false;
+    }
+}

+ 857 - 0
src/cli/ProgressBar.vala

@@ -0,0 +1,857 @@
+namespace Usm.Cli {
+
+    /**
+     * Single-line, in-place progress display for a running
+     * {@link Usm.Transaction}:
+     *
+     * ```
+     * → [3/14] Installing invercargill      ████████████████░░░░░░░░░░░ 42%
+     * ```
+     *
+     * The line splits into two fixed zones: the leftmost third of the
+     * terminal carries the text — arrow, action counter and
+     * present-tense action label, ellipsised and space-padded to
+     * exactly the zone width — and the right two thirds hold the bar:
+     * a gap, the `█`/`░` cells (`[####    ]` with brackets in the
+     * ASCII fallback), a second gap and the OVERALL transaction
+     * percentage, right-aligned in a `100%`-wide field so the cell
+     * count never changes. Bar fill and trailing number both track
+     * `(task + task progress) / total tasks` from
+     * {@link Usm.Transaction.progress_updated}; the current action's
+     * own percentage is not shown. The width is re-queried on every
+     * draw, so a resized terminal re-proportions the zones on the
+     * next report.
+     *
+     * Each completed action leaves a persistent log line that scrolls
+     * up — `✓ [3/14] Installed invercargill` (past tense, task
+     * colour) — by consuming the in-place line; repeated 100% reports
+     * for one action never duplicate it. A successful close prints
+     * `✓ Transaction completed` in green; failures print
+     * `✗ [3/14] Failed invercargill` in red. Colours per
+     * {@link Usm.TransactionTask}: green unpack/install, blue build,
+     * magenta test, yellow remove/rebuild/clean-up, cyan strategy, red
+     * failure.
+     *
+     * When stderr is not a terminal the same lines print as plain text
+     * — one per significant change, no ANSI — with the ASCII fallback
+     * characters in non-Unicode locales.
+     *
+     * Besides the transaction display, plain phase bars cover the
+     * stages around it — strategising, package downloads. A phase bar
+     * uses the same chunky cells and right-aligned percentage but no
+     * colours and no `[X/Y]` counter: {@link begin_phase} draws
+     * `Resolving dependencies ███░░░░░ 42%`, {@link update_phase}
+     * redraws it (monotonic within the phase), {@link phase_log}
+     * prints a completion line above the live bar and {@link end_phase}
+     * erases it for whatever displays next.
+     *
+     * Connect {@link on_transaction_progress} to
+     * {@link Usm.Transaction.progress_updated}:
+     *
+     * ```
+     * var progress = new ProgressBar();
+     * transaction.progress_updated.connect(progress.on_transaction_progress);
+     * transaction.run();
+     * progress.finish();
+     * ```
+     */
+    public class ProgressBar : Object {
+
+        private int pulse_position = 0;
+        private bool pulse_forward = true;
+        private Cancellable? pulse_cancel = null;
+        private Thread<bool>? pulse_thread = null;
+
+        /** Whether the active phase bar is in indeterminate (pulsing) mode. */
+        public bool phase_is_indeterminate {
+            get { return phase_progress < 0.0f; }
+        }
+
+        private const string COLOUR_RESET = "\x1b[0m";
+        private const string COLOUR_RED = "\x1b[31m";
+        private const string COLOUR_GREEN = "\x1b[32m";
+        private const string COLOUR_YELLOW = "\x1b[33m";
+        private const string COLOUR_BLUE = "\x1b[34m";
+        private const string COLOUR_MAGENTA = "\x1b[35m";
+        private const string COLOUR_CYAN = "\x1b[36m";
+        private const string COLOUR_MUTED = "\x1b[0m";
+        private const string COLOUR_BRIGHT = "\x1b[97m";
+
+        // TIOCGWINSZ request value on Linux; the CLI is already Linux-only
+        // (mount/chroot in Cli.vala)
+        private const ulong TIOCGWINSZ = 0x5413;
+
+        private bool interactive;
+
+        private string bar_filled_char;
+        private string bar_empty_char;
+        /**
+         * Partial-block characters for sub-cell fill precision, from 1/8
+         * to 7/8: ▏▎▍▌▋▊▉. Index 0 is the empty cell, index 8 the full
+         * cell, so `partial_blocks[eighths]` covers the whole range.
+         */
+        private string[] partial_blocks;
+        private string action_mark;
+        private string success_mark;
+        private string failure_mark;
+        private bool unicode;
+
+        private string? current_package;
+        private TransactionTask current_task;
+        private float current_progress;
+        private int current_index;
+        private int current_total;
+
+        /**
+         * Whole-transaction fraction: the running maximum of
+         * `(task + task progress) / total tasks`. Task weighting is the
+         * only scale the signal offers (removals weigh 3 tasks,
+         * installs 5), and the maximum keeps the bar monotonic through
+         * build-cache clean retries that restart a task's progress.
+         */
+        private float overall = 0.0f;
+
+        /** Whether the transient in-place line is currently on screen. */
+        private bool line_live;
+
+        /**
+         * Action counter of the last logged completion. Actions log at
+         * most once however many 100% reports they emit (installs fire
+         * several); the counter is monotonic within a transaction, so
+         * `action > last_logged_action` is a sufficient duplicate guard.
+         */
+        private int last_logged_action = -1;
+
+        private string plain_key = "";
+        private int plain_decile = -1;
+
+        private string? phase_label = null;
+        private float phase_progress = 0.0f;
+        private bool phase_live = false;
+        private string phase_plain_key = "";
+        private int phase_plain_decile = -1;
+
+        construct {
+            // Charset probing needs the locale applied before it reflects
+            // the environment; without this the C locale forces the ASCII
+            // fallbacks even on UTF-8 terminals
+            Intl.setlocale(LocaleCategory.ALL, "");
+            interactive = Posix.isatty(Posix.STDERR_FILENO);
+            unowned string charset;
+            unicode = get_charset(out charset);
+            bar_filled_char = unicode ? "█" : "#";
+            bar_empty_char = " ";
+            if(unicode) {
+                partial_blocks = { " ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█" };
+            }
+            else {
+                partial_blocks = { " ", ".", ":", "-", "=", "+", "*", "#", "#" };
+            }
+            action_mark = unicode ? "→" : ">";
+            success_mark = unicode ? "✓" : "+";
+            failure_mark = unicode ? "✗" : "x";
+        }
+
+        /**
+         * Adapter matching {@link Usm.Transaction.progress_updated}'s
+         * signature: forwards each report to {@link update}.
+         */
+        public void on_transaction_progress(TransactionTask task_type, string subject, uint task, uint total_tasks, float task_progress) {
+            update(task_type, task_progress, subject, (int)task, (int)total_tasks);
+        }
+
+        /**
+         * Records one progress report and redraws the in-place line.
+         * A report that moves the action counter on, or the current
+         * action's first 100%, retires the current action: it consumes
+         * the in-place line and leaves the persistent
+         * `✓ [n/m] <past tense> <package>` log above it — once only,
+         * however many further 100% reports the action emits.
+         *
+         * Reports for an action the display has already moved past, or
+         * whose completion already logged, are dropped silently: the
+         * ninja progress reader thread of a build can emit fractions a
+         * moment after the main thread advanced the cursor, and drawing
+         * such a stale report would resurrect a finished action's line,
+         * flicker over the current action and corrupt the retire
+         * bookkeeping.
+         */
+        public void update(TransactionTask task, float progress, string? package_name, int index, int total) {
+            if(index < current_index || index <= last_logged_action) {
+                return;
+            }
+            var same_action = index == current_index;
+            var retire = line_live && current_index > last_logged_action
+                && (index > current_index || (same_action && progress >= 1.0f));
+
+            if(retire) {
+                if(!interactive && same_action) {
+                    print_plain();
+                }
+                print_action_log();
+                last_logged_action = current_index;
+            }
+
+            current_task = task;
+            current_package = package_name;
+            current_progress = progress < 0.0f ? 0.0f : progress > 1.0f ? 1.0f : progress;
+            current_index = index;
+            current_total = total;
+            var reported = total > 0 ? ((float)index + current_progress) / (float)total : 0.0f;
+            overall = reported > overall ? reported : overall;
+
+            // A retired action's own 100% report does not redraw — its
+            // log just consumed the line; the next action draws fresh
+            if(!(retire && same_action)) {
+                if(interactive) {
+                    draw_line();
+                }
+                else {
+                    print_plain();
+                }
+            }
+        }
+
+        /**
+         * Prints a completion log for an explicit ({@link package_name},
+         * {@link task}) pair — the same `✓ [n/m] <past tense> <package>`
+         * line actions leave automatically, or `✗ [n/m] Failed <package>`
+         * in red when {@link success} is false.
+         */
+        public void complete_line(string package_name, TransactionTask task, bool success) {
+            var counter = current_total > 0 ? @"[$(current_index + 1)/$current_total] " : "";
+            var outcome = success ? past_tense(task) : "Failed";
+            if(interactive) {
+                stderr.printf("\r\033[K%s%s %s%s %s%s\n",
+                    success ? colour_for_task(task) : COLOUR_RED,
+                    success ? success_mark : failure_mark, counter, outcome, package_name, COLOUR_RESET);
+                line_live = false;
+            }
+            else {
+                stderr.printf("%s %s%s %s\n", success ? success_mark : failure_mark, counter, outcome, package_name);
+            }
+        }
+
+        /**
+         * Marks the in-flight action failed (a thrown
+         * {@link Usm.TransactionError} aborts the run): erases the
+         * in-place line and prints `✗ [n/m] Failed <package>` in red.
+         * Harmless when nothing was drawn yet.
+         */
+        public void fail() {
+            var counter = current_total > 0 ? @"[$(current_index + 1)/$current_total] " : "";
+            if(interactive) {
+                if(line_live) {
+                    stderr.printf("\r\033[K");
+                }
+                stderr.printf("%s%s %sFailed %s%s\n", COLOUR_RED, failure_mark, counter, current_package ?? "transaction", COLOUR_RESET);
+                line_live = false;
+            }
+            else {
+                stderr.printf("%s %sFailed %s\n", failure_mark, counter, current_package ?? "transaction");
+            }
+        }
+
+        /**
+         * Closes the display after a successful transaction: an action
+         * that never reported 100% still leaves its log, the transient
+         * line is erased, and `✓ Transaction completed` prints in
+         * green, scrolling up with the other logs.
+         */
+        public void finish() {
+            if(line_live && current_index > last_logged_action) {
+                print_action_log();
+                last_logged_action = current_index;
+            }
+            if(interactive) {
+                if(line_live) {
+                    stderr.printf("\r\033[K");
+                }
+                line_live = false;
+                stderr.printf("%s%s Transaction completed%s\n", COLOUR_GREEN, success_mark, COLOUR_RESET);
+            }
+            else {
+                stderr.printf("%s Transaction completed\n", success_mark);
+            }
+        }
+
+        /**
+         * Starts an indeterminate (pulsing) phase bar — for operations
+         * with unknown duration (SPM queries, pre-Content-Length fetches).
+         * The bar shows a short segment sweeping back and forth across a
+         * dim track, with `--%` as the percentage. A GLib timeout drives
+         * the animation; {@link end_phase} stops it.
+         */
+        public void begin_indeterminate(string label) {
+            phase_label = label;
+            phase_progress = -1.0f;
+            phase_plain_key = "";
+            phase_plain_decile = -1;
+            pulse_forward = true;
+            pulse_position = 0;
+            if(interactive) {
+                draw_phase_line();
+                pulse_cancel = new Cancellable();
+                try {
+                    pulse_thread = new Thread<bool>.try("usm-pulse", () => {
+                        while(!pulse_cancel.is_cancelled()) {
+                            Thread.usleep(120000);
+                            if(pulse_cancel.is_cancelled()) {
+                                break;
+                            }
+                            advance_pulse();
+                            draw_phase_line();
+                        }
+                        return true;
+                    });
+                }
+                catch(ThreadError e) {
+                }
+            }
+        }
+
+        /**
+         * Starts a plain single-line bar for a non-transaction phase —
+         * strategising, package downloads.
+         */
+        public void begin_phase(string label) {
+            stop_pulse();
+            phase_label = label;
+            phase_progress = 0.0f;
+            phase_plain_key = "";
+            phase_plain_decile = -1;
+            if(interactive) {
+                draw_phase_line();
+            }
+        }
+
+        /** Stops the indeterminate pulse thread if active. */
+        private void stop_pulse() {
+            if(pulse_cancel != null) {
+                pulse_cancel.cancel();
+            }
+            if(pulse_thread != null) {
+                pulse_thread.join();
+                pulse_thread = null;
+            }
+            pulse_cancel = null;
+        }
+
+        private void advance_pulse() {
+            var width = detect_terminal_width();
+            var text_zone = int.max(14, width / 3);
+            var bar_zone = width - text_zone;
+            var capacity = bar_zone - 6;
+            var segment = int.max(3, capacity / 8);
+            if(pulse_forward) {
+                pulse_position += 1;
+                if(pulse_position + segment >= capacity) {
+                    pulse_forward = false;
+                }
+            }
+            else {
+                pulse_position -= 1;
+                if(pulse_position <= 0) {
+                    pulse_forward = true;
+                }
+            }
+        }
+
+        /**
+         * Redraws the phase bar at {@link progress} (clamped to 0..1).
+         * The value is monotonic within a phase — a report below the
+         * current fill never redraws the bar emptier — so callers may
+         * feed raw byte or count fractions without policing ordering
+         * themselves. Ignored when no phase is active. Non-interactive
+         * streams print one line per decile change, mirroring
+         * {@link update}'s plain mode.
+         */
+        public void update_phase(float progress) {
+            if(phase_label == null) {
+                return;
+            }
+            var clamped = progress < 0.0f ? 0.0f : progress > 1.0f ? 1.0f : progress;
+            phase_progress = clamped > phase_progress ? clamped : phase_progress;
+            if(interactive) {
+                draw_phase_line();
+            }
+            else {
+                print_phase_plain();
+            }
+        }
+
+        /**
+         * Erases the phase bar without leaving a log line — the space
+         * is handed to whatever displays next (the transaction plan,
+         * the transaction bar). Harmless when no phase is active.
+         */
+        public void end_phase() {
+            stop_pulse();
+            if(interactive && phase_live) {
+                stderr.printf("\r\033[K");
+            }
+            phase_live = false;
+            phase_label = null;
+            phase_progress = 0.0f;
+            phase_plain_key = "";
+            phase_plain_decile = -1;
+        }
+
+        /**
+         * Prints one completion line above the live phase bar —
+         * `✓ Downloaded invercargill-1.0.0.usmc (388 B)` — consuming
+         * the in-place line and redrawing the bar beneath it, so
+         * per-item logs scroll up like transaction action logs. Plain
+         * (no colours) like the phase bar itself.
+         */
+        public void phase_log(string message) {
+            if(interactive) {
+                if(phase_live) {
+                    stderr.printf("\r\033[K");
+                }
+                stderr.printf("%s %s\n", success_mark, message);
+                if(phase_label != null) {
+                    draw_phase_line();
+                }
+            }
+            else {
+                stderr.printf("%s %s\n", success_mark, message);
+            }
+        }
+
+        /**
+         * The persistent log for the current action, in the past tense;
+         * printing it consumes the transient line, so the log scrolls
+         * up as the next action redraws below.
+         */
+        private void print_action_log() {
+            var counter = @"[$(current_index + 1)/$current_total]";
+            var verb = past_tense(current_task);
+            var pkg = current_package ?? "transaction";
+            if(interactive) {
+                var colour = colour_for_task(current_task);
+                var sb = new StringBuilder();
+                sb.append("\r\033[K");
+                sb.append(COLOUR_BRIGHT);
+                sb.append(success_mark);
+                sb.append(COLOUR_RESET);
+                sb.append(COLOUR_MUTED);
+                sb.append(" ");
+                sb.append(counter);
+                sb.append(" ");
+                sb.append(COLOUR_RESET);
+                sb.append(colour);
+                sb.append(verb);
+                sb.append(COLOUR_RESET);
+                sb.append(COLOUR_BRIGHT);
+                sb.append(" ");
+                sb.append(pkg);
+                sb.append(COLOUR_RESET);
+                sb.append("\n");
+                stderr.printf("%s", sb.str);
+                line_live = false;
+            }
+            else {
+                stderr.printf("%s %s %s %s\n", success_mark, counter, verb, pkg);
+            }
+        }
+
+        private void draw_line() {
+            stderr.printf("\r\033[K%s", line_text());
+            line_live = true;
+        }
+
+        private void draw_phase_line() {
+            stderr.printf("\r\033[K%s", phase_line_text());
+            phase_live = true;
+        }
+
+        /**
+         * One full terminal row: muted counter, coloured verb, bright
+         * package name (left third), then a white bar on the dim track
+         * (right two thirds). The width is re-queried on every call so
+         * a resized terminal re-proportions immediately.
+         */
+        private string line_text() {
+            var width = detect_terminal_width();
+            var text_zone = int.max(14, width / 3);
+
+            var counter = @"[$(current_index + 1)/$current_total]";
+            var head = @"$action_mark $counter ";
+            var verb_colour = colour_for_task(current_task);
+            var verb = verb_for(current_task);
+            var pkg = current_package ?? "";
+
+            var head_len = head.char_count();
+            var verb_len = verb.char_count();
+            var pkg_len = pkg.length > 0 ? pkg.char_count() + 1 : 0;
+            var visible = head_len + verb_len + pkg_len;
+            if(visible > text_zone) {
+                var allowed = text_zone - head_len - verb_len - 1;
+                if(allowed >= 4) {
+                    pkg = ellipsise(pkg, allowed);
+                }
+                else {
+                    var all = ellipsise(@"$head$verb $pkg", text_zone);
+                    var sb0 = new StringBuilder();
+                    sb0.append(COLOUR_MUTED);
+                    sb0.append(all);
+                    sb0.append(COLOUR_RESET);
+                    var pad0 = text_zone - all.char_count();
+                    for(var i = 0; i < pad0; i++) {
+                        sb0.append(" ");
+                    }
+                    sb0.append(bar_text(width - text_zone, overall, null));
+                    return sb0.str;
+                }
+            }
+
+            var padding = text_zone - head_len - verb_len - (pkg.length > 0 ? pkg.char_count() + 1 : 0);
+            var sb = new StringBuilder();
+            sb.append(COLOUR_BRIGHT);
+            sb.append(action_mark);
+            sb.append(COLOUR_RESET);
+            sb.append(COLOUR_MUTED);
+            sb.append(" ");
+            sb.append(counter);
+            sb.append(" ");
+            sb.append(COLOUR_RESET);
+            sb.append(verb_colour);
+            sb.append(verb);
+            sb.append(COLOUR_RESET);
+            if(pkg.length > 0) {
+                sb.append(COLOUR_BRIGHT);
+                sb.append(" ");
+                sb.append(pkg);
+                sb.append(COLOUR_RESET);
+            }
+            for(var i = 0; i < padding; i++) {
+                sb.append(" ");
+            }
+            sb.append(bar_text(width - text_zone, overall, null));
+            return sb.str;
+        }
+
+        /**
+         * The phase row: the phase label alone — no counter, no
+         * colours — ellipsised into the same left-third text zone,
+         * then the same bar zone at {@link phase_progress}.
+         */
+        private string phase_line_text() {
+            var width = detect_terminal_width();
+            var text_zone = int.max(14, width / 3);
+
+            var text = ellipsise(phase_label ?? "", text_zone);
+            var padding = text_zone - text.char_count();
+            var padded = new StringBuilder(text);
+            for(var i = 0; i < padding; i++) {
+                padded.append(" ");
+            }
+
+            var cells = new StringBuilder();
+            cells.append(padded.str);
+            if(phase_progress < 0.0f) {
+                cells.append(indeterminate_bar_text(width - text_zone));
+            }
+            else {
+                cells.append(bar_text(width - text_zone, phase_progress, null));
+            }
+            return cells.str;
+        }
+
+        private string indeterminate_bar_text(int bar_zone) {
+            var capacity = bar_zone - 6;
+            var segment = int.max(3, capacity / 8);
+
+            const string DIM_BG = "\x1b[100m";
+            const string RESET = "\x1b[0m";
+            const string FILL = "\x1b[97m\x1b[107m";
+
+            var cells = new StringBuilder();
+            cells.append(" ");
+            cells.append(DIM_BG);
+            for(var i = 0; i < capacity; i++) {
+                if(i >= pulse_position && i < pulse_position + segment) {
+                    cells.append(RESET);
+                    cells.append(FILL);
+                    cells.append(" ");
+                    cells.append(RESET);
+                    cells.append(DIM_BG);
+                }
+                else {
+                    cells.append(" ");
+                }
+            }
+            cells.append(RESET);
+            cells.append(" ");
+            cells.append(" --%");
+            return cells.str;
+        }
+
+        /** Space-pads {@link cells} out to exactly {@link text_zone} visible characters. */
+        private void pad_to_zone(StringBuilder cells, int text_zone) {
+            var padding = text_zone - cells.str.char_count();
+            for(var i = 0; i < padding; i++) {
+                cells.append(" ");
+            }
+        }
+
+        /**
+         * The bar zone proper — gap, fill cells, gap and the fraction's
+         * percentage right-aligned in a `100%`-wide field.
+         *
+         * All interactive modes render the bar as a coloured track:
+         * full cells use identical foreground+background (a seamless
+         * solid bar with no inter-character gaps), the partial cell
+         * carries the bar colour on the dim track background, and empty
+         * cells are the dim track itself. The track is always visually
+         * defined. {@link colour} selects the fill (task colour, or
+         * bright white when null for phase bars).
+         */
+        private string bar_text(int bar_zone, float fraction, string? colour = null) {
+            var percent = (int)(fraction * 100);
+            var capacity = bar_zone - 6 - (unicode ? 0 : 2);
+
+            var exact = fraction * capacity;
+            if(exact < 0.0f) {
+                exact = 0.0f;
+            }
+            if(exact > capacity) {
+                exact = capacity;
+            }
+            var full = (int)exact;
+            var eighths = (int)((exact - full) * 8.0f + 0.5f);
+            if(eighths > 8) {
+                eighths = 8;
+            }
+            if(eighths == 8) {
+                full++;
+                eighths = 0;
+            }
+
+            var cells = new StringBuilder();
+            cells.append(" ");
+
+            if(interactive && unicode) {
+                var fg = colour ?? "\x1b[97m";
+                var bg = fg.contains("[9") ? fg.replace("[9", "[10") : fg.replace("[3", "[4");
+                const string DIM_BG = "\x1b[100m";
+                const string RESET = "\x1b[0m";
+
+                cells.append(fg);
+                cells.append(bg);
+                for(var i = 0; i < full; i++) {
+                    cells.append(" ");
+                }
+                cells.append(RESET);
+                cells.append(DIM_BG);
+                if(full < capacity && eighths > 0) {
+                    cells.append(fg);
+                    cells.append(partial_blocks[eighths]);
+                    cells.append(RESET);
+                    cells.append(DIM_BG);
+                    full++;
+                }
+                for(var i = full; i < capacity; i++) {
+                    cells.append(" ");
+                }
+                cells.append(RESET);
+            }
+            else if(interactive && !unicode) {
+                var fg = colour ?? "\x1b[97m";
+                var bg = fg.contains("[9") ? fg.replace("[9", "[10") : fg.replace("[3", "[4");
+                const string DIM_BG = "\x1b[100m";
+                const string RESET = "\x1b[0m";
+
+                cells.append("[");
+                cells.append(fg);
+                cells.append(bg);
+                for(var i = 0; i < full; i++) {
+                    cells.append("#");
+                }
+                cells.append(RESET);
+                cells.append(DIM_BG);
+                for(var i = full; i < capacity; i++) {
+                    cells.append(" ");
+                }
+                cells.append(RESET);
+                cells.append("]");
+            }
+            else {
+                if(!unicode) {
+                    cells.append("[");
+                }
+                for(var i = 0; i < full; i++) {
+                    cells.append(bar_filled_char);
+                }
+                if(full < capacity && eighths > 0) {
+                    cells.append(partial_blocks[eighths]);
+                    full++;
+                }
+                for(var i = full; i < capacity; i++) {
+                    cells.append(" ");
+                }
+                if(!unicode) {
+                    cells.append("]");
+                }
+            }
+
+            cells.append(" ");
+            cells.append("%3d%%".printf(percent));
+            return cells.str;
+        }
+
+        /**
+         * `→ [3/14] Installing pkg`: the action label is ellipsised
+         * into whatever the text zone leaves it; when even the arrow
+         * and counter overflow a very narrow zone the whole text is
+         * ellipsised instead.
+         */
+        private string zone_text(int text_zone) {
+            var counter = @"[$(current_index + 1)/$current_total]";
+            var head = @"$action_mark $counter ";
+            var label = label_for(current_task, current_package);
+            var allowed = text_zone - head.char_count();
+            if(allowed >= 4) {
+                return @"$head$(ellipsise(label, allowed))";
+            }
+            return ellipsise(@"$head$label", text_zone);
+        }
+
+        private void print_plain() {
+            var key = @"$current_package|$(current_task)|$current_index";
+            var decile = (int)(current_progress * 10);
+            if(key == plain_key && decile == plain_decile) {
+                return;
+            }
+            plain_key = key;
+            plain_decile = decile;
+            stderr.printf("%s\n", line_text());
+        }
+
+        /** Plain-mode stream for {@link update_phase}: one line per phase or decile change. */
+        private void print_phase_plain() {
+            var key = phase_label ?? "";
+            var decile = (int)(phase_progress * 10);
+            if(key == phase_plain_key && decile == phase_plain_decile) {
+                return;
+            }
+            phase_plain_key = key;
+            phase_plain_decile = decile;
+            stderr.printf("%s\n", phase_line_text());
+        }
+
+        /**
+         * Truncates {@link label} to {@link allowed} visible characters
+         * with an ellipsis, char-aligned so a multi-byte name is never
+         * cut mid-codepoint.
+         */
+        private string ellipsise(string label, int allowed) {
+            if(label.char_count() <= allowed) {
+                return label;
+            }
+            var marker = unicode ? "…" : "...";
+            var cut = allowed - marker.char_count();
+            if(cut <= 0) {
+                return marker;
+            }
+            return label.substring(0, label.index_of_nth_char(cut)) + marker;
+        }
+
+        private string label_for(TransactionTask task, string? package_name) {
+            if(task == TransactionTask.STRATEGISING) {
+                return "Preparing transaction";
+            }
+            var verb = task.get_verb();
+            var capitalised = verb.substring(0, 1).up() + verb.substring(1);
+            return package_name != null ? @"$capitalised $package_name" : capitalised;
+        }
+
+        /** The present-tense verb on its own (for the coloured text zone). */
+        private string verb_for(TransactionTask task) {
+            if(task == TransactionTask.STRATEGISING) {
+                return "Preparing";
+            }
+            var verb = task.get_verb();
+            return verb.substring(0, 1).up() + verb.substring(1);
+        }
+
+        private string colour_for_task(TransactionTask task) {
+            switch(task) {
+                case TransactionTask.UNPACKING:
+                case TransactionTask.INSTALLING:
+                case TransactionTask.INSTALLING_SYSTEM:
+                    return COLOUR_GREEN;
+                case TransactionTask.BUILDING:
+                    return COLOUR_BLUE;
+                case TransactionTask.TESTING:
+                    return COLOUR_MAGENTA;
+                case TransactionTask.REMOVING:
+                case TransactionTask.REBUILDING:
+                case TransactionTask.CLEANING_UP:
+                    return COLOUR_YELLOW;
+                case TransactionTask.STRATEGISING:
+                    return COLOUR_CYAN;
+                default:
+                    return COLOUR_RESET;
+            }
+        }
+
+        private string past_tense(TransactionTask task) {
+            switch(task) {
+                case TransactionTask.STRATEGISING:
+                    return "Prepared";
+                case TransactionTask.UNPACKING:
+                    return "Unpacked";
+                case TransactionTask.BUILDING:
+                    return "Built";
+                case TransactionTask.TESTING:
+                    return "Tested";
+                case TransactionTask.INSTALLING:
+                    return "Installed";
+                case TransactionTask.INSTALLING_SYSTEM:
+                    return "Installed system package";
+                case TransactionTask.REMOVING:
+                    return "Removed";
+                case TransactionTask.REBUILDING:
+                    return "Rebuilt";
+                case TransactionTask.CLEANING_UP:
+                    return "Cleaned up";
+                default:
+                    return task.get_verb();
+            }
+        }
+
+        [CCode (cname = "winsize", cheader_filename = "sys/ioctl.h", has_type_id = false)]
+        private struct WinSize {
+            public uint16 ws_row;
+            public uint16 ws_col;
+            public uint16 ws_xpixel;
+            public uint16 ws_ypixel;
+        }
+
+        [CCode (cname = "ioctl", cheader_filename = "sys/ioctl.h")]
+        private static extern int terminal_ioctl(int fd, ulong request, void* argument);
+
+        /**
+         * The terminal width of stderr: TIOCGWINSZ first, then
+         * `$COLUMNS`, then 80 — mirroring how shells and common CLI
+         * tooling pick a fallback width. Queried per draw rather than
+         * cached, which is what makes the display resize-responsive.
+         */
+        private int detect_terminal_width() {
+            WinSize window = WinSize();
+            if(terminal_ioctl(Posix.STDERR_FILENO, TIOCGWINSZ, &window) == 0 && window.ws_col > 0) {
+                return window.ws_col;
+            }
+            var columns = Environment.get_variable("COLUMNS");
+            if(columns != null) {
+                var parsed = int.parse(columns);
+                if(parsed > 0) {
+                    return parsed;
+                }
+            }
+            return 80;
+        }
+    }
+}

+ 168 - 0
src/cli/Provides.vala

@@ -0,0 +1,168 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+/** Entry point wired into Cli.vala's dispatch; delegates to {@link Usm.Cli.Provides.run}. */
+private int provides_main(string[] args) {
+    return Usm.Cli.Provides.run(args);
+}
+
+namespace Usm.Cli {
+
+    /**
+     * `usm provides <type:resource>` — find the packages providing one
+     * explicit resource ref, for example `usm provides lib:libglib-2.0.so.0`
+     * or `usm provides bin:valac`.
+     *
+     * Refs must be fully written (`type:resource`); a ref without a type or
+     * resource is rejected rather than guessed. Every configured
+     * repository's listing and every installed package's manifest is
+     * searched with {@link Usm.ResourceRef.satisfied_by}, so a `lib:`
+     * lookup also finds `canonlib:` providers of the same name — the same
+     * matching the resolver uses. Repository listings are refreshed first
+     * and fall back to the cached listing when a refresh fails.
+     */
+    public class Provides {
+
+        /**
+         * Runs the command against `args` (`args[0]` is the program name,
+         * `args[1]` the command); exactly one resource ref is expected.
+         * Returns 0 when at least one package provides the ref, 1 when none
+         * does or the ref is malformed, 245 when the system is not managed
+         * by usm and 255 on usage errors.
+         */
+        public static int run(string[] args) {
+            string? ref_text = null;
+            for(int i = 2; i < args.length; i++) {
+                if(args[i].has_prefix("-")) {
+                    printerr(@"Unknown option \"$(args[i])\"\n");
+                    return provides_usage();
+                }
+                if(ref_text != null) {
+                    printerr("Expected exactly one resource ref\n");
+                    return provides_usage();
+                }
+                ref_text = args[i];
+            }
+            if(ref_text == null) {
+                return provides_usage();
+            }
+
+            var parts = ref_text.split(":", 2);
+            if(parts.length < 2 || parts[1].length == 0) {
+                printerr(@"\"$ref_text\" is not a resource ref; write the type explicitly, e.g. lib:libglib-2.0.so.0\n");
+                return 1;
+            }
+
+            Usm.ResourceRef resource;
+            try {
+                resource = new Usm.ResourceRef(ref_text);
+            }
+            catch(Error e) {
+                printerr(@"\"$ref_text\" is not a resource ref: $(e.message)\n");
+                return 1;
+            }
+
+            Usm.SystemState state;
+            try {
+                state = new Usm.SystemState(paths);
+            }
+            catch(Error e) {
+                printerr(@"This system is not managed by usm: $(e.message)\n");
+                return 245;
+            }
+
+            try {
+                var found = 0;
+
+                printerr("Refreshing repositories...\n");
+                foreach(var repo in provides_repositories(state)) {
+                    var listing = provides_listing(state, repo);
+                    if(listing == null) {
+                        continue;
+                    }
+                    foreach(var entry in listing.entries) {
+                        var provided = provides_match(entry.manifest, resource);
+                        if(provided != null) {
+                            print("%s-%s (%s) provides %s\n",
+                                entry.manifest.name ?? "", entry.manifest.version.to_string(), repo.name, provided);
+                            found++;
+                        }
+                    }
+                }
+
+                foreach(var cached in state.get_installed_packages()) {
+                    var manifest = cached.get_manifest();
+                    var provided = provides_match(manifest, resource);
+                    if(provided != null) {
+                        print("%s-%s (installed) provides %s\n",
+                            manifest.name ?? "", manifest.version.to_string(), provided);
+                        found++;
+                    }
+                }
+
+                if(found == 0) {
+                    printerr(@"No package provides $(resource.to_string())\n");
+                    return 1;
+                }
+            }
+            catch(Error e) {
+                printerr(@"Error: $(e.message)\n");
+                return 1;
+            }
+
+            return 0;
+        }
+
+        private static int provides_usage() {
+            printerr("USAGE:\n\tusm provides <type:resource>\n\te.g. usm provides lib:libglib-2.0.so.0\n");
+            return 255;
+        }
+
+        /**
+         * The first ref in `manifest`'s provides that satisfies `resource`
+         * (see {@link Usm.ResourceRef.satisfied_by} and the pc
+         * package-version assumption of {@link Usm.Manifest.satisfies}),
+         * or null when the manifest does not provide it.
+         */
+        private static string? provides_match(Usm.Manifest manifest, Usm.ResourceRef resource) {
+            if(!manifest.satisfies(resource)) {
+                return null;
+            }
+            foreach(var provide in manifest.provides) {
+                if(resource.satisfied_by(provide.key)) {
+                    return provide.key.to_string();
+                }
+            }
+            return @"pc:$(resource.base_name)==$(manifest.version.to_string())";
+        }
+
+        /**
+         * The configured repositories; an unreadable or absent repos.d
+         * leaves the lookup running over installed packages alone.
+         */
+        private static Vector<Usm.Repository> provides_repositories(Usm.SystemState state) {
+            var repositories = new Vector<Usm.Repository>();
+            try {
+                repositories.add_all(state.get_repositories());
+            }
+            catch(Error e) {
+                printerr(@"Warning: no repositories could be read from repos.d: $(e.message)\n");
+            }
+            return repositories;
+        }
+
+        /**
+         * The repository's freshest listing, refreshing it first and
+         * degrading to the cached listing when the refresh fails.
+         */
+        private static Usm.RepositoryListing? provides_listing(Usm.SystemState state, Usm.Repository repository) throws Error {
+            try {
+                state.refresh_list(repository);
+            }
+            catch(Error e) {
+                printerr(@"Warning: could not refresh \"$(repository.name)\", searching the cached listing: $(e.message)\n");
+            }
+            return state.get_latest_list(repository);
+        }
+    }
+}

+ 82 - 0
src/cli/Rebuild.vala

@@ -0,0 +1,82 @@
+/**
+ * `usm rebuild <package> [--clean]` — rebuild an installed package from
+ * its cached sources through {@link Usm.Transaction.rebuild_package}, the
+ * standard pipeline with build-cache restore and a clean retry on
+ * failure, then reinstall its resources. Non-destructive (same version,
+ * same state directory), so no confirmation is needed.
+ *
+ * `--clean` discards the build cache (build directory + build.tar.xz)
+ * before rebuilding, forcing a from-scratch compilation.
+ */
+private int rebuild_main(string[] args) {
+
+    bool clean_build = false;
+    string? package_name = null;
+
+    for(int i = 2; i < args.length; i++) {
+        var arg = args[i];
+        if(arg == "--clean") {
+            clean_build = true;
+        }
+        else if(arg.has_prefix("-")) {
+            return rebuild_usage();
+        }
+        else if(package_name == null) {
+            package_name = arg;
+        }
+        else {
+            return rebuild_usage();
+        }
+    }
+
+    if(package_name == null) {
+        return rebuild_usage();
+    }
+
+    var progress = new Usm.Cli.ProgressBar();
+
+    Usm.SystemState state = null;
+    try {
+        state = new Usm.SystemState(paths);
+    }
+    catch(Error e) {
+        printerr(@"This system is not managed by usm: $(e.message)\n");
+        return 245;
+    }
+
+    try {
+        var target = state.find_installed(package_name);
+        if(target == null) {
+            printerr(@"No installed package named \"$package_name\"\n");
+            return 254;
+        }
+
+        if(clean_build) {
+            target.clean_build_cache();
+            print("Cleared build cache for %s\n", target.package_name);
+        }
+
+        var transaction = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(paths),
+            state = state
+        };
+        transaction.progress_updated.connect(progress.on_transaction_progress);
+        transaction.rebuild_package(target);
+        progress.finish();
+
+        print(@"Rebuilt $(target.package_name) from cached sources\n");
+    }
+    catch(Error e) {
+        progress.fail();
+        printerr(@"Error: $(e.message)\n");
+        return 238;
+    }
+
+    return 0;
+}
+
+private int rebuild_usage() {
+    printerr("USAGE:\n\tusm rebuild <package> [--clean]\n");
+    return 255;
+}

+ 242 - 0
src/cli/Remove.vala

@@ -0,0 +1,242 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+/**
+ * `usm remove [-y|--yes] <packages>` — remove installed packages and,
+ * by cascade, every installed package that transitively depends on any
+ * of them (discovered through {@link Usm.SystemState.find_dependant_names},
+ * so the summary shows the full closure before anything happens). All
+ * requested cascades merge into one plan and one confirm.
+ *
+ * The plan then gains every orphaned dependency: a remaining package
+ * installed implicitly ({@link Usm.OriginInformation.explicitly_installed}
+ * false — a resolved dependency, or a record from before the field
+ * existed) that no package surviving the removal depends on. Orphans
+ * appear under their own "remove (orphaned)" label in the summary, and
+ * nothing is removed without confirmation.
+ *
+ * After the transaction each removed package's state directory —
+ * sources, build cache, `installed/` entry — is deleted; OTHER versions'
+ * directories (the siblings updates keep for downgrade) remain.
+ */
+private int remove_main(string[] args) {
+
+    var assume_yes = false;
+    var package_names = new Vector<string>();
+    for(int i = 2; i < args.length; i++) {
+        var argument = args[i];
+        if(argument == "-y" || argument == "--yes") {
+            assume_yes = true;
+        }
+        else if(argument.has_prefix("-")) {
+            printerr(@"Unknown option \"$argument\"\n");
+            return remove_usage();
+        }
+        else {
+            package_names.add(argument);
+        }
+    }
+    if(package_names.length == 0) {
+        return remove_usage();
+    }
+
+    var progress = new Usm.Cli.ProgressBar();
+
+    Usm.SystemState state = null;
+    try {
+        state = new Usm.SystemState(paths);
+    }
+    catch(Error e) {
+        printerr(@"This system is not managed by usm: $(e.message)\n");
+        return 245;
+    }
+
+    try {
+        var by_package_name = new Dictionary<string, Usm.CachedPackage>();
+        foreach(var installed in state.get_installed_packages()) {
+            by_package_name.set(installed.package_name, state.resolve_installed(installed));
+        }
+
+        // One shared visited set across every requested target, so a
+        // package reached from two cascades is removed once
+        var closure = new Vector<Usm.CachedPackage>();
+        var visited = new HashSet<string>();
+        foreach(var package_name in package_names) {
+            var target = state.find_installed(package_name);
+            if(target == null) {
+                printerr(@"No installed package named \"$package_name\"\n");
+                return 254;
+            }
+            remove_collect_cascade(state, by_package_name, visited, closure, target);
+        }
+
+        var orphans = remove_collect_orphans(by_package_name, closure);
+
+        var to_remove = new HashSet<Usm.CachedPackage>();
+        to_remove.union_with(closure);
+        to_remove.union_with(orphans);
+
+        var transaction = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(paths),
+            to_remove = to_remove,
+            state = state
+        };
+
+        // Planning before the prompt lets the removal order (dependents
+        // before providers, orphans last) be computed and shown
+        try {
+            transaction.strategise();
+        }
+        catch(Usm.TransactionError e) {
+            printerr(@"$(e.message)\n");
+            return 240;
+        }
+
+        var summary = new Usm.TransactionSummary() {
+            to_remove = closure,
+            orphaned_removals = orphans
+        };
+        if(!Usm.Cli.confirm_transaction(summary, assume_yes)) {
+            print("Aborted.\n");
+            return 1;
+        }
+
+        printerr("\nRunning transaction...\n");
+        transaction.progress_updated.connect(progress.on_transaction_progress);
+        transaction.run();
+        progress.finish();
+
+        // The state directories go only after a successful transaction;
+        // other versions' directories are never touched
+        var removed_packages = closure.concat(orphans);
+        foreach(var package in removed_packages) {
+            Usm.Util.delete_tree(package.state_path);
+        }
+        print(@"Removed $(closure.length + orphans.length) package(s) ($(removed_packages.to_string(p => p.package_name, ", ")))\n");
+    }
+    catch(Error e) {
+        progress.fail();
+        printerr(@"Error: $(e.message)\n");
+        return 238;
+    }
+
+    return 0;
+}
+
+/**
+ * Depth-first reverse-dependency closure: {@link package} plus every
+ * installed package that transitively depends on it, appended to
+ * {@link closure} target-first. {@link visited} keys on the versioned
+ * cache-directory name so shared dependants collapse to one removal.
+ */
+private void remove_collect_cascade(Usm.SystemState state, Dictionary<string, Usm.CachedPackage> by_package_name,
+        HashSet<string> visited, Vector<Usm.CachedPackage> closure, Usm.CachedPackage package) throws Error {
+    if(visited.contains(package.package_name)) {
+        return;
+    }
+    visited.add(package.package_name);
+    closure.add(package);
+
+    foreach(var dependant_name in state.find_dependant_names(package)) {
+        Usm.CachedPackage dependant;
+        if(by_package_name.try_get(dependant_name, out dependant)) {
+            remove_collect_cascade(state, by_package_name, visited, closure, dependant);
+        }
+    }
+}
+
+/**
+ * Orphan scan over the packages {@link closure} leaves behind: every
+ * implicitly installed survivor that no other survivor depends on,
+ * where dependency means any runtime/build/manage/acquire ref matched
+ * against the candidate's provides (the same resource-level matching
+ * {@link Usm.SystemState.find_dependant_names} applies, restricted to
+ * the survivors because that helper scans the whole installed set).
+ * Rescans until no new orphan appears so a chain of orphans — libB
+ * orphaned, then the libC only libB ever needed — is collected whole.
+ */
+private Vector<Usm.CachedPackage> remove_collect_orphans(Dictionary<string, Usm.CachedPackage> by_package_name,
+        Vector<Usm.CachedPackage> closure) throws Error {
+    // Manifests, dependency refs and explicit marks are read once up
+    // front: the fixpoint loop below only consults these tables
+    var manifests = new Dictionary<string, Usm.Manifest>();
+    var refs_by_name = new Dictionary<string, HashSet<Usm.ResourceRef>>();
+    var implicit_names = new HashSet<string>();
+    foreach(var pair in by_package_name) {
+        var manifest = pair.value.get_manifest();
+        manifests.set(pair.key, manifest);
+        refs_by_name.set(pair.key, remove_dependency_refs(manifest));
+        if(!remove_explicitly_installed(pair.value)) {
+            implicit_names.add(pair.key);
+        }
+    }
+
+    var gone = new HashSet<string>();
+    foreach(var package in closure) {
+        gone.add(package.package_name);
+    }
+
+    var orphans = new Vector<Usm.CachedPackage>();
+    var collected = true;
+    while(collected) {
+        collected = false;
+        foreach(var name in implicit_names) {
+            if(gone.contains(name)) {
+                continue;
+            }
+            var provides = manifests[name].effective_provides();
+            var needed = false;
+            foreach(var pair in refs_by_name) {
+                if(pair.key == name || gone.contains(pair.key)) {
+                    continue;
+                }
+                if(pair.value.any(d => provides.any(p => d.satisfied_by(p)))) {
+                    needed = true;
+                    break;
+                }
+            }
+            if(needed) {
+                continue;
+            }
+            gone.add(name);
+            orphans.add(by_package_name[name]);
+            collected = true;
+        }
+    }
+    return orphans;
+}
+
+/**
+ * Every dependency ref of {@link manifest} across all phases, flat plus
+ * the union of all candidate groups of grouped phases.
+ */
+private HashSet<Usm.ResourceRef> remove_dependency_refs(Usm.Manifest manifest) {
+    var refs = new HashSet<Usm.ResourceRef>();
+    refs.union_with(manifest.dependencies.runtime.all_refs());
+    refs.union_with(manifest.dependencies.build.all_refs());
+    refs.union_with(manifest.dependencies.manage.all_refs());
+    if(manifest.dependencies.acquire != null) {
+        refs.union_with(manifest.dependencies.acquire.all_refs());
+    }
+    return refs;
+}
+
+/**
+ * The package's explicit-install mark; a missing or unreadable origin
+ * record means the package predates the field, whose default is false —
+ * the same value the record itself would carry.
+ */
+private bool remove_explicitly_installed(Usm.CachedPackage package) {
+    try {
+        return package.get_origin_information().explicitly_installed;
+    }
+    catch(Error e) {
+        return false;
+    }
+}
+
+private int remove_usage() {
+    printerr("USAGE:\n\tusm remove [-y|--yes] <packages>\n");
+    return 255;
+}

+ 534 - 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,487 @@ 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),
+                size = (uint64)File.new_for_path(path).query_info(FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NONE).get_size()
+            };
+
+            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 +581,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;
+}

+ 283 - 0
src/cli/Scaffold.vala

@@ -0,0 +1,283 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+private int scaffold_main(string[] args) {
+    if(args.length < 4) {
+        return scaffold_usage();
+    }
+
+    var name = args[2];
+    var template = args[3];
+    
+    // Parse attributes
+    var attributes = new HashSet<string>();
+    for(int i = 4; i < args.length; i++) {
+        attributes.add(args[i]);
+    }
+
+    try {
+        // Create the manifest based on template and attributes
+        var manifest = create_manifest(name, template, attributes);
+        
+        // Write MANIFEST.usm file
+        write_manifest_file(manifest);
+        
+        // Create scripts directory and scripts based on template
+        create_scripts(template, attributes);
+        
+        print(@"Successfully scaffolded project '$name' with template '$template'\n");
+        return 0;
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return 1;
+    }
+}
+
+private int scaffold_usage() {
+    printerr("USAGE:\n\tusm scaffold <name> <template> [attributes...]\n");
+    printerr("\nTemplates:\n");
+    printerr("\tbasic    - Creates only MANIFEST.usm\n");
+    printerr("\tmakefile - Creates MANIFEST.usm with make build scripts\n");
+    printerr("\tmeson    - Creates MANIFEST.usm with meson build scripts\n");
+    printerr("\nAttributes:\n");
+    printerr("\tvala     - Add Vala language dependencies\n");
+    printerr("\tc        - Add C language dependencies\n");
+    printerr("\tacquire  - Add acquire dependencies and script\n");
+    return 255;
+}
+
+private Usm.Manifest create_manifest(string name, string template, HashSet<string> attributes) throws Error {
+    var manifest = new Usm.Manifest();
+    
+    // Basic manifest fields
+    manifest.name = name;
+    manifest.version = new Usm.Version.from_string("0.1.0");
+    manifest.summary = @"$name project";
+    manifest.licences = new Vector<Usm.Licence>();
+    manifest.licences.add(new Usm.Licence() { name = "MIT", category = Usm.LicenceCategory.LIBRE });
+    
+    // Dependencies
+    manifest.dependencies = new Usm.Dependencies();
+
+    // 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.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.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.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.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 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;
+}
+
+private void write_manifest_file(Usm.Manifest manifest) throws Error {
+    var mapper = Usm.Manifest.get_mapper();
+    var properties = mapper.map_from(manifest);
+    var json_element = new InvercargillJson.JsonElement.from_properties(properties);
+    
+    // Create pretty JSON string
+    var json_string = json_element.stringify_pretty();
+    
+    // Write to file manually
+    var file = File.new_for_path("MANIFEST.usm");
+    var data_stream = new DataOutputStream(file.replace(null, false, FileCreateFlags.NONE));
+    data_stream.put_string(json_string);
+}
+
+private void create_scripts(string template, HashSet<string> attributes) throws Error {
+    // Create usm-scripts directory
+    var scripts_dir = File.new_for_path("usm-scripts");
+    if(!scripts_dir.query_exists()) {
+        scripts_dir.make_directory_with_parents();
+    }
+    
+    // Create build script
+    var build_script = File.new_for_path("usm-scripts/build.sh");
+    create_build_script(build_script, template);
+    
+    // Create install script if needed
+    if(template == "makefile" || template == "meson") {
+        var install_script = File.new_for_path("usm-scripts/install.sh");
+        create_install_script(install_script, template);
+    }
+    
+    // Create acquire script if needed
+    if(attributes.contains("acquire")) {
+        var acquire_script = File.new_for_path("usm-scripts/acquire.sh");
+        create_acquire_script(acquire_script);
+    }
+}
+
+private void create_build_script(File file, string template) throws Error {
+    string content;
+    
+    switch(template) {
+        case "basic":
+            content = """#!/bin/bash
+set -e
+
+src_dir=$(pwd)
+build_dir=$1
+
+# Basic build script
+echo "Building $(basename $(pwd))..."
+# Add your build commands here
+
+echo "Build completed successfully"
+""";
+            break;
+            
+        case "makefile":
+            content = """#!/bin/bash
+set -e
+
+src_dir=$(pwd)
+build_dir=$1
+
+cd ${build_dir}
+
+make
+""";
+            break;
+            
+        case "meson":
+            content = """#!/bin/bash
+set -e
+
+src_dir=$(pwd)
+build_dir=$1
+
+cd ${build_dir}
+
+meson setup ${src_dir}/src --prefix=${PREFIX} --libdir=${LIBDIR} --bindir=${BINDIR} --includedir=${INCLUDEDIR}
+ninja
+""";
+            break;
+            
+        default:
+            throw new Error(Quark.from_string("scaffold"), 1, @"Unknown template '$template' for build script");
+    }
+    
+    write_script_file(file, content);
+}
+
+private void create_install_script(File file, string template) throws Error {
+    string content = get_script_header();
+    
+    switch(template) {
+        case "makefile":
+            content += """src_dir=$(pwd)
+build_dir=$1
+install_dir=$2
+
+cd ${build_dir}
+
+make install DESTDIR=${install_dir}
+""";
+            break;
+            
+        case "meson":
+            content += """src_dir=$(pwd)
+build_dir=$1
+install_dir=$2
+
+cd ${build_dir}
+
+meson install --destdir ${install_dir}
+""";
+            break;
+            
+        default:
+            throw new Error(Quark.from_string("scaffold"), 1, @"Unknown template '$template' for install script");
+    }
+    
+    write_script_file(file, content);
+}
+
+private void create_acquire_script(File file) throws Error {
+    string content = get_script_header();
+    
+    content += """# Acquire script - download and extract source archive
+# Replace these placeholders with actual values
+ARCHIVE_URL="https://example.com/source.tar.gz"
+ARCHIVE_NAME="source.tar.gz"
+EXTRACT_DIR="source"
+
+echo "Downloading source archive..."
+wget -O ${ARCHIVE_NAME} ${ARCHIVE_URL}
+
+echo "Extracting archive..."
+mkdir -p ${EXTRACT_DIR}
+tar -xzf ${ARCHIVE_NAME} -C ${EXTRACT_DIR} --strip-components=1
+
+echo "Cleaning up..."
+rm ${ARCHIVE_NAME}
+
+echo "Acquisition completed successfully"
+""";
+    
+    write_script_file(file, content);
+}
+
+private string get_script_header() {
+    return """#!/bin/bash
+set -e
+""";
+}
+
+private void write_script_file(File file, string content) throws Error {
+    var data_stream = new DataOutputStream(file.replace(null, false, FileCreateFlags.NONE));
+    data_stream.put_string(content);
+    
+    // Set execute bit
+    var file_info = file.query_info("*", FileQueryInfoFlags.NONE);
+    var mode = file_info.get_attribute_uint32("unix::mode");
+    mode |= 0111; // Add execute bit for owner, group, and others
+    file.set_attribute_uint32("unix::mode", mode, FileQueryInfoFlags.NONE);
+}

+ 206 - 0
src/cli/Search.vala

@@ -0,0 +1,206 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+/** Entry point wired into Cli.vala's dispatch; delegates to {@link Usm.Cli.Search.run}. */
+private int search_main(string[] args) {
+    return Usm.Cli.Search.run(args);
+}
+
+namespace Usm.Cli {
+
+    /**
+     * `usm search <query>` — find packages across every configured
+     * repository and the installed set.
+     *
+     * The query is a case-insensitive substring matched against package
+     * names and summaries. A package that is both installed and listed by
+     * a repository appears as one row showing the installed version and
+     * the available one (`installed → available`), sourced from the
+     * repository; packages that are only installed show `installed` as
+     * their source. Repository listings are refreshed before searching and
+     * fall back to the cached listing when a refresh fails, so an offline
+     * search still serves the last known state.
+     */
+    public class Search {
+
+        /**
+         * Runs the command against `args` (`args[0]` is the program name,
+         * `args[1]` the command); every remaining argument joins into the
+         * query. Returns 0 when at least one package matches, 1 when none
+         * does, 245 when the system is not managed by usm and 255 on usage
+         * errors.
+         */
+        public static int run(string[] args) {
+            var terms = new Vector<string>();
+            for(int i = 2; i < args.length; i++) {
+                if(args[i].has_prefix("-")) {
+                    printerr(@"Unknown option \"$(args[i])\"\n");
+                    return search_usage();
+                }
+                terms.add(args[i]);
+            }
+            if(terms.length == 0) {
+                return search_usage();
+            }
+            var query = string.joinv(" ", terms.to_array());
+
+            Usm.SystemState state;
+            try {
+                state = new Usm.SystemState(paths);
+            }
+            catch(Error e) {
+                printerr(@"This system is not managed by usm: $(e.message)\n");
+                return 245;
+            }
+
+            try {
+                var installed = search_installed(state);
+                var rows = new Vector<SearchRow>();
+                var shown_installed = new HashSet<string>();
+                var needle = query.casefold();
+
+                printerr("Refreshing repositories...\n");
+                foreach(var repo in search_repositories(state)) {
+                    var listing = search_listing(state, repo);
+                    if(listing == null) {
+                        continue;
+                    }
+                    foreach(var entry in listing.entries) {
+                        var name = entry.manifest.name ?? "";
+                        var summary = entry.manifest.summary ?? "";
+                        if(!name.casefold().contains(needle) && !summary.casefold().contains(needle)) {
+                            continue;
+                        }
+
+                        Usm.Manifest? installed_manifest = null;
+                        installed.try_get(name, out installed_manifest);
+                        var available = entry.manifest.version.to_string();
+                        var version = installed_manifest == null
+                            ? available
+                            : search_versions(installed_manifest.version.to_string(), available);
+                        if(installed_manifest != null) {
+                            shown_installed.add(name);
+                        }
+                        rows.add(new SearchRow() {
+                            name = name,
+                            version = version,
+                            source = repo.name,
+                            summary = summary
+                        });
+                    }
+                }
+
+                foreach(var pair in installed) {
+                    if(shown_installed.contains(pair.key)) {
+                        continue;
+                    }
+                    var name = pair.value.name ?? "";
+                    var summary = pair.value.summary ?? "";
+                    if(!name.casefold().contains(needle) && !summary.casefold().contains(needle)) {
+                        continue;
+                    }
+                    rows.add(new SearchRow() {
+                        name = name,
+                        version = pair.value.version.to_string(),
+                        source = "installed",
+                        summary = summary
+                    });
+                }
+
+                if(rows.length == 0) {
+                    printerr(@"No packages matching \"$query\"\n");
+                    return 1;
+                }
+
+                search_print(rows.sort((a, b) => a.name.collate(b.name)).to_vector());
+            }
+            catch(Error e) {
+                printerr(@"Error: $(e.message)\n");
+                return 1;
+            }
+
+            return 0;
+        }
+
+        private static int search_usage() {
+            printerr("USAGE:\n\tusm search <query>\n");
+            return 255;
+        }
+
+        /**
+         * The installed packages by name, keeping the highest version when
+         * more than one version is marked installed.
+         */
+        private static Dictionary<string, Usm.Manifest> search_installed(Usm.SystemState state) throws Error {
+            var installed = new Dictionary<string, Usm.Manifest>();
+            foreach(var cached in state.get_installed_packages()) {
+                var manifest = cached.get_manifest();
+                Usm.Manifest? existing = null;
+                if(installed.try_get(manifest.name, out existing)
+                    && existing.version.compare(manifest.version) >= 0) {
+                    continue;
+                }
+                installed.set(manifest.name, manifest);
+            }
+            return installed;
+        }
+
+        /**
+         * The configured repositories; an unreadable or absent repos.d
+         * leaves the search running over installed packages alone.
+         */
+        private static Vector<Usm.Repository> search_repositories(Usm.SystemState state) {
+            var repositories = new Vector<Usm.Repository>();
+            try {
+                repositories.add_all(state.get_repositories());
+            }
+            catch(Error e) {
+                printerr(@"Warning: no repositories could be read from repos.d: $(e.message)\n");
+            }
+            return repositories;
+        }
+
+        /**
+         * The repository's freshest listing, refreshing it first and
+         * degrading to the cached listing when the refresh fails.
+         */
+        private static Usm.RepositoryListing? search_listing(Usm.SystemState state, Usm.Repository repository) throws Error {
+            try {
+                state.refresh_list(repository);
+            }
+            catch(Error e) {
+                printerr(@"Warning: could not refresh \"$(repository.name)\", searching the cached listing: $(e.message)\n");
+            }
+            return state.get_latest_list(repository);
+        }
+
+        /** `installed → available`, collapsing to one version when they match. */
+        private static string search_versions(string installed, string available) {
+            return installed == available ? available : @"$installed → $available";
+        }
+
+        private static void search_print(Vector<SearchRow> rows) {
+            var name_width = "NAME".length;
+            var version_width = "VERSION".length;
+            var source_width = "SOURCE".length;
+            foreach(var row in rows) {
+                name_width = int.max(name_width, row.name.length);
+                version_width = int.max(version_width, row.version.length);
+                source_width = int.max(source_width, row.source.length);
+            }
+
+            print("%-*s  %-*s  %-*s  %s\n", name_width, "NAME", version_width, "VERSION", source_width, "SOURCE", "SUMMARY");
+            foreach(var row in rows) {
+                print("%-*s  %-*s  %-*s  %s\n", name_width, row.name, version_width, row.version, source_width, row.source, row.summary);
+            }
+        }
+
+        /** One output row; see {@link Search} for the column semantics. */
+        private class SearchRow {
+            public string name;
+            public string version;
+            public string source;
+            public string summary;
+        }
+    }
+}

+ 290 - 0
src/cli/Update.vala

@@ -0,0 +1,290 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+/**
+ * `usm update [-y|--yes] [<packages>]` — bring installed packages up to
+ * the newest version their repositories offer.
+ *
+ * Without package names every installed package with a newer repository
+ * version becomes a resolution root; with names exactly those packages
+ * do (already-current names are skipped with a note). Either way there
+ * is one resolution, one {@link Usm.TransactionSummary}, one confirm —
+ * and the downloads happen only once the plan is accepted.
+ *
+ * Dependencies already installed at the resolved version are left alone;
+ * an installed package moving to a different version is removed
+ * (RemoveType.UPGRADE) and the new version installed in its place. The
+ * old version's state directory is kept, which is what makes `usm
+ * downgrade` possible.
+ */
+private int update_main(string[] args) {
+
+    var assume_yes = false;
+    var package_names = new Vector<string>();
+    for(int i = 2; i < args.length; i++) {
+        var argument = args[i];
+        if(argument == "-y" || argument == "--yes") {
+            assume_yes = true;
+        }
+        else if(argument.has_prefix("-")) {
+            printerr(@"Unknown option \"$argument\"\n");
+            return update_usage();
+        }
+        else {
+            package_names.add(argument);
+        }
+    }
+
+    var progress = new Usm.Cli.ProgressBar();
+
+    Usm.SystemState state = null;
+    try {
+        state = new Usm.SystemState(paths);
+    }
+    catch(Error e) {
+        printerr(@"This system is not managed by usm: $(e.message)\n");
+        return 245;
+    }
+
+    try {
+        var resolver = new Usm.Resolver(new Usm.ResourceFinder());
+        foreach(var repo in state.get_repositories()) {
+            progress.begin_phase(@"Refreshing $(repo.name)");
+            state.refresh_list(repo, (f, c, t) => {
+                progress.update_phase(t > 0 ? (float)c / (float)t : 0.0f);
+            });
+            progress.phase_log(@"Refreshed $(repo.name) listing");
+            progress.end_phase();
+            var listing = state.get_latest_list(repo);
+            if(listing != null) {
+                resolver.load_listing(repo, listing);
+            }
+        }
+
+        // Cached packages can satisfy dependencies, including the old
+        // versions kept behind by earlier updates
+        resolver.load_cache(paths);
+
+        var installed_by_name = new Dictionary<string, Usm.CachedPackage>();
+        var installed_versions = new Dictionary<string, Usm.Version>();
+        foreach(var installed in state.get_installed_packages()) {
+            var manifest = installed.get_manifest();
+            Usm.Version seen;
+            if(installed_versions.try_get(manifest.name, out seen) && !manifest.version.greater_than(seen)) {
+                continue;
+            }
+            installed_by_name.set(manifest.name, state.resolve_installed(installed));
+            installed_versions.set(manifest.name, manifest.version);
+        }
+
+        var roots = new Vector<Usm.AbstractPackage>();
+        if(package_names.length > 0) {
+            foreach(var package_name in package_names) {
+                Usm.CachedPackage installed;
+                if(!installed_by_name.try_get(package_name, out installed)) {
+                    printerr(@"No installed package named \"$package_name\"\n");
+                    return 254;
+                }
+                var target = resolver.find_package(package_name);
+                if(target == null) {
+                    printerr(@"No package named \"$package_name\" found in any repository or the cache\n");
+                    return 254;
+                }
+                Usm.Version installed_version;
+                installed_versions.try_get(package_name, out installed_version);
+                if(!target.manifest.version.greater_than(installed_version)) {
+                    print(@"\"$package_name\" is already up to date.\n");
+                    continue;
+                }
+                roots.add(target);
+            }
+            if(roots.length == 0) {
+                return 0;
+            }
+        }
+        else {
+            foreach(var pair in installed_by_name) {
+                var target = resolver.find_package(pair.key);
+                if(target != null && target.manifest.version.greater_than(installed_versions[pair.key])) {
+                    roots.add(target);
+                }
+            }
+            if(roots.length == 0) {
+                print("All installed packages are up to date.\n");
+                return 0;
+            }
+        }
+
+        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;
+        }
+
+        // Build the transaction set WITHOUT downloading: dependencies
+        // already installed at the resolved version stay as-is (so an
+        // update plan names only what actually moves), and repository
+        // packages join as their cache directories carrying the listing
+        // manifest — the plan is confirmed before a single byte moves
+        var current_names = new HashSet<string>();
+        foreach(var package in resolution.install_order) {
+            Usm.Version installed_version;
+            if(installed_versions.try_get(package.manifest.name, out installed_version)
+                    && installed_version.compare(package.manifest.version) == 0) {
+                current_names.add(package.manifest.name);
+            }
+        }
+
+        var cached_packages = new HashSet<Usm.CachedPackage>();
+        uint download_count = 0;
+        int64 download_bytes = 0;
+        var download_sizes_known = true;
+        foreach(var package in resolution.install_order) {
+            if(current_names.contains(package.manifest.name)) {
+                continue;
+            }
+            if(package.repository == null) {
+                if(package.package_path != null) {
+                    cached_packages.add(new Usm.CachedPackage(Path.get_dirname((!)package.package_path)));
+                }
+                continue;
+            }
+            var path = state.generate_cache_path(package.manifest);
+            cached_packages.add(new Usm.CachedPackage(path) {
+                known_manifest = package.manifest
+            });
+            if(File.new_for_path(Path.build_filename(path, "package.usmc")).query_exists()) {
+                continue;
+            }
+            download_count++;
+            var size = install_package_download_size(package.repository, package.repository_entry);
+            if(size == null) {
+                download_sizes_known = false;
+            }
+            else {
+                download_bytes += size;
+            }
+        }
+
+        // The installed versions being replaced by a different version
+        var to_remove = new HashSet<Usm.CachedPackage>();
+        foreach(var package in resolution.install_order) {
+            Usm.CachedPackage installed;
+            Usm.Version installed_version;
+            if(!installed_by_name.try_get(package.manifest.name, out installed)) {
+                continue;
+            }
+            if(installed_versions.try_get(package.manifest.name, out installed_version)
+                    && installed_version.compare(package.manifest.version) != 0) {
+                to_remove.add(installed);
+            }
+        }
+
+        var install_order = new Vector<string>();
+        foreach(var package in resolution.install_order) {
+            install_order.add(package.manifest.name);
+        }
+
+        // The exact reverse: dependents are removed before their providers
+        var remove_order = new Vector<string>();
+        foreach(var package in resolution.removal_order) {
+            remove_order.add(package.manifest.name);
+        }
+
+        // Resources the chosen system packages will install before this
+        // transaction's first lot runs; the scheduler counts them as
+        // present so system-provided build dependencies do not look unmet
+        // at planning time
+        var preinstalled_resources = new HashSet<Usm.ResourceRef>();
+        foreach(var system_package in resolution.system_packages) {
+            foreach(var resource in system_package.resources) {
+                preinstalled_resources.add(resource);
+            }
+        }
+
+        // The material system install set — chosen packages plus every
+        // native dependency of a dependency — so the plan preview lists
+        // the whole native transaction and every material package gets
+        // its own progress action
+        var system_install_plan = new Vector<string>();
+        if(spm.enabled && resolution.system_packages.any()) {
+            var chosen_system_names = new Vector<string>();
+            foreach(var system_package in resolution.system_packages) {
+                chosen_system_names.add(system_package.name);
+            }
+            system_install_plan = spm.plan_install(chosen_system_names);
+        }
+        var chosen_system_names_set = new HashSet<string>();
+        foreach(var system_package in resolution.system_packages) {
+            chosen_system_names_set.add(system_package.name);
+        }
+        var system_dependencies = new Vector<string>();
+        foreach(var name in system_install_plan) {
+            if(!chosen_system_names_set.contains(name)) {
+                system_dependencies.add(name);
+            }
+        }
+
+        var transaction = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(paths),
+            preinstalled_resources = preinstalled_resources,
+            to_remove = to_remove,
+            to_install = cached_packages,
+            install_order = install_order,
+            remove_order = remove_order,
+            state = state,
+            system_package_manager = spm,
+            system_packages = resolution.system_packages,
+            system_install_plan = system_install_plan
+        };
+
+        // Planning before the prompt populates the rebuildDependants
+        // entries so the summary shows the whole transaction
+        try {
+            transaction.strategise();
+        }
+        catch(Usm.TransactionError e) {
+            printerr(@"$(e.message)\n");
+            return 240;
+        }
+
+        var summary = new Usm.TransactionSummary() {
+            to_install = cached_packages.to_vector(),
+            system_installs = resolution.system_packages,
+            system_dependencies = system_dependencies,
+            to_remove = to_remove.to_vector(),
+            to_rebuild = transaction.rebuilds,
+            download_count = download_count,
+            download_bytes = download_sizes_known ? download_bytes : -1
+        };
+        if(!Usm.Cli.confirm_transaction(summary, assume_yes)) {
+            print("Aborted.\n");
+            return 1;
+        }
+
+        // Only now that the plan is confirmed do the packages transfer
+        install_download_packages(state, resolution, current_names);
+
+        printerr("\nRunning transaction...\n");
+        transaction.progress_updated.connect(progress.on_transaction_progress);
+        transaction.run();
+        progress.finish();
+    }
+    catch(Error e) {
+        progress.fail();
+        printerr(@"Error: $(e.message)\n");
+        return 238;
+    }
+
+    return 0;
+}
+
+private int update_usage() {
+    printerr("USAGE:\n\tusm update [-y|--yes] [<packages>]\n");
+    return 255;
+}

+ 17 - 0
src/cli/meson.build

@@ -1,10 +1,27 @@
 
 sources = files('Cli.vala')
+sources += files('Help.vala')
 sources += files('Manifest.vala')
+sources += files('ManifestBump.vala')
+sources += files('ProgressBar.vala')
 sources += files('Repository.vala')
 sources += files('Install.vala')
+sources += files('Update.vala')
+sources += files('Remove.vala')
+sources += files('Downgrade.vala')
+sources += files('Rebuild.vala')
+sources += files('Clean.vala')
+sources += files('Confirm.vala')
+sources += files('Search.vala')
+sources += files('Provides.vala')
+sources += files('AddRepo.vala')
+sources += files('Deploy.vala')
+sources += files('Scaffold.vala')
+sources += files('GenConfig.vala')
+sources += files('Enroll.vala')
 
  deps = dependencies
  deps += usm_dep
+ deps += meson.get_compiler('vala').find_library('linux')
 
 executable('usm', sources, dependencies: deps, install: true)

+ 69 - 3
src/lib/Configuration.vala

@@ -1,19 +1,48 @@
 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; }
+
+        /**
+         * Seconds a single repository transfer may run before it is
+         * aborted; consumed by {@link CurlRepositoryClient} through
+         * {@link Repository.get_client}.
+         */
+        public int download_timeout { get; set; default = 300; }
 
         public static PropertyMapper<Configuration> get_mapper() {
             return PropertyMapper.build_for<Configuration>(cfg => {
                 cfg.map<bool>("is_managed", o => o.is_managed, (o, v) => o.is_managed = v);
+                cfg.map<int>("download_timeout", o => o.download_timeout, (o, v) => o.download_timeout = v)
+                    .undefined_when(o => o.download_timeout == 0)
+                    .when_undefined(o => o.download_timeout = 300);
                 cfg.map_properties_with<ManagedConfiguration>("managed", o => o.managed_config, (o, v) => o.managed_config = v, ManagedConfiguration.get_mapper())
                     .undefined_when(o => o.managed_config == null)
                     .when_undefined(o => o.managed_config = null);
+                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());
             });
         }
@@ -21,6 +50,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) {
@@ -49,9 +92,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());
+            });
+        }
+    }
 
-}
+}

+ 211 - 9
src/lib/Dependencies.vala

@@ -1,24 +1,226 @@
 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 {
+                var parsed = new ResourceRef(text);
+                parsed.ensure_valid_dependency();
+                return parsed;
+            }
+            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());
             });
         }
     }
-}
+}

+ 8 - 2
src/lib/Exectuables.vala

@@ -4,16 +4,19 @@ 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; }
         public string? post_install { get; set; }
         public string? acquire { get; set; }
+        public string? test { get; set; }
 
         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);
@@ -29,6 +32,9 @@ namespace Usm {
                 cfg.map<string?>("remove", o => o.remove, (o, v) => o.remove = v)
                     .undefined_when(o => o.remove == null)
                     .when_undefined(o => o.remove = null);
+                cfg.map<string?>("test", o => o.test, (o, v) => o.test = v)
+                    .undefined_when(o => o.test == null)
+                    .when_undefined(o => o.test = null);
                 cfg.set_constructor(() => new Executables());
             });
         }

+ 20 - 4
src/lib/File.vala

@@ -6,8 +6,8 @@ namespace Usm {
 
     public class ManifestFile {
 
-        public ManifestFilePathBase path_base { get; set; }
-        public string path { get; set; }
+        public ManifestFilePathBase? path_base { get; set; }
+        public string? path { get; set; }
         public ManifestFileType file_type { get; set; }
         public string? destination { get; set; }
         public Vector<RemoveType>? keep_on { get; set; }
@@ -15,8 +15,12 @@ namespace Usm {
 
         public static PropertyMapper<ManifestFile> get_mapper() {
             return PropertyMapper.build_for<ManifestFile>(cfg => {
-                cfg.map<string>("path", o => o.path, (o, v) => o.path = v); // TODO make nullable in some cases, add validtion (i.e. type != reg, and pathBase == as-expected)
-                cfg.map<string>("pathBase", o => o.path_base.to_string(), (o, v) => o.path_base = ManifestFilePathBase.from_string(v));
+                cfg.map<string>("path", o => o.path, (o, v) => o.path = v)
+                    .undefined_when(o => o.path == null)
+                    .when_undefined(o => o.path = null);
+                cfg.map<string>("pathBase", o => o.path_base != null ? o.path_base.to_string() : null, (o, v) => { if (v != null) o.path_base = ManifestFilePathBase.from_string(v); else o.path_base = null; })
+                    .undefined_when(o => o.path_base == null)
+                    .when_undefined(o => o.path_base = null);
                 cfg.map<string>("type", o => o.file_type.to_string(), (o, v) => o.file_type = ManifestFileType.from_string(v));
                 cfg.map<string>("dest", o => o.destination, (o, v) => o.destination = v)
                     .undefined_when(o => o.destination == null)
@@ -44,6 +48,18 @@ namespace Usm {
             }
             path = parts[1];
         }
+
+        public void validate() throws ManifestError {
+            // Validate that path and pathBase are provided when type is "reg"
+            if (file_type == ManifestFileType.REGULAR) {
+                if (path_base == null) {
+                    throw new ManifestError.INVALID_PATH_BASE("pathBase is required when type is 'reg'");
+                }
+                if (path == null && path_base != ManifestFilePathBase.AS_EXPECTED) {
+                    throw new ManifestError.INVALID_FILE_PATH("path is required when type is 'reg' and pathBase is not 'as-expected'");
+                }
+            }
+        }
         
     }
 

+ 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;
+            }
+        }
+    }
+}

+ 393 - 0
src/lib/Journal.vala

@@ -0,0 +1,393 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Usm {
+
+    /**
+     * Rollback journal for a {@link Transaction}: every install and
+     * remove records what it is about to change ({@link entry_install}
+     * and {@link entry_remove}), every file that would be overwritten or
+     * deleted is copied into the journal's backup area first
+     * ({@link backup_file}), and each operation that succeeded marks
+     * itself done ({@link complete}). When the transaction then fails,
+     * {@link rollback} undoes the recorded operations in reverse —
+     * deleting installed files, restoring overwritten and removed files
+     * from their backups, and re-creating `installed/` marks — while
+     * {@link describe_rollback} narrates what was undone.
+     *
+     * The journal owns `<backup-root>/<transaction-id>/` (with the
+     * default root `/var/usm/backup`, rooted like every other USM
+     * destination through {@link Paths}), holding `journal.json` plus
+     * each backed-up file mirrored at `<package-name>/<original-path>`.
+     * {@link finish} removes the whole directory on the success path; a
+     * rollback deliberately keeps it for inspection.
+     *
+     * What a journal cannot undo:
+     *
+     * * post-install and remove scripts may change the system beyond
+     *   their manifest's resources — system users, enabled services,
+     *   databases — and those side effects persist through a rollback;
+     * * system packages installed through the system package manager
+     *   are not journalable, they belong to that manager;
+     * * parent directories created for new files are left behind, and
+     *   empty `dir` provides are neither backed up nor re-created;
+     * * the `.usmc` downloads and build caches a transaction made stay
+     *   in the cache — harmless, and reusable by the retry;
+     * * a rollback step that itself fails (a missing or unreadable
+     *   backup, for instance) is reported and skipped, never retried.
+     */
+    public class Journal {
+
+        private const string OPERATION_INSTALL = "install";
+        private const string OPERATION_REMOVE = "remove";
+        /** Extension of the sidecar recording a backed-up symlink's target. */
+        private const string LINK_SIDECAR_SUFFIX = ".usm-link-target";
+
+        /** Directory holding every transaction's backup area, e.g. `/var/usm/backup`. */
+        public string backup_root { get; private set; }
+
+        /** This journal's unique directory name beneath {@link backup_root}. */
+        public string transaction_id { get; private set; }
+
+        /** The backup area `<backup-root>/<transaction-id>`, created by {@link begin}. */
+        public string directory { get; private set; }
+
+        private Vector<JournalEntry> entries = new Vector<JournalEntry>();
+        private Vector<string> undone = new Vector<string>();
+
+        public Journal(string backup_root) {
+            this.backup_root = backup_root;
+            transaction_id = @"$(new DateTime.now_utc().format_iso8601())-$(Uuid.string_random())";
+        }
+
+        /**
+         * Creates the backup directory and writes an empty journal: the
+         * first call of a transaction, before any operation runs.
+         */
+        public void begin() throws Error {
+            directory = Path.build_filename(backup_root, transaction_id);
+            File.new_for_path(directory).make_directory_with_parents();
+            write();
+        }
+
+        /**
+         * Records what installing {@link pkg} will do: the
+         * {@link files_created} destination paths do not exist yet, the
+         * {@link files_overwritten} paths do and will have their content
+         * replaced — back those up with {@link backup_file} before the
+         * install executes. {@link state_mark_path} is the `installed/`
+         * symlink {@link SystemState.mark_installed} will create and
+         * {@link previous_state_target} the target that symlink held
+         * before, null when nothing was marked (see
+         * {@link SystemState.read_installed_target}); a rollback
+         * re-links the previous target, or removes a fresh mark.
+         */
+        public void entry_install(CachedPackage pkg, string[] files_created, string[] files_overwritten,
+                string? state_mark_path = null, string? previous_state_target = null) throws Error {
+            var entry = new JournalEntry();
+            entry.operation = OPERATION_INSTALL;
+            entry.package_name = pkg.package_name;
+            entry.state_mark_path = state_mark_path;
+            entry.previous_state_target = previous_state_target;
+            foreach(var path in files_created) {
+                entry.files_created.add(path);
+            }
+            foreach(var path in files_overwritten) {
+                entry.files_overwritten.add(path);
+            }
+            entries.add(entry);
+            write();
+        }
+
+        /**
+         * Records what removing {@link pkg} will do: every
+         * {@link files_removed} path is deleted by the removal — back
+         * them up with {@link backup_file} before it executes.
+         * {@link state_mark_path} is the `installed/` symlink
+         * {@link SystemState.unmark_installed} will delete and
+         * {@link state_target} the target it held (null when nothing was
+         * marked); a rollback re-links it.
+         */
+        public void entry_remove(CachedPackage pkg, string[] files_removed,
+                string? state_mark_path = null, string? state_target = null) throws Error {
+            var entry = new JournalEntry();
+            entry.operation = OPERATION_REMOVE;
+            entry.package_name = pkg.package_name;
+            entry.state_mark_path = state_mark_path;
+            entry.state_target = state_target;
+            foreach(var path in files_removed) {
+                entry.files_removed.add(path);
+            }
+            entries.add(entry);
+            write();
+        }
+
+        /**
+         * Copies {@link file_path} into {@link package_name}'s backup
+         * area, preserving mode and ownership metadata. A symbolic link
+         * is recorded as a sidecar holding its target, so restoration
+         * re-creates the link itself rather than a copy of whatever it
+         * pointed at.
+         */
+        public void backup_file(string file_path, string package_name) throws Error {
+            var backup_path = backup_path_for(file_path, package_name);
+            var parent = File.new_for_path(backup_path).get_parent();
+            if(parent != null && !parent.query_exists()) {
+                parent.make_directory_with_parents();
+            }
+
+            var info = File.new_for_path(file_path)
+                .query_info(FileAttribute.STANDARD_TYPE + "," + FileAttribute.STANDARD_SYMLINK_TARGET, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+            if(info.get_file_type() == FileType.SYMBOLIC_LINK) {
+                FileUtils.set_contents(backup_path + LINK_SIDECAR_SUFFIX, info.get_symlink_target() ?? "");
+                return;
+            }
+            File.new_for_path(file_path).copy(File.new_for_path(backup_path), FileCopyFlags.ALL_METADATA);
+        }
+
+        /**
+         * Marks {@link package_name}'s latest uncompleted entry done: the
+         * operation succeeded, so a rollback has to undo it.
+         */
+        public void complete(string package_name) throws Error {
+            for(int i = (int)entries.length - 1; i >= 0; i--) {
+                if(entries[i].package_name == package_name && !entries[i].completed) {
+                    entries[i].completed = true;
+                    break;
+                }
+            }
+            write();
+        }
+
+        /**
+         * Undoes every recorded operation in reverse order — completed
+         * ones plus a trailing half-applied one whose own operation
+         * failed — removing again what was installed and installing
+         * again what was removed. Steps that fail are reported and
+         * skipped, never retried; the backup directory is kept
+         * afterwards for inspection. Follow with
+         * {@link describe_rollback}.
+         */
+        public void rollback() {
+            undone = new Vector<string>();
+            for(int i = (int)entries.length - 1; i >= 0; i--) {
+                var entry = entries[i];
+                try {
+                    if(entry.operation == OPERATION_INSTALL) {
+                        rollback_install(entry);
+                    }
+                    else {
+                        rollback_remove(entry);
+                    }
+                }
+                catch(Error e) {
+                    warning(@"[Usm] Could not roll back the $(entry.operation) of \"$(entry.package_name)\": $(e.message)");
+                }
+            }
+        }
+
+        /**
+         * A human-readable transcript of what the last {@link rollback}
+         * undid, one line per operation; when nothing was ever recorded
+         * the transaction had changed nothing, and the description says
+         * so instead.
+         */
+        public string describe_rollback() {
+            if(undone.length == 0) {
+                return "Nothing had been changed when the transaction failed, so there was nothing to roll back.";
+            }
+            var builder = new StringBuilder("Rolled back after the failure:\n");
+            foreach(var line in undone) {
+                builder.append("  ").append(line).append("\n");
+            }
+            return builder.str;
+        }
+
+        /**
+         * Removes the backup directory entirely — the success path. The
+         * {@link backup_root} goes with it when no other transaction is
+         * leaving a directory behind.
+         */
+        public void finish() throws Error {
+            Util.delete_tree(directory);
+            try {
+                File.new_for_path(backup_root).delete();
+            }
+            catch(Error e) {
+                // a non-empty root belongs to a concurrent transaction
+            }
+        }
+
+        private void rollback_install(JournalEntry entry) throws Error {
+            for(int i = (int)entry.files_created.length - 1; i >= 0; i--) {
+                delete_if_present(entry.files_created[i]);
+            }
+            for(int i = (int)entry.files_overwritten.length - 1; i >= 0; i--) {
+                restore_file(entry.files_overwritten[i], entry.package_name);
+            }
+            if(entry.state_mark_path != null) {
+                if(entry.previous_state_target == null) {
+                    delete_if_present((!)entry.state_mark_path);
+                }
+                else {
+                    relink((!)entry.state_mark_path, (!)entry.previous_state_target);
+                }
+            }
+
+            var parts = new Vector<string>();
+            parts.add(@"removed $(entry.files_created.length) created file(s)");
+            if(entry.files_overwritten.length > 0) {
+                parts.add(@"restored $(entry.files_overwritten.length) overwritten file(s)");
+            }
+            if(entry.state_mark_path != null) {
+                parts.add(entry.previous_state_target == null ? "removed the installed mark" : "restored the previous installed mark");
+            }
+            undone.add(@"uninstalled \"$(entry.package_name)\": $(string.joinv(", ", parts.to_array()))");
+        }
+
+        private void rollback_remove(JournalEntry entry) throws Error {
+            for(int i = (int)entry.files_removed.length - 1; i >= 0; i--) {
+                restore_file(entry.files_removed[i], entry.package_name);
+            }
+            if(entry.state_mark_path != null && entry.state_target != null) {
+                relink((!)entry.state_mark_path, (!)entry.state_target);
+            }
+
+            var parts = new Vector<string>();
+            parts.add(@"restored $(entry.files_removed.length) file(s)");
+            if(entry.state_mark_path != null && entry.state_target != null) {
+                parts.add("re-created the installed mark");
+            }
+            undone.add(@"reinstalled \"$(entry.package_name)\": $(string.joinv(", ", parts.to_array()))");
+        }
+
+        private void delete_if_present(string path) throws Error {
+            try {
+                File.new_for_path(path).delete();
+            }
+            catch(IOError.NOT_FOUND e) {
+            }
+        }
+
+        private void relink(string mark_path, string target) throws Error {
+            delete_if_present(mark_path);
+            File.new_for_path(mark_path).make_symbolic_link(target);
+        }
+
+        private void restore_file(string file_path, string package_name) throws Error {
+            var backup_path = backup_path_for(file_path, package_name);
+            var sidecar_path = backup_path + LINK_SIDECAR_SUFFIX;
+            if(File.new_for_path(sidecar_path).query_exists()) {
+                string target;
+                FileUtils.get_contents(sidecar_path, out target);
+                delete_if_present(file_path);
+                File.new_for_path(file_path).make_symbolic_link(target);
+                return;
+            }
+
+            var dest = File.new_for_path(file_path);
+            var parent = dest.get_parent();
+            if(parent != null && !parent.query_exists()) {
+                parent.make_directory_with_parents();
+            }
+            delete_if_present(file_path);
+            File.new_for_path(backup_path).copy(dest, FileCopyFlags.ALL_METADATA);
+        }
+
+        /** The backup location mirroring {@link file_path} under this journal's area. */
+        private string backup_path_for(string file_path, string package_name) {
+            var mirrored = file_path.has_prefix(Path.DIR_SEPARATOR_S) ? file_path.substring(1) : file_path;
+            return Path.build_filename(directory, package_name, mirrored);
+        }
+
+        private void write() throws Error {
+            var builder = new StringBuilder("[");
+            for(int i = 0; i < entries.length; i++) {
+                if(i > 0) {
+                    builder.append(",");
+                }
+                entries[i].write(builder);
+            }
+            builder.append("]");
+            FileUtils.set_contents(Path.build_filename(directory, "journal.json"), builder.str);
+        }
+
+        /** One recorded operation: everything a rollback needs to undo it. */
+        private class JournalEntry {
+            public string operation = "";
+            public string package_name = "";
+            public bool completed = false;
+            public Vector<string> files_created = new Vector<string>();
+            public Vector<string> files_overwritten = new Vector<string>();
+            public Vector<string> files_removed = new Vector<string>();
+            public string? state_mark_path = null;
+            public string? state_target = null;
+            public string? previous_state_target = null;
+
+            public void write(StringBuilder builder) {
+                var fields = new Vector<string>();
+                fields.add(json_field("operation", json_string(operation)));
+                fields.add(json_field("package", json_string(package_name)));
+                fields.add(json_field("status", json_string(completed ? "completed" : "in-progress")));
+                fields.add(json_field("files_created", json_string_array(files_created)));
+                fields.add(json_field("files_overwritten", json_string_array(files_overwritten)));
+                fields.add(json_field("files_removed", json_string_array(files_removed)));
+                if(state_mark_path != null) {
+                    fields.add(json_field("state_entry", json_string((!)state_mark_path)));
+                }
+                builder.append("{").append(string.joinv(",", fields.to_array())).append("}");
+            }
+
+            private static string json_field(string key, string value) {
+                return json_string(key) + ":" + value;
+            }
+
+            private static string json_string(string value) {
+                var builder = new StringBuilder("\"");
+                for(int i = 0; i < value.length; i++) {
+                    char c = value[i];
+                    switch(c) {
+                        case '"':
+                            builder.append("\\\"");
+                            break;
+                        case '\\':
+                            builder.append("\\\\");
+                            break;
+                        case '\b':
+                            builder.append("\\b");
+                            break;
+                        case '\f':
+                            builder.append("\\f");
+                            break;
+                        case '\n':
+                            builder.append("\\n");
+                            break;
+                        case '\r':
+                            builder.append("\\r");
+                            break;
+                        case '\t':
+                            builder.append("\\t");
+                            break;
+                        default:
+                            if(c < 0x20) {
+                                builder.append_printf("\\u%04x", (int)c);
+                            }
+                            else {
+                                builder.append_c(c);
+                            }
+                            break;
+                    }
+                }
+                return builder.append("\"").str;
+            }
+
+            private static string json_string_array(Vector<string> values) {
+                var escaped = new Vector<string>();
+                foreach(var value in values) {
+                    escaped.add(json_string(value));
+                }
+                return "[" + string.joinv(",", escaped.to_array()) + "]";
+            }
+        }
+    }
+}

+ 550 - 54
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 {
@@ -94,36 +114,137 @@ namespace Usm {
 				uint8[] buffer;
 				Posix.off_t offset;
 				while (archive.read_data_block (out buffer, out offset) == Archive.Result.OK) {
-					manifest_blob.append_byte_array(buffer[offset:]);
+					// buffer holds just this block; offset is the block's
+					// position within the entry, so slicing would drop every
+					// block after the first.
+					manifest_blob.append_byte_array(buffer);
 				}
 
                 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();
             foreach (var pair in obj) {
+                ManifestFile file;
                 if(pair.value.assignable_to<string>()) {
-                    provides[new ResourceRef(pair.key)] = new ManifestFile.from_string(pair.value.as<string>());
+                    file = new ManifestFile.from_string(pair.value.as<string>());
                 }
                 else {
-                    provides[new ResourceRef(pair.key)] = mapper.materialise(pair.value.as<Properties>());
+                    file = mapper.materialise(pair.value.as<Properties>());
                 }
+                // Validate the ManifestFile after creation
+                file.validate();
+                var key = new ResourceRef(pair.key);
+                key.ensure_valid_provide();
+                provides[key] = file;
             }
         }
 
+        /**
+         * Whether this manifest's provides satisfy the given dependency
+         * ref: {@link ResourceRef.satisfied_by} against every provide
+         * key (soname tails for `lib`, `==` declarations for `pc`),
+         * plus the pc fallback — a `pc:` provide WITHOUT a `==`
+         * declaration is assumed to carry this package's own version.
+         */
+        public bool satisfies(ResourceRef dependency) {
+            if(provides.any(r => dependency.satisfied_by(r.key))) {
+                return true;
+            }
+            if(dependency.constraint != null && dependency.resource_type == ResourceType.PKG_CONFIG) {
+                return provides.any(r => r.key.resource_type == ResourceType.PKG_CONFIG
+                    && r.key.constraint == null
+                    && r.key.resource == dependency.base_name
+                    && dependency.constraint.satisfied_by(version));
+            }
+            return false;
+        }
+
+        /**
+         * The provides keys as flat matching refs: `pc:` provides
+         * without a `==` declaration are materialised as
+         * `pc:<name>==<package version>`, so sets collected from this
+         * (transaction lot scheduling, removal planning) match both
+         * plain and constrained dependencies without carrying the
+         * manifest along.
+         */
+        public Enumerable<ResourceRef> effective_provides() {
+            return provides.select<ResourceRef>(p => {
+                if(p.key.resource_type == ResourceType.PKG_CONFIG && p.key.constraint == null) {
+                    try {
+                        return new ResourceRef(@"pc:$(p.key.resource)==$(version.to_string())");
+                    }
+                    catch(Error e) {
+                        return p.key;
+                    }
+                }
+                return p.key;
+            });
+        }
+
         private Properties map_from_provides_dict() {
             var dict = new PropertyDictionary();
             var mapper = ManifestFile.get_mapper();
             foreach (var pair in provides) {
                 try {
-                    dict.set_native<Properties>(pair.key.to_string(), mapper.map_from(pair.value));
+                    // Handle special case: when pathBase is as-expected, path is empty, and type is reg
+                    // output string "as-expected" instead of full JSON object
+                    if(pair.value.path_base == ManifestFilePathBase.AS_EXPECTED &&
+                       pair.value.path == "" &&
+                       pair.value.file_type == ManifestFileType.REGULAR) {
+                        dict.set_native<string>(pair.key.to_string(), "as-expected");
+                    } else {
+                        dict.set_native<Properties>(pair.key.to_string(), mapper.map_from(pair.value));
+                    }
                 }
                 catch(Error e) {
                     assert_not_reached();
@@ -133,19 +254,164 @@ namespace Usm {
         }
 
         
-        public Subprocess run_build(string build_path, Paths paths, SubprocessFlags flags) throws Error {
-            var path = Path.build_filename(Environment.get_current_dir(), executables.build);
+        /**
+         * 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, owned 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;
+            string effective_build_path = build_path;
+            
+            if (this.flags.contains(ManifestFlag.SIMPLE_BUILD_ENVIRONMENT)) {
+                // Copy the full source tree to the build directory
+                var source_dir = File.new_for_path(working_dir);
+                var build_dir = File.new_for_path(build_path);
+                
+                // Copy all files from source to build directory
+                copy_directory(source_dir, build_dir);
+                
+                // Use build directory as working directory
+                working_dir = build_path;
+            }
+            
+            var path = Path.build_filename(working_dir, executables.build);
+            
+            // Change to the working directory for subprocess execution
+            Environment.set_current_dir(working_dir);
             paths.set_envs();
-            var proc = new Subprocess.newv(new string[] { path, build_path }, flags);
-            return proc;
+            
+            // Check if NINJA_STYLE_PROGRESS flag is set and progress delegate is provided
+            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
+                if ((modified_flags & SubprocessFlags.STDOUT_SILENCE) != 0) {
+                    modified_flags = modified_flags & ~SubprocessFlags.STDOUT_SILENCE;
+                }
+                modified_flags = modified_flags | SubprocessFlags.STDOUT_PIPE;
+                
+                var proc = new Subprocess.newv(new string[] { path, Paths.ensure_trailing_slash(effective_build_path) }, modified_flags);
+                
+                // Start a new thread to monitor STDOUT for progress information
+                ThreadFunc<void> progress_thread_func = () => {
+                    try {
+                        var stdout_pipe = proc.get_stdout_pipe();
+                        if (stdout_pipe != null) {
+                            var dis = new DataInputStream(stdout_pipe);
+                            string line;
+                            
+                            // Read lines from STDOUT until the process ends
+                            while ((line = dis.read_line(null)) != null) {
+                                // Look for Ninja progress pattern: "[x/x] "
+                                if (line.has_prefix("[")) {
+                                    var end_bracket = line.index_of("]");
+                                    if (end_bracket > 1) {
+                                        var progress_str = line.substring(1, end_bracket - 1);
+                                        var parts = progress_str.split("/");
+                                        
+                                        if (parts.length == 2) {
+                                            int current_task = 0;
+                                            int total_tasks = 0;
+                                            
+                                            // Parse the current and total task numbers
+                                            if (int.try_parse(parts[0], out current_task) &&
+                                                int.try_parse(parts[1], out total_tasks) &&
+                                                total_tasks > 0) {
+                                                
+                                                // Calculate progress as a float between 0.0 and 1.0
+                                                float progress = (float)current_task / (float)total_tasks;
+                                                
+                                                // Ensure progress is within valid bounds
+                                                progress = float.max(0.0f, float.min(1.0f, progress));
+                                                
+                                                // Call the progress delegate with the calculated progress
+                                                progress_delegate(progress);
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    } catch (Error e) {
+                        // Log any errors during progress monitoring but don't fail the build
+                        warning(@"Error monitoring build progress: $(e.message)");
+                    }
+                };
+                
+                try {
+                    // Start the progress monitoring thread
+                    new Thread<void>(null, progress_thread_func);
+                } catch (Error e) {
+                    // If we can't start the thread, log a warning but continue with the build
+                    warning(@"Failed to start progress monitoring thread: $(e.message)");
+                }
+                
+                // Restore original working directory after subprocess completes
+                Environment.set_current_dir(original_working_dir);
+                
+                return proc;
+            } else {
+                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);
+                
+                return proc;
+            }
         }
 
         public Subprocess? run_rebuild(string build_path, SubprocessFlags flags) throws Error {
             if(executables.rebuild == null) {
                 return null;
             }
-            var path = Path.build_filename(Environment.get_current_dir(), executables.rebuild);
-            var proc = new Subprocess.newv(new string[] { path, build_path }, flags);
+            string original_working_dir = Environment.get_current_dir();
+            string working_dir = original_working_dir;
+            if (this.flags.contains(ManifestFlag.SIMPLE_BUILD_ENVIRONMENT)) {
+                working_dir = build_path;
+            }
+            var path = Path.build_filename(working_dir, executables.rebuild);
+            
+            // 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) }, verbose_flags(flags));
+            
+            // Restore original working directory after subprocess completes
+            Environment.set_current_dir(original_working_dir);
+            
             return proc;
         }
 
@@ -153,22 +419,86 @@ namespace Usm {
             if(executables.acquire == null) {
                 return null;
             }
-            var path = Path.build_filename(Environment.get_current_dir(), executables.acquire);
-            var proc = new Subprocess.newv(new string[] { path }, flags);
+            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 }, verbose_flags(flags));
             return proc;
         }
 
-        public Subprocess? run_install(string build_path, string install_path, Paths paths, InstallType type, SubprocessFlags flags) throws Error {
+        /**
+         * Spawns the manifest's install executable with
+         * {@link InstallType} appended to the build and install paths.
+         *
+         * When the manifest carries {@link ManifestFlag.NINJA_STYLE_PROGRESS}
+         * and a {@link progress_delegate} is supplied (and USM_VERBOSE is
+         * unset), stdout is piped and each meson-style `Installing X to Y`
+         * line reports `lines_parsed / estimated_total` — the estimate is
+         * the manifest's provides count doubled, since meson commonly
+         * installs several outputs per resource. Otherwise the flags pass
+         * through {@link verbose_flags} unchanged (silenced by default).
+         */
+        public Subprocess? run_install(string build_path, string install_path, Paths paths, InstallType type, SubprocessFlags flags, owned ProgressDelegate? progress_delegate = null) throws Error {
             if(executables.install == null) {
                 return null;
             }
-            var path = Path.build_filename(Environment.get_current_dir(), executables.install);
+            string original_working_dir = Environment.get_current_dir();
+            string working_dir = original_working_dir;
+            if (this.flags.contains(ManifestFlag.SIMPLE_BUILD_ENVIRONMENT)) {
+                working_dir = build_path;
+            }
+            var path = Path.build_filename(working_dir, executables.install);
 
             // Override destination environment variable
             var new_paths = paths.clone();
             new_paths.destination = install_path;
+
+            // 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, build_path, install_path, type.to_string() }, flags);
+
+            var parse_install_progress = progress_delegate != null
+                && this.flags != null
+                && this.flags.contains(ManifestFlag.NINJA_STYLE_PROGRESS)
+                && !verbose_requested();
+
+            var spawn_flags = flags;
+            if(parse_install_progress) {
+                if ((spawn_flags & SubprocessFlags.STDOUT_SILENCE) != 0) {
+                    spawn_flags = spawn_flags & ~SubprocessFlags.STDOUT_SILENCE;
+                }
+                spawn_flags = spawn_flags | SubprocessFlags.STDOUT_PIPE;
+            }
+            else {
+                spawn_flags = verbose_flags(flags);
+            }
+
+            var proc = new Subprocess.newv(new string[] { path, Paths.ensure_trailing_slash(build_path), Paths.ensure_trailing_slash(install_path), type.to_string() }, spawn_flags);
+
+            if(parse_install_progress) {
+                // Drain the pipe before returning: meson holds stdout
+                // open until it exits, so EOF aligns with the caller's
+                // wait_check
+                uint estimated_total = provides.count() * 2;
+                if(estimated_total < 1) {
+                    estimated_total = 1;
+                }
+                uint lines_parsed = 0;
+                var output = new DataInputStream(proc.get_stdout_pipe());
+                string line;
+                while((line = output.read_line(null)) != null) {
+                    if(!line.has_prefix("Installing ")) {
+                        continue;
+                    }
+                    lines_parsed++;
+                    var fraction = (float)lines_parsed / (float)estimated_total;
+                    progress_delegate(fraction > 1.0f ? 1.0f : fraction);
+                }
+            }
+
+            // Restore original working directory after subprocess completes
+            Environment.set_current_dir(original_working_dir);
+
             return proc;
         }
 
@@ -176,8 +506,20 @@ namespace Usm {
             if(executables.post_install == null) {
                 return null;
             }
-            var path = Path.build_filename(Environment.get_current_dir(), executables.post_install);
-            var proc = new Subprocess.newv(new string[] { path, build_path, type.to_string() }, flags);
+            string original_working_dir = Environment.get_current_dir();
+            string working_dir = original_working_dir;
+            if (this.flags.contains(ManifestFlag.SIMPLE_BUILD_ENVIRONMENT)) {
+                working_dir = build_path;
+            }
+            var path = Path.build_filename(working_dir, executables.post_install);
+            
+            // 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() }, verbose_flags(flags));
+            
+            // Restore original working directory after subprocess completes
+            Environment.set_current_dir(original_working_dir);
+            
             return proc;
         }
 
@@ -185,22 +527,83 @@ namespace Usm {
             if(executables.remove == null) {
                 return null;
             }
-            var path = Path.build_filename(Environment.get_current_dir(), executables.remove);
-            var proc = new Subprocess.newv(new string[] { path, type.to_string() }, flags);
+            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() }, verbose_flags(flags));
+            return proc;
+        }
+
+        public Subprocess? run_test(string build_path, SubprocessFlags flags) throws Error {
+            if(executables.test == null) {
+                return null;
+            }
+            string original_working_dir = Environment.get_current_dir();
+            string working_dir = original_working_dir;
+            if (this.flags.contains(ManifestFlag.SIMPLE_BUILD_ENVIRONMENT)) {
+                working_dir = build_path;
+            }
+            var path = Path.build_filename(working_dir, executables.test);
+            
+            // 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) }, verbose_flags(flags));
+            
+            // Restore original working directory after subprocess completes
+            Environment.set_current_dir(original_working_dir);
+            
             return proc;
         }
 
         public delegate void ResourceProgressCallback(ResourceRef resource, uint current_resource, uint total_resources, float resource_frac);
+
+        /**
+         * The destination paths {@link install_resources} will affect,
+         * split by what it will do to each: paths collected in
+         * {@link created} do not exist yet and will appear, paths in
+         * {@link overwritten} exist and will have their content
+         * replaced. The same ordering and the same
+         * {@link Paths.get_suggested_path_for_resource} resolution
+         * drive both methods, so a transaction journal can record — and
+         * back up — everything before the install runs. Directory
+         * provides are not reported: an empty directory is neither worth
+         * a backup nor worth deleting on rollback, and parent
+         * directories created implicitly for new files are left behind
+         * the same way.
+         */
+        public void report_install_resources(Paths paths, out Vector<string> created, out Vector<string> overwritten) {
+            created = new Vector<string>();
+            overwritten = new Vector<string>();
+            var seen = new HashSet<string>();
+            var install_order = provides.sort((a, b) => paths.get_suggested_path_for_resource(a.key).length - paths.get_suggested_path_for_resource(b.key).length);
+            foreach(var resource in install_order) {
+                if(resource.value.file_type == Usm.ManifestFileType.DIRECTORY) {
+                    continue;
+                }
+                var path = paths.get_suggested_path_for_resource(resource.key);
+                if(seen.contains(path)) {
+                    continue;
+                }
+                seen.add(path);
+                if(File.new_for_path(path).query_exists()) {
+                    overwritten.add(path);
+                }
+                else {
+                    created.add(path);
+                }
+            }
+        }
+
         public void install_resources(string source_path, string build_path, string? install_path, Paths paths, ResourceProgressCallback callback, bool dry_run = false) throws Error {
             // Install each resource speficied by the manifest
             var resource_count = provides.count();
             var resources_installed = 0;
 
             // Install from shortest path to longest path, to ensure directories are created before children
-            var install_order = provides.sort((a, b) => paths.get_suggested_path(a.key).length - paths.get_suggested_path(b.key).length);
+            var install_order = provides.sort((a, b) => paths.get_suggested_path_for_resource(a.key).length - paths.get_suggested_path_for_resource(b.key).length);
             foreach (var resource in install_order) {
                 callback(resource.key, resources_installed, resource_count, 0.0f);
-                var path = paths.get_suggested_path(resource.key);
+                var path = paths.get_suggested_path_for_resource(resource.key);
                 
                 if(resource.key.resource_type == ResourceType.TAG) {
                     // Ensure parent directories are created first
@@ -212,49 +615,71 @@ 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");
-                            }
-                            base_path = Path.build_filename(install_path, paths.get_suggested_path(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;
                     }
-                    var src = File.new_build_filename(base_path, resource.value.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);
 
-                    if(!src.query_exists()) {
+                    // Don't throw if an alternative format resource wasn't built
+                    var source_exists = src.query_exists();
+                    if(!source_exists) {
                         throw new ManifestError.INVALID_FILE_PATH(@"Expected to find file listed in manifest at \"$(src.get_path())\", but no such file was found.");
                     }
-
-                    if(!dry_run) {
-                        src.copy(dest, FileCopyFlags.OVERWRITE, null, (c, t) => callback(resource.key, resources_installed, resource_count, (float)c / (float)t));
+                    
+                    // Don't copy an alternative format resource if it has the same path as the non-alternative resource
+                    if(source_exists && !dry_run) {
+                        if(dest.query_exists()) {
+                            dest.delete();
+                        }
+                        var parent = dest.get_parent();
+                        if(!parent.query_exists()) {
+                            parent.make_directory_with_parents(null);
+                        }
+                        src.copy(dest, FileCopyFlags.ALL_METADATA, null, (c, t) => callback(resource.key, resources_installed, resource_count, (float)c / (float)t));
                     }
                 }
                 else if(resource.value.file_type == Usm.ManifestFileType.DIRECTORY) {
                     var dest = File.new_for_path(path);
-                    if(!dry_run) {
+                    if(!dry_run && !dest.query_exists()) {
                         dest.make_directory();
                     }
                 }
                 else if(resource.value.file_type == Usm.ManifestFileType.SYMBOLIC_LINK) {
                     var dest = File.new_for_path(path);
+                    if(!dry_run && dest.query_exists()) {
+                        dest.delete();
+                    }
                     if(!dry_run){
-                        dest.make_symbolic_link(resource.value.path);
+                        dest.make_symbolic_link(resource.value.destination ?? "");
                     }
                 }
                 else {
@@ -266,6 +691,26 @@ namespace Usm {
             }
         }
 
+        /**
+         * The destination paths {@link remove_resources} will delete:
+         * every non-directory provide that currently exists, files and
+         * symbolic links alike, in the same files-before-directories
+         * removal order. Paths whose destination is already gone (or a
+         * dangling link, which {@link remove_resources} cannot see
+         * either) are not reported — there is nothing to back up.
+         */
+        public Vector<string> report_remove_resources(Paths paths) {
+            var removed = new Vector<string>();
+            var non_directories = provides.where(r => r.value.file_type != Usm.ManifestFileType.DIRECTORY);
+            foreach(var resource in non_directories) {
+                var path = paths.get_suggested_path_for_resource(resource.key);
+                if(File.new_for_path(path).query_exists()) {
+                    removed.add(path);
+                }
+            }
+            return removed;
+        }
+
         public void remove_resources(Paths paths, ResourceProgressCallback callback) throws Error {
             var non_directories = provides.where(r => r.value.file_type != Usm.ManifestFileType.DIRECTORY).cache();
             var directories = provides.where(r => r.value.file_type == Usm.ManifestFileType.DIRECTORY).cache();
@@ -275,7 +720,7 @@ namespace Usm {
             // Delete files and symlinks first
             foreach (var resource in non_directories) {
                 callback(resource.key, current_operation, total_operations, 0.0f);
-                var path = paths.get_suggested_path(resource.key);
+                var path = paths.get_suggested_path_for_resource(resource.key);
                 var file = File.new_for_path(path);
                 if(file.query_exists()) {
                     file.delete();
@@ -287,7 +732,7 @@ namespace Usm {
             // Delete directories last
             foreach (var resource in directories) {
                 callback(resource.key, current_operation, total_operations, 0.0f);
-                var path = paths.get_suggested_path(resource.key);
+                var path = paths.get_suggested_path_for_resource(resource.key);
                 try {
                     var file = File.new_for_path(path);
                     if(file.query_exists()) {
@@ -302,6 +747,29 @@ namespace Usm {
             }
         }
 
+        private void copy_directory(File source, File destination) throws Error {
+            // Ensure destination directory exists
+            if (!destination.query_exists()) {
+                destination.make_directory_with_parents();
+            }
+
+            var enumerator = source.enumerate_children(FileAttribute.STANDARD_NAME + "," + FileAttribute.STANDARD_TYPE, FileQueryInfoFlags.NONE);
+            FileInfo file_info;
+
+            while ((file_info = enumerator.next_file()) != null) {
+                var source_child = source.get_child(file_info.get_name());
+                var destination_child = destination.get_child(file_info.get_name());
+
+                if (file_info.get_file_type() == FileType.DIRECTORY) {
+                    // Recursively copy subdirectories
+                    copy_directory(source_child, destination_child);
+                } else {
+                    // Copy files
+                    source_child.copy(destination_child, FileCopyFlags.OVERWRITE | FileCopyFlags.ALL_METADATA);
+                }
+            }
+        }
+
     }
 
 
@@ -369,9 +837,21 @@ namespace Usm {
         }
     }
 
+    public delegate void ProgressDelegate(float progress);
+
     public enum ManifestFlag {
         BUILD_IN_SOURCE_TREE,
-        SET_MANIFEST_PROPERTY_ENVS;
+        SET_MANIFEST_PROPERTY_ENVS,
+        NINJA_STYLE_PROGRESS,
+        SIMPLE_BUILD_ENVIRONMENT,
+        DATA_PACKAGE,
+        /**
+         * Installing or updating this package also rebuilds every installed
+         * package whose "depends" refs match any of its provides keys (see
+         * {@link SystemState.find_dependant_names}); the flag lives on the
+         * dependency, not on its dependants.
+         */
+        REBUILD_DEPENDANTS;
 
         public string to_string() {
             switch (this) {
@@ -379,6 +859,14 @@ namespace Usm {
                     return "buildInSourceTree";
                 case ManifestFlag.SET_MANIFEST_PROPERTY_ENVS:
                     return "setManifestPropertyEnvs";
+                case ManifestFlag.NINJA_STYLE_PROGRESS:
+                    return "ninjaStyleProgress";
+                case ManifestFlag.SIMPLE_BUILD_ENVIRONMENT:
+                    return "simpleBuildEnvironment";
+                case ManifestFlag.DATA_PACKAGE:
+                    return "dataPackage";
+                case ManifestFlag.REBUILD_DEPENDANTS:
+                    return "rebuildDependants";
                 default:
                     assert_not_reached();
             }
@@ -390,6 +878,14 @@ namespace Usm {
                     return ManifestFlag.BUILD_IN_SOURCE_TREE;
                 case "setManifestPropertyEnvs":
                     return ManifestFlag.SET_MANIFEST_PROPERTY_ENVS;
+                case "ninjaStyleProgress":
+                    return ManifestFlag.NINJA_STYLE_PROGRESS;
+                case "simpleBuildEnvironment":
+                    return ManifestFlag.SIMPLE_BUILD_ENVIRONMENT;
+                case "dataPackage":
+                    return ManifestFlag.DATA_PACKAGE;
+                case "rebuildDependants":
+                    return ManifestFlag.REBUILD_DEPENDANTS;
                 default:
                     throw new ManifestError.INVALID_FLAG(@"Unknown flag \"$str\".");
             }

+ 137 - 68
src/lib/Paths.vala

@@ -1,3 +1,5 @@
+using Invercargill.Mapping;
+
 namespace Usm {
 
     public class Paths {
@@ -10,6 +12,7 @@ namespace Usm {
         public string data { get; set; }
         public string info { get; set; }
         public string lib { get; set; }
+        public string canonlib { get; set; }
         public string man { get; set; }
         public string libexec { get; set; }
         public string locale { get; set; }
@@ -22,82 +25,94 @@ namespace Usm {
         public string usm_config_dir { get; set; }
 
         public void set_envs() {
-            Environment.set_variable("DESTDIR", destination, true);
-            Environment.set_variable("PREFIX", prefix, true);
-            Environment.set_variable("BINDIR", bin, true);
-            Environment.set_variable("INCLUDEDIR", include, true);
-            Environment.set_variable("DATADIR", data, true);
-            Environment.set_variable("INFODIR", info, true);
-            Environment.set_variable("LIBDIR", lib, true);
-            Environment.set_variable("MANDIR", man, true);
-            Environment.set_variable("LIBEXECDIR", libexec, true);
-            Environment.set_variable("LOCALEDIR", locale, true);
-            Environment.set_variable("LOCALSTATEDIR", local_state, true);
-            Environment.set_variable("SBINDIR", sbin, true);
-            Environment.set_variable("SHAREDSTATEDIR", shared_state, true);
-            Environment.set_variable("SYSCONFIGDIR", sys_config, true);
-            Environment.set_variable("TAGSDIR", tags, true);
+            Environment.set_variable("DESTDIR", Paths.ensure_trailing_slash(destination), true);
+            Environment.set_variable("PREFIX", Paths.ensure_trailing_slash(prefix), true);
+            Environment.set_variable("BINDIR", add_prefix(bin), true);
+            Environment.set_variable("INCLUDEDIR", add_prefix(include), true);
+            Environment.set_variable("DATADIR", add_prefix(data), true);
+            Environment.set_variable("INFODIR", add_prefix(info), true);
+            Environment.set_variable("LIBDIR", add_prefix(lib), true);
+            Environment.set_variable("CANONLIBDIR", add_prefix(canonlib), true);
+            Environment.set_variable("MANDIR", add_prefix(man), true);
+            Environment.set_variable("LIBEXECDIR", add_prefix(libexec), true);
+            Environment.set_variable("LOCALEDIR", add_prefix(locale), true);
+            Environment.set_variable("LOCALSTATEDIR", Paths.ensure_trailing_slash(local_state), true);
+            Environment.set_variable("SBINDIR", add_prefix(sbin), true);
+            Environment.set_variable("SHAREDSTATEDIR", Paths.ensure_trailing_slash(shared_state), true);
+            Environment.set_variable("SYSCONFIGDIR", Paths.ensure_trailing_slash(sys_config), true);
+            Environment.set_variable("TAGSDIR", add_prefix(tags), true);
+        }
+
+        public static string ensure_trailing_slash(string path) {
+            if (path == null || path.length == 0) {
+                return "/";
+            }
+            return path.has_suffix("/") ? path : path + "/";
+        }
+
+        public string add_prefix(string path) {
+            return ensure_trailing_slash(Path.build_filename(prefix, path));
         }
 
-        public string get_suggested_path(ResourceRef resource) {
-            switch (resource.resource_type) {
+        public string get_suggested_base_path_for_type(ResourceType type) {
+            switch (type) {
                 case ResourceType.ROOT_PATH:
-                    return Path.build_filename(destination, resource.resource);
+                    return destination;
                 case ResourceType.PATH:
-                    return Path.build_filename(destination, prefix, resource.resource);
+                    return Path.build_filename(destination, prefix);
                 case ResourceType.OPTIONAL:
-                    return Path.build_filename(destination, "opt", resource.resource);
+                    return Path.build_filename(destination, "opt");
                 case Usm.ResourceType.RESOURCE:
-                    return Path.build_filename(destination, prefix, data, resource.resource);
+                    return Path.build_filename(destination, prefix, data);
                 case ResourceType.CONFIGURATION:
-                    return Path.build_filename(destination, sys_config, resource.resource);
+                    return Path.build_filename(destination, sys_config);
                 case Usm.ResourceType.BINARY:
-                    return Path.build_filename(destination, prefix, bin, resource.resource);
+                    return Path.build_filename(destination, prefix, bin);
                 case Usm.ResourceType.SUPER_BINARY:
-                    return Path.build_filename(destination, prefix, sbin, resource.resource);
+                    return Path.build_filename(destination, prefix, sbin);
                 case Usm.ResourceType.LIBRARY:
-                    return Path.build_filename(destination, prefix, lib, resource.resource);
-                case Usm.ResourceType.LIBRARY_EXECUTABLE:
-                    return Path.build_filename(destination, prefix, libexec, resource.resource);
                 case Usm.ResourceType.LIBRARY_RESOURCE:
-                    return Path.build_filename(destination, prefix, lib, resource.resource);
+                    return Path.build_filename(destination, prefix, lib);
+                case Usm.ResourceType.LIBRARY_EXECUTABLE:
+                    return Path.build_filename(destination, prefix, libexec);
+                case Usm.ResourceType.CANONICAL_LIBRARY:
+                case Usm.ResourceType.CANONICAL_LIBRARY_RESOURCE:
+                    return Path.build_filename(destination, prefix, canonlib);
                 case Usm.ResourceType.INFO_PAGE:
-                    return Path.build_filename(destination, prefix, info, resource.resource);
+                    return Path.build_filename(destination, prefix, info);
                 case Usm.ResourceType.MANUAL_PAGE:
-                    return Path.build_filename(destination, prefix, man, resource.resource);
+                    return Path.build_filename(destination, prefix, man);
                 case Usm.ResourceType.LOCALE:
-                    return Path.build_filename(destination, prefix, locale, resource.resource);
+                    return Path.build_filename(destination, prefix, locale);
                 case Usm.ResourceType.APPLICATION:
-                    return Path.build_filename(destination, prefix, "share", "applications", resource.resource);
+                    return Path.build_filename(destination, prefix, "share", "applications");
                 case Usm.ResourceType.INCLUDE:
-                    return Path.build_filename(destination, prefix, include, resource.resource);
+                    return Path.build_filename(destination, prefix, include);
                 case Usm.ResourceType.PKG_CONFIG:
-                    return Path.build_filename(destination, prefix, lib, "pkgconfig", resource.resource);
+                    return Path.build_filename(destination, prefix, lib, "pkgconfig");
                 case Usm.ResourceType.VALA_API:
-                    return Path.build_filename(destination, prefix, "share", "vala", "vapi", resource.resource);
+                    return Path.build_filename(destination, prefix, "share", "vala", "vapi");
                 case Usm.ResourceType.GOBJECT_IR:
-                    return Path.build_filename(destination, prefix, "share", "gir-1.0", resource.resource);
+                    return Path.build_filename(destination, prefix, "share", "gir-1.0");
                 case Usm.ResourceType.TYPELIB:
-                    return Path.build_filename(destination, prefix, lib, "girepository-1.0", resource.resource);
+                    return Path.build_filename(destination, prefix, lib, "girepository-1.0");
+                case Usm.ResourceType.GIO_MODULE:
+                    return Path.build_filename(destination, prefix, lib, "gio", "modules");
                 case Usm.ResourceType.TAG:
-                    return get_tag_file_path(resource.resource);
+                    return Path.build_filename(destination, prefix, tags);
                 default:
                     assert_not_reached();
             }
         }
 
-        public ResourceType get_suggested_resource_type(string path) {
-            var name = Path.get_basename(path);
-            var types = ResourceType.get_types()
-                .where(t => get_suggested_path(new ResourceRef.with_type(t, name)) == path)
-                .to_vector();
-
-            if(types.no()) {
-                return ResourceType.ROOT_PATH;
+        public string get_suggested_path_for_resource(ResourceRef resource) {
+            if (resource.resource_type == ResourceType.TAG) {
+                return get_tag_file_path(resource.base_name);
             }
-            return types.first_or_default();
+            return Path.build_filename(get_suggested_base_path_for_type(resource.resource_type), resource.base_name);
         }
 
+
         public string get_tag_file_path(string tag) {
             var tag_path = tag.replace(".", "/");
             tag_path += ".tag";
@@ -117,7 +132,8 @@ namespace Usm {
             include = "include";
             data = "share";
             info = "share/info";
-            lib = sizeof(void*) == 8 ? "lib64" : "lib";
+            lib = "lib";
+            canonlib = "lib";
             man = "share/man";
             libexec = "libexec";
             locale = "share/locale";
@@ -130,26 +146,22 @@ namespace Usm {
             usm_config_dir = "/etc/usm";
         }
 
-        public Paths.usm_environ() {
-            var defaults = new Paths.defaults();
-            
-            destination = Environment.get_variable("USM_DESTDIR") ?? defaults.destination;
-            prefix = Environment.get_variable("USM_PREFIX") ?? defaults.prefix;
-            bin = Environment.get_variable("USM_BINDIR") ?? defaults.bin;
-            include = Environment.get_variable("USM_INCLUDEDIR") ?? defaults.include;
-            data = Environment.get_variable("USM_DATADIR") ?? defaults.data;
-            info = Environment.get_variable("USM_INFODIR") ?? defaults.info;
-            lib = Environment.get_variable("USM_LIBDIR") ?? defaults.lib;
-            man = Environment.get_variable("USM_MANDIR") ?? defaults.man;
-            libexec = Environment.get_variable("USM_LIBEXECDIR") ?? defaults.libexec;
-            locale = Environment.get_variable("USM_LOCALEDIR") ?? defaults.locale;
-            local_state = Environment.get_variable("USM_LOCALSTATEDIR") ?? defaults.local_state;
-            sbin = Environment.get_variable("USM_SBINDIR") ?? defaults.sbin;
-            shared_state = Environment.get_variable("USM_SHAREDSTATEDIR") ?? defaults.shared_state;
-            sys_config = Environment.get_variable("USM_SYSCONFIGDIR") ?? defaults.sys_config;
-            tags = Environment.get_variable("USM_TAGSDIR") ?? defaults.tags;
-
-            usm_config_dir = Environment.get_variable("USM_CONFIGDIR") ?? defaults.usm_config_dir;
+        public Paths.usm_environ(Paths? defaults = null) {
+            var fallback = defaults ?? new Paths.defaults();
+            override_from_environment(fallback);
+        }
+
+        private Paths.empty() {}
+
+        public Paths() {
+            var provisional_paths = new Paths.usm_environ();
+            try {
+                var config = new Configuration.from_paths(provisional_paths);
+                override_from_environment(config.paths);
+            }
+            catch {
+                override_from_environment(provisional_paths);
+            }
         }
 
         public Paths clone() {
@@ -161,6 +173,7 @@ namespace Usm {
                 data = data,
                 info = info,
                 lib = lib,
+                canonlib = canonlib,
                 man = man,
                 libexec = libexec,
                 locale = locale,
@@ -172,6 +185,62 @@ namespace Usm {
                 usm_config_dir = usm_config_dir,
             };
         }
+
+        private void override_from_environment(Paths fallback) {
+            destination = Environment.get_variable("USM_DESTDIR") ?? fallback.destination;
+            prefix = Environment.get_variable("USM_PREFIX") ?? fallback.prefix;
+            bin = Environment.get_variable("USM_BINDIR") ?? fallback.bin;
+            include = Environment.get_variable("USM_INCLUDEDIR") ?? fallback.include;
+            data = Environment.get_variable("USM_DATADIR") ?? fallback.data;
+            info = Environment.get_variable("USM_INFODIR") ?? fallback.info;
+            lib = Environment.get_variable("USM_LIBDIR") ?? fallback.lib;
+            canonlib = Environment.get_variable("USM_CANONLIBDIR") ?? fallback.canonlib;
+            man = Environment.get_variable("USM_MANDIR") ?? fallback.man;
+            libexec = Environment.get_variable("USM_LIBEXECDIR") ?? fallback.libexec;
+            locale = Environment.get_variable("USM_LOCALEDIR") ?? fallback.locale;
+            local_state = Environment.get_variable("USM_LOCALSTATEDIR") ?? fallback.local_state;
+            sbin = Environment.get_variable("USM_SBINDIR") ?? fallback.sbin;
+            shared_state = Environment.get_variable("USM_SHAREDSTATEDIR") ?? fallback.shared_state;
+            sys_config = Environment.get_variable("USM_SYSCONFIGDIR") ?? fallback.sys_config;
+            tags = Environment.get_variable("USM_TAGSDIR") ?? fallback.tags;
+            usm_config_dir = Environment.get_variable("USM_CONFIGDIR") ?? fallback.usm_config_dir;
+        }
+
+        public static PropertyMapper<Paths> get_mapper() {
+            return new PropertyMapperBuilder<Paths>()
+                .map<string?>("prefix", o => o.prefix, (o, v) => o.prefix = v)
+                    .when_undefined(o => o.prefix = o.prefix)
+                .map<string?>("bin", o => o.bin, (o, v) => o.bin = v)
+                    .when_undefined(o => o.bin = o.bin)
+                .map<string?>("include", o => o.include, (o, v) => o.include = v)
+                    .when_undefined(o => o.include = o.include)
+                .map<string?>("data", o => o.data, (o, v) => o.data = v)
+                    .when_undefined(o => o.data = o.data)
+                .map<string?>("info", o => o.info, (o, v) => o.info = v)
+                    .when_undefined(o => o.info = o.info)
+                .map<string?>("lib", o => o.lib, (o, v) => o.lib = v)
+                    .when_undefined(o => o.lib = o.lib)
+                .map<string?>("canonlib", o => o.canonlib, (o, v) => o.canonlib = v)
+                    .when_undefined(o => o.canonlib = o.canonlib)
+                .map<string?>("man", o => o.man, (o, v) => o.man = v)
+                    .when_undefined(o => o.man = o.man)
+                .map<string?>("libexec", o => o.libexec, (o, v) => o.libexec = v)
+                    .when_undefined(o => o.libexec = o.libexec)
+                .map<string?>("locale", o => o.locale, (o, v) => o.locale = v)
+                    .when_undefined(o => o.locale = o.locale)
+                .map<string?>("localState", o => o.local_state, (o, v) => o.local_state = v)
+                    .when_undefined(o => o.local_state = o.local_state)
+                .map<string?>("sbin", o => o.sbin, (o, v) => o.sbin = v)
+                    .when_undefined(o => o.sbin = o.sbin)
+                .map<string?>("sharedState", o => o.shared_state, (o, v) => o.shared_state = v)
+                    .when_undefined(o => o.shared_state = o.shared_state)
+                .map<string?>("sysConfig", o => o.sys_config, (o, v) => o.sys_config = v)
+                    .when_undefined(o => o.sys_config = o.sys_config)
+                .map<string?>("tags", o => o.tags, (o, v) => o.tags = v)
+                    .when_undefined(o => o.tags = o.tags)
+                .set_constructor(() => new Paths.defaults())
+                .build();
+        }
     }
 
 }

+ 49 - 0
src/lib/PcFile.vala

@@ -0,0 +1,49 @@
+namespace Usm {
+
+    /**
+     * Minimal pkg-config file reader: extracts the `Version:` field of
+     * a `.pc` file, the only part USM consults (dependency constraints
+     * and provides validation). Key parsing follows pkg-config's own
+     * conventions — `Keyword: value` with optional surrounding
+     * whitespace, `#` comments — without any variable expansion: a
+     * version line containing `${...}` references yields null rather
+     * than a guess.
+     */
+    public class PcFile {
+
+        /**
+         * The version declared by the `Version:` keyword of the .pc
+         * file at {@link path}, or null when the file cannot be read,
+         * carries no `Version:` keyword, or declares something that is
+         * not a dotted numeric version.
+         */
+        public static Version? read_version(string path) {
+            string contents = null;
+            try {
+                if(!FileUtils.get_contents(path, out contents)) {
+                    return null;
+                }
+            }
+            catch(Error e) {
+                return null;
+            }
+            foreach(var raw_line in contents.split("\n")) {
+                var line = raw_line.chomp().chug();
+                if(line.has_prefix("#") || !line.has_prefix("Version:")) {
+                    continue;
+                }
+                var value = line.substring("Version:".length).chomp().chug();
+                if(value.length == 0 || value.contains("${")) {
+                    return null;
+                }
+                try {
+                    return new Version.from_string(value);
+                }
+                catch(Error e) {
+                    return null;
+                }
+            }
+            return null;
+        }
+    }
+}

+ 233 - 0
src/lib/Repository/CurlRepositoryClient.vala

@@ -0,0 +1,233 @@
+namespace Usm {
+
+    /**
+     * A {@link RepositoryClient} that fetches over HTTP/HTTPS by shelling
+     * out to `curl`.
+     *
+     * GIO cannot fetch `http(s)://` URIs on platforms whose GIO build has
+     * no TLS backend (musl/alpine images report "Operation not supported"),
+     * so {@link Repository.get_client} routes remote schemes here while
+     * `file://` stays on {@link GioRepositoryClient}.
+     *
+     * Each transfer runs `curl -fsSL --max-time {@link timeout_seconds} --
+     * <url>` and streams stdout into a `<destination>.part` file, counting
+     * bytes as they are written: `-s` keeps curl completely silent during
+     * the transfer and `-S` lets genuine failures print one error line to
+     * stderr, which is captured and surfaced through the thrown error.
+     * Progress reports carry the exact byte counts — current from the
+     * pump's own counter, total from a `Content-Length` probe before the
+     * transfer — and the finished part file is moved over the destination
+     * only after curl exits successfully.
+     */
+    public class CurlRepositoryClient : RepositoryClient {
+
+        /** Default {@link timeout_seconds} when no configuration is supplied. */
+        public const int DEFAULT_TIMEOUT_SECONDS = 300;
+
+        /**
+         * Seconds a single transfer (and the size probe before it) may run
+         * before curl aborts it; {@link Repository.get_client} seeds this
+         * from {@link Configuration.download_timeout}.
+         */
+        public int timeout_seconds { get; set; default = DEFAULT_TIMEOUT_SECONDS; }
+
+        public CurlRepositoryClient(Repository repo, int timeout = DEFAULT_TIMEOUT_SECONDS) {
+            repository_config = repo;
+            timeout_seconds = timeout;
+        }
+
+        public override void download_repository_listing(string path, RepositoryClientProgressCallback? callback = null) throws Error {
+            download("PACKAGES.usml", path, callback);
+        }
+
+        public override void download_package(string path, RepositoryListingEntry package, RepositoryClientProgressCallback? callback = null) throws Error {
+            download(package.path, path, callback);
+        }
+
+        private void download(string remote_path, string destination, RepositoryClientProgressCallback? callback) throws Error {
+            var url = get_for_path(remote_path).to_string();
+            var filename = Path.get_basename(remote_path);
+            var part_path = @"$destination.part";
+
+            // Exact total up front where the server states one; transfers
+            // without Content-Length report progress against 0 until the
+            // final report
+            var total_bytes = content_length(url);
+
+            var launcher = new SubprocessLauncher(SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
+            var argv = new string[] {
+                "curl", "-fsSL",
+                "--max-time", timeout_seconds.to_string(),
+                "--", url
+            };
+            Subprocess process;
+            try {
+                process = launcher.spawnv(argv);
+            }
+            catch(Error e) {
+                throw new IOError.NOT_FOUND(@"Unable to launch curl to fetch \"$url\" (is curl installed?): $(e.message)");
+            }
+
+            var output = File.new_for_path(part_path).replace(null, false, FileCreateFlags.REPLACE_DESTINATION);
+            var loop = new MainLoop();
+            uint64 downloaded = 0;
+            var failure = "";
+            var errors = new StringBuilder();
+            // Both pipes must drain before curl is waited on: stdout closes
+            // at end-of-body, while an error line can still land on stderr
+            var pumps_running = 2;
+
+            ProgressEmitter report = () => {
+                if(callback == null) {
+                    return;
+                }
+                callback(filename, (int64)downloaded, (int64)total_bytes);
+            };
+
+            ChunkConsumer consume_body = (data, length) => {
+                var chunk = data[0:length];
+                size_t written;
+                try {
+                    output.write_all(chunk, out written);
+                    downloaded += written;
+                    report();
+                }
+                catch(Error e) {
+                    failure = @"writing \"$part_path\": $(e.message)";
+                }
+            };
+
+            Completion finished = () => {
+                pumps_running--;
+                if(pumps_running > 0) {
+                    return;
+                }
+                process.wait_check_async.begin(null, (source, result) => {
+                    try {
+                        process.wait_check_async.end(result);
+                    }
+                    catch(Error e) {
+                        failure = failure.length > 0 ? failure : e.message;
+                    }
+                    loop.quit();
+                });
+            };
+
+            pump(process.get_stderr_pipe(), (data, length) => {
+                // -sS leaves stderr error text only, and only failures talk
+                if(errors.len < 512) {
+                    errors.append_len((string)data, length);
+                }
+            }, finished);
+
+            pump(process.get_stdout_pipe(), consume_body, () => {
+                try {
+                    output.close();
+                }
+                catch(Error e) {
+                    failure = @"closing \"$part_path\": $(e.message)";
+                }
+                finished();
+            });
+
+            loop.run();
+
+            if(failure.length > 0 || errors.len > 0 && !process.get_successful()) {
+                try {
+                    output.close();
+                }
+                catch(Error e) {
+                }
+                try {
+                    File.new_for_path(part_path).delete();
+                }
+                catch(Error e) {
+                }
+                var detail = errors.len > 0 ? ": " + errors.str.strip() : "";
+                throw new IOError.FAILED(@"curl failed while fetching \"$url\": $(failure.length > 0 ? failure : "transfer error")$detail");
+            }
+
+            // A transfer without Content-Length can only state its total
+            // once every byte is on disk
+            if(total_bytes == 0) {
+                total_bytes = downloaded;
+            }
+            report();
+            File.new_for_path(part_path).move(File.new_for_path(destination), FileCopyFlags.OVERWRITE);
+        }
+
+        private delegate void ProgressEmitter();
+
+        private delegate void ChunkConsumer(uint8[] data, ssize_t length);
+
+        private delegate void Completion();
+
+        /** Reads a pipe to EOF in 8 KiB chunks, handing each chunk to {@link consume} before {@link completed}. */
+        private async void pump(owned InputStream pipe, owned ChunkConsumer consume, owned Completion completed) {
+            while(true) {
+                var buffer = new uint8[8192];
+                ssize_t read;
+                try {
+                    read = yield pipe.read_async(buffer);
+                }
+                catch(Error e) {
+                    break;
+                }
+                if(read <= 0) {
+                    break;
+                }
+                consume(buffer, read);
+            }
+            completed();
+        }
+
+        /**
+         * The URL's Content-Length via `curl -I`, or 0 when the server does
+         * not state one. The probe is silent: headers are consumed from the
+         * pipe and any stderr is drained and discarded — a failed probe
+         * merely leaves the total unknown.
+         */
+        private uint64 content_length(string url) {
+            try {
+                var launcher = new SubprocessLauncher(SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
+                var process = launcher.spawnv(new string[] {
+                    "curl", "-fsSI",
+                    "--max-time", timeout_seconds.to_string(),
+                    "--", url
+                });
+                var headers = new DataInputStream(process.get_stdout_pipe());
+                string? found = null;
+                while(true) {
+                    var header = headers.read_line();
+                    if(header == null) {
+                        break;
+                    }
+                    var separator = header.index_of(":");
+                    if(separator < 1 || header.substring(0, separator).down() != "content-length") {
+                        continue;
+                    }
+                    found = header.substring(separator + 1).strip();
+                }
+                process.get_stderr_pipe().read_bytes(1);
+                if(!process.wait()) {
+                    return 0;
+                }
+                return found != null ? uint64.parse(found) : 0;
+            }
+            catch(Error e) {
+            }
+            return 0;
+        }
+
+        private Uri get_for_path(string path) throws Error {
+            var uri = Uri.parse(repository_config.url, UriFlags.NONE);
+            // A scheme-plus-host URL ("http://h:1") has an empty path, and a
+            // relative joined path would make Uri.build reject the URI
+            var base_path = uri.get_path() ?? "/";
+            if(base_path.length == 0) {
+                base_path = "/";
+            }
+            return Uri.build(UriFlags.NONE, uri.get_scheme(), uri.get_userinfo(), uri.get_host(), uri.get_port(), Path.build_filename(base_path, path), uri.get_query(), uri.get_fragment());
+        }
+    }
+}

+ 12 - 3
src/lib/Repository/Repository.vala

@@ -30,18 +30,27 @@ namespace Usm {
             Repository.get_mapper().map_into(this, element.as<Invercargill.Properties>());
         }
 
-        public RepositoryClient get_client() throws Error {
+        /**
+         * The client for this repository's URL scheme: `http`/`https` via
+         * curl, `file` via GIO, anything else rejected. When a
+         * {@link configuration} is supplied its
+         * {@link Configuration.download_timeout} seeds the curl client's
+         * transfer timeout.
+         */
+        public RepositoryClient get_client(Configuration? configuration = null) throws Error {
             var uri = Uri.parse(url, UriFlags.NONE);
             var scheme = uri.get_scheme();
 
             switch (scheme) {
                 case "http":
                 case "https":
+                    // GIO builds without a TLS backend (musl/alpine) cannot
+                    // fetch remote URIs, so HTTP goes through curl
+                    return new CurlRepositoryClient(this, configuration?.download_timeout ?? CurlRepositoryClient.DEFAULT_TIMEOUT_SECONDS);
                 case "file":
-                case "ftp":
                     return new GioRepositoryClient(this);
                 default:
-                    assert_not_reached();
+                    throw new IOError.NOT_SUPPORTED(@"Repository \"$name\" uses the unsupported scheme \"$scheme\" (expected http, https or file)");
             }
         }
 

+ 11 - 1
src/lib/Repository/RepositoryListing.vala

@@ -72,15 +72,25 @@ namespace Usm {
         public Manifest manifest { get; set; }
         public BinaryData sha512sum { get; set; }
 
+        /**
+         * The .usmc's size in bytes, recorded by `usm repository
+         * build-list` when it stats the package; 0 marks listings built
+         * before the field existed, so consumers fall back to probing the
+         * repository for the size.
+         */
+        public uint64 size { get; set; default = 0; }
+
 
         public static PropertyMapper<RepositoryListingEntry> get_mapper() {
             return PropertyMapper.build_for<RepositoryListingEntry>(cfg => {
                 cfg.map<string>("path", o => o.path, (o, v) => o.path = v);
                 cfg.map_properties_with<Manifest>("manifest", o => o.manifest, (o, v) => o.manifest = v, Manifest.get_mapper());
                 cfg.map<string>("sha512", o => o.sha512sum.to_base64(), (o, v) => o.sha512sum = Wrap.base64_string(v));
+                cfg.map<uint64?>("size", o => o.size, (o, v) => o.size = v)
+                    .when_undefined(o => o.size = 0);
                 cfg.set_constructor(() => new RepositoryListingEntry());
             });
-        }       
+        }
     }
 
     public class RepositoryListingSignature {

+ 810 - 38
src/lib/Resolver.vala

@@ -3,68 +3,816 @@ 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;
+
+        /**
+         * Optional progress callback fired once per resolved package as
+         * `resolved_count / estimated_total`, where the estimate is the
+         * number of roots times two (a rough dependency multiplier) and
+         * the final report is clamped to 1.0 when resolution completes.
+         * Null (the default) disables reporting.
+         */
+        public owned ProgressDelegate? resolution_progress { get; set; }
 
-        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)));
+        /** Estimated package count for {@link resolution_progress}: roots x 2, at least 1. */
+        private uint resolution_estimated_total = 1;
+
+        /** 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;
         }
 
         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.satisfies(resource)) {
+                    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;
+            resolution_estimated_total = roots.length * 2;
+            if(resolution_estimated_total < 1) {
+                resolution_estimated_total = 1;
+            }
 
-            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) || dep.is_satisfied()) {
-                        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);
+
+            if(resolution_progress != null) {
+                resolution_progress(1.0f);
+            }
+
+            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.satisfies(resource)) {
+                            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()))) {
+                // Version-constrained refs have no SPM translation yet:
+                // shims match names exactly, so querying them would only
+                // yield misleading not-founds. They resolve from local
+                // presence or USM packages.
+                if(resource.constraint != null) {
+                    continue;
+                }
+                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);
+            report_resolution_progress();
+
+            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());
+                }
+            }
+        }
+
+        /**
+         * Fires {@link resolution_progress} for the packages processed
+         * so far, clamped to 1.0; the estimate deliberately
+         * over-counts shallow closures so the bar never sits full
+         * before resolution is done.
+         */
+        private void report_resolution_progress() {
+            if(resolution_progress == null) {
+                return;
+            }
+            var fraction = (float)state.processed.count() / (float)resolution_estimated_total;
+            resolution_progress(fraction > 1.0f ? 1.0f : fraction);
+        }
+
+        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.satisfies(resource)) {
+                            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> {
@@ -77,19 +825,38 @@ namespace Usm {
             add(new AbstractPackage.from_repository(repository, entry));
         }
 
-        public bool is_satisfied() {
-            return all(check_satisfied);
+        /**
+         * 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 => {
+                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;
+            });
         }
 
-        private bool check_satisfied(AbstractPackage package) {
-            return package.manifest.dependencies.build.all(d => provides(d) || d.is_satisfied()) &&
-                package.manifest.dependencies.manage.all(d => provides(d) || d.is_satisfied()) &&
-                package.manifest.dependencies.runtime.all(d => provides(d) || d.is_satisfied());
-
+        public bool provides(ResourceRef resource) {
+            return any(p => p.manifest.satisfies(resource));
         }
 
-        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();
+            }
         }
 
     }
@@ -104,7 +871,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) {
@@ -113,6 +880,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;
+        }
+
     }
 
-}
+}

+ 360 - 0
src/lib/ResourceFinder.vala

@@ -0,0 +1,360 @@
+using Invercargill;
+namespace Usm {
+
+    public class ResourceFinder {
+        public Paths paths { get; set; }
+
+        public ResourceFinder(Paths? paths = null) {
+            this.paths = paths ?? new Paths();
+        }
+
+        public bool has_resource(ResourceRef resource_ref) {
+            return locate_resource(resource_ref) != null;
+        }
+
+        public string? locate_resource(ResourceRef resource_ref) {
+            switch (resource_ref.resource_type) {
+                case ResourceType.ROOT_PATH:
+                    return locate_rootpath(resource_ref);
+                case ResourceType.PATH:
+                    return locate_path(resource_ref);
+                case ResourceType.OPTIONAL:
+                    return locate_opt(resource_ref);
+                case ResourceType.RESOURCE:
+                    return locate_res(resource_ref);
+                case ResourceType.CONFIGURATION:
+                    return locate_cfg(resource_ref);
+                case ResourceType.BINARY:
+                    return locate_bin(resource_ref);
+                case ResourceType.SUPER_BINARY:
+                    return locate_sbin(resource_ref);
+                case ResourceType.LIBRARY:
+                    return locate_lib(resource_ref);
+                case ResourceType.LIBRARY_EXECUTABLE:
+                    return locate_libexec(resource_ref);
+                case ResourceType.LIBRARY_RESOURCE:
+                    return locate_libres(resource_ref);
+                case ResourceType.CANONICAL_LIBRARY:
+                    return locate_canonlib(resource_ref);
+                case ResourceType.CANONICAL_LIBRARY_RESOURCE:
+                    return locate_canonlibres(resource_ref);
+                case ResourceType.GIO_MODULE:
+                    return locate_gio(resource_ref);
+                case ResourceType.INFO_PAGE:
+                    return locate_info(resource_ref);
+                case ResourceType.MANUAL_PAGE:
+                    return locate_man(resource_ref);
+                case ResourceType.LOCALE:
+                    return locate_locale(resource_ref);
+                case ResourceType.APPLICATION:
+                    return locate_app(resource_ref);
+                case ResourceType.INCLUDE:
+                    return locate_inc(resource_ref);
+                case ResourceType.PKG_CONFIG:
+                    return locate_pc(resource_ref);
+                case ResourceType.VALA_API:
+                    return locate_vapi(resource_ref);
+                case ResourceType.GOBJECT_IR:
+                    return locate_gir(resource_ref);
+                case ResourceType.TYPELIB:
+                    return locate_typelib(resource_ref);
+                case ResourceType.TAG:
+                    return locate_tag(resource_ref);
+                default:
+                    assert_not_reached();
+            }
+        }
+
+        private string? locate_tag(ResourceRef resource) {
+            if(Tag.read(resource.resource) != null) {
+                return paths.get_tag_file_path(resource.resource);
+            }
+            return null;
+        }
+
+        private string? locate_lib(ResourceRef resource) {
+            var via_ldconfig = locate_lib_ldconfig(resource);
+            if(via_ldconfig != null) {
+                return via_ldconfig;
+            }
+            // musl systems have no `ldconfig -p` output to consult: fall
+            // back to the conventional library directories
+            foreach(var directory in new string[] { "/lib", "/usr/lib", "/lib64", "/usr/lib64" }) {
+                var hit = locate_lib_in_directory(resource, directory);
+                if(hit != null) {
+                    return hit;
+                }
+            }
+            return null;
+        }
+
+        /**
+         * Whether the concrete library name {@link candidate} satisfies
+         * the ref: exact filename for plain refs; for constrained refs
+         * the name must be the base plus a fully numeric dotted tail
+         * (the soname version) that satisfies the constraint — a bare
+         * "libcmark.so" declares no version.
+         */
+        private static bool lib_name_matches(ResourceRef resource, string candidate) {
+            if(resource.constraint == null) {
+                return candidate == resource.base_name;
+            }
+            var tail = ResourceRef.version_tail(resource.base_name, candidate);
+            return tail != null && resource.constraint.satisfied_by(tail);
+        }
+
+        private string? locate_lib_in_directory(ResourceRef resource, string directory) {
+            if(resource.constraint == null) {
+                var file = File.new_build_filename(directory, resource.base_name);
+                return file.query_exists() ? file.get_path() : null;
+            }
+            // Constrained: enumerate the directory and version-match soname tails
+            try {
+                foreach(var entry in Iterate.directory(directory)) {
+                    if(!lib_name_matches(resource, entry)) {
+                        continue;
+                    }
+                    var file = File.new_build_filename(directory, entry);
+                    if(file.query_exists()) {
+                        return file.get_path();
+                    }
+                }
+            }
+            catch(FileError e) {
+            }
+            return null;
+        }
+
+        private string? locate_lib_ldconfig(ResourceRef resource) {
+            try {
+                var proc = new Subprocess.newv(new string[] { "ldconfig", "-p" },
+                    SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_SILENCE);
+                var pipe = new DataInputStream(proc.get_stdout_pipe());
+                string line = null;
+                while((line = pipe.read_line()) != null) {
+                    if(line.has_prefix("\t")) {
+                        var name = line.substring(1).split(" ", 2)[0];
+                        if(lib_name_matches(resource, name)) {
+                            return line.substring(1).split("=>", 2)[1].chomp().chug();
+                        }
+                    }
+                }
+                proc.wait_check();
+            }
+            catch(Error e) {
+                // busybox/musl ldconfig doesn't support -p; the fallback in
+                // locate_lib handles it — suppress the noise
+            }
+            return null;
+        }
+
+        private string? locate_canonlib(ResourceRef resource) {
+            try {
+                var proc = new Subprocess.newv(new string[] { "ldconfig", "-p" },
+                    SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_SILENCE);
+                var pipe = new DataInputStream(proc.get_stdout_pipe());
+                string line = null;
+                while((line = pipe.read_line()) != null) {
+                    if(line.has_prefix("\t")) {
+                        var name = line.substring(1).split(" ", 2)[0];
+                        // Only return true for libraries found in the canonical lib directory (e.g. not in /lib64)
+                        if(resource.resource == name && (name.has_prefix("/usr/lib") || name.has_prefix("/lib"))) {
+                            return line.substring(1).split("=>", 2)[1].chomp().chug();
+                        }
+                    }
+                }
+                proc.wait_check();
+            }
+            catch(Error e) {
+                warning(@"Error checking for resource $resource: $(e.message)");
+            }
+            return null;
+        }
+
+        private string? locate_bin(ResourceRef resource) {
+            var env_paths = Environment.get_variable("PATH").split(":");
+            foreach (var path in env_paths) {
+                var file = File.new_build_filename(paths.destination, path, resource.resource);
+                if(file.query_exists()) {
+                    return file.get_path();
+                }
+            }
+            return null;
+        }
+
+        private string? locate_path(ResourceRef resource) {
+            var file = File.new_build_filename(paths.destination, paths.prefix, resource.resource);
+            return file.query_exists() ? file.get_path() : null;
+        }
+
+        private string? locate_rootpath(ResourceRef resource) {
+            var file = File.new_build_filename(paths.destination, resource.resource);
+            return file.query_exists() ? file.get_path() : null;
+        }
+
+        private string? locate_res(ResourceRef resource) {
+            var file = File.new_build_filename(paths.destination, paths.prefix, paths.data, resource.resource);
+            return file.query_exists() ? file.get_path() : null;
+        }
+
+        private string? locate_opt(ResourceRef resource) {
+            var file = File.new_build_filename(paths.destination, "opt", resource.resource);
+            return file.query_exists() ? file.get_path() : null;
+        }
+
+        private string? locate_cfg(ResourceRef resource) {
+            var file = File.new_build_filename(paths.destination, paths.sys_config, resource.resource);
+            return file.query_exists() ? file.get_path() : null;
+        }
+
+        private string? locate_libres(ResourceRef resource) {
+            var search = new string[] { "/usr/lib", "/usr/lib64", "/lib", "/lib64" };
+            foreach (var path in search) {
+                var file = File.new_build_filename(paths.destination, path, resource.resource);
+                if(file.query_exists()) {
+                    return file.get_path();
+                }
+            }
+            return null;
+        }
+
+        private string? locate_canonlibres(ResourceRef resource) {
+            var search = new string[] { "/usr/lib", "/lib" };
+            foreach (var path in search) {
+                var file = File.new_build_filename(paths.destination, path, resource.resource);
+                if(file.query_exists()) {
+                    return file.get_path();
+                }
+            }
+            return null;
+        }
+
+        private string? locate_gio(ResourceRef resource) {
+            var search = Iterate.these("/usr/lib64/gio/modules", "/usr/lib/gio/modules");
+            // Debian-style multiarch keeps GIO modules in
+            // /usr/lib/<triple>/gio/modules, which no fixed path covers:
+            // enumerate whatever multiarch directories exist
+            try {
+                foreach(var entry in Iterate.directory("/usr/lib")) {
+                    if(entry.contains("-linux-")) {
+                        search = search.concat(Wrap.array(new string[] { @"/usr/lib/$entry/gio/modules" }));
+                    }
+                }
+            }
+            catch(FileError e) {
+            }
+            foreach (var path in search) {
+                var file = File.new_build_filename(paths.destination, path, resource.resource);
+                if(file.query_exists()) {
+                    return file.get_path();
+                }
+            }
+            return null;
+        }
+
+        private string? locate_app(ResourceRef resource) {
+            var file = File.new_build_filename("/usr/share/applications", resource.resource);
+            return file.query_exists() ? file.get_path() : null;
+        }
+
+        private string? locate_inc(ResourceRef resource) {
+            var file = File.new_build_filename("/usr/include", resource.resource);
+            return file.query_exists() ? file.get_path() : null;
+        }
+
+        private string? locate_sbin(ResourceRef resource) {
+            var search = new string[] { "/usr/sbin", "/sbin" };
+            foreach (var path in search) {
+                var file = File.new_build_filename(paths.destination, path, resource.resource);
+                if(file.query_exists()) {
+                    return file.get_path();
+                }
+            }
+            return null;
+        }
+
+        private string? locate_libexec(ResourceRef resource) {
+            var file = File.new_build_filename("/usr/libexec", resource.resource);
+            return file.query_exists() ? file.get_path() : null;
+        }
+
+        private string? locate_info(ResourceRef resource) {
+            var file = File.new_build_filename("/usr/share/info", resource.resource);
+            return file.query_exists() ? file.get_path() : null;
+        }
+
+        private string? locate_man(ResourceRef resource) {
+            var file = File.new_build_filename("/usr/share/man", resource.resource);
+            return file.query_exists() ? file.get_path() : null;
+        }
+
+        private string? locate_locale(ResourceRef resource) {
+            var file = File.new_build_filename("/usr/share/locale", resource.resource);
+            return file.query_exists() ? file.get_path() : null;
+        }
+
+        private string? locate_vapi(ResourceRef resource) {
+            var search = new string[] { "/usr/share/vala/vapi", "/usr/share/vala-0.56/vapi" };
+            foreach (var path in search) {
+                var file = File.new_build_filename(paths.destination, path, resource.resource);
+                if(file.query_exists()) {
+                    return file.get_path();
+                }
+            }
+            return null;
+        }
+
+        private string? locate_pc(ResourceRef resource) {
+            var search = Iterate.these("/usr/lib/pkgconfig", "/usr/lib64/pkgconfig", "/usr/share/pkgconfig");
+            // Debian-style multiarch keeps pkg-config files in
+            // /usr/lib/<triple>/pkgconfig, which no fixed path covers:
+            // enumerate whatever multiarch directories exist
+            try {
+                foreach(var entry in Iterate.directory("/usr/lib")) {
+                    if(entry.contains("-linux-")) {
+                        search = search.concat(Wrap.array(new string[] { @"/usr/lib/$entry/pkgconfig" }));
+                    }
+                }
+            }
+            catch(FileError e) {
+            }
+            var env = Environment.get_variable("PKG_CONFIG_PATH");
+            if(env != null) {
+                search = search.concat(Wrap.array(env.split(":")));
+            }
+
+            foreach (var path in search) {
+                var file = File.new_build_filename(paths.destination, path, resource.base_name);
+                if(file.query_exists()) {
+                    // A constrained pc ref must also be satisfied by the
+                    // version the .pc file itself declares
+                    if(resource.constraint == null) {
+                        return file.get_path();
+                    }
+                    var declared = PcFile.read_version(file.get_path());
+                    if(declared != null && resource.constraint.satisfied_by(declared)) {
+                        return file.get_path();
+                    }
+                }
+            }
+            return null;
+        }
+
+        private string? locate_gir(ResourceRef resource) {
+            var file = File.new_build_filename("/usr/share/gir", resource.resource);
+            return file.query_exists() ? file.get_path() : null;
+        }
+
+        private string? locate_typelib(ResourceRef resource) {
+            var search = new string[] { "/usr/lib64/girepository-1.0", "/usr/lib/girepository-1.0", "/lib64/girepository-1.0", "/lib/girepository-1.0"  };
+            foreach (var path in search) {
+                var file = File.new_build_filename(paths.destination, path, resource.resource);
+                if(file.query_exists()) {
+                    return file.get_path();
+                }
+            }
+            return null;
+        }
+    }
+
+}

+ 136 - 187
src/lib/ResourceRef.vala

@@ -11,8 +11,12 @@ namespace Usm {
         BINARY,
         SUPER_BINARY,
         LIBRARY,
-        LIBRARY_EXECUTABLE,
         LIBRARY_RESOURCE,
+        LIBRARY_EXECUTABLE,
+        CANONICAL_LIBRARY,
+        CANONICAL_LIBRARY_RESOURCE,
+        /** GIO plugin module: the module filename, locatable in a gio/modules directory */
+        GIO_MODULE,
         INFO_PAGE,
         MANUAL_PAGE,
         LOCALE,
@@ -46,6 +50,12 @@ namespace Usm {
                     return "libexec";
                 case ResourceType.LIBRARY_RESOURCE:
                     return "libres";
+                case ResourceType.CANONICAL_LIBRARY:
+                    return "canonlib";
+                case ResourceType.CANONICAL_LIBRARY_RESOURCE:
+                    return "canonlibres";
+                case ResourceType.GIO_MODULE:
+                    return "gio";
                 case ResourceType.INFO_PAGE:
                     return "info";
                 case ResourceType.MANUAL_PAGE:
@@ -93,6 +103,12 @@ namespace Usm {
                     return ResourceType.LIBRARY_EXECUTABLE;
                 case "libres":
                     return ResourceType.LIBRARY_RESOURCE;
+                case "canonlib":
+                    return ResourceType.CANONICAL_LIBRARY;
+                case "canonlibres":
+                    return ResourceType.CANONICAL_LIBRARY_RESOURCE;
+                case "gio":
+                    return ResourceType.GIO_MODULE;
                 case "info":
                     return ResourceType.INFO_PAGE;
                 case "man":
@@ -130,6 +146,9 @@ namespace Usm {
                 LIBRARY,
                 LIBRARY_EXECUTABLE,
                 LIBRARY_RESOURCE,
+                CANONICAL_LIBRARY,
+                CANONICAL_LIBRARY_RESOURCE,
+                GIO_MODULE,
                 INFO_PAGE,
                 MANUAL_PAGE,
                 LOCALE,
@@ -144,21 +163,52 @@ 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> {
+        /** The full resource string after the type, including any version constraint suffix. */
         public string resource { get; set; }
         public ResourceType resource_type { get; set; }
+        /**
+         * The resource name with any version constraint suffix removed —
+         * the concrete file/soname name. Equal to {@link resource} for
+         * constraint-free refs; path computation and filesystem lookups
+         * must always use this, never {@link resource}.
+         */
+        public string base_name { get; set; }
+        /** The version constraint carried by a dependency ref, or null. */
+        public VersionConstraint? constraint { get; set; }
 
         public ResourceRef(string str) throws ManifestError {
             var parts = str.split(":", 2);
+            if(parts.length < 2 || parts[1] == null || parts[1].length == 0) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(@"Resource ref \"$str\" must be a \"type:name\" pair.");
+            }
             resource_type = ResourceType.from_string(parts[0]);
             resource = parts[1];
+            string name_base;
+            constraint = VersionConstraint.find_in(resource, out name_base);
+            base_name = name_base;
+            if(base_name.length == 0) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(@"Resource ref \"$str\" has no resource name.");
+            }
         }
 
-        public ResourceRef.with_type(ResourceType type, string name) {
+        public ResourceRef.with_type(ResourceType type, string name) throws ManifestError {
             resource_type = type;
             resource = name;
+            if(name == null || name.length == 0) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(@"Resource ref \"$resource_type:\" has no resource name.");
+            }
+            string name_base;
+            constraint = VersionConstraint.find_in(resource, out name_base);
+            base_name = name_base;
+            if(base_name.length == 0) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(@"Resource ref \"$resource_type:$name\" has no resource name.");
+            }
         }
-        
+
         public uint hash_code() {
             return to_string().hash();
         }
@@ -169,211 +219,110 @@ namespace Usm {
             return @"$resource_type:$resource";
         }
 
-        public bool satisfied_by(ResourceRef other) {
-            return equals(other);
-        }
-        
-        public bool is_satisfied() {
-            switch (resource_type) {
-                case ResourceType.ROOT_PATH:
-                    return check_rootpath();
-                case ResourceType.PATH:
-                    return check_path();
-                case ResourceType.OPTIONAL:
-                    return check_opt();
-                case ResourceType.RESOURCE:
-                    return check_res();
-                case ResourceType.CONFIGURATION:
-                    return check_cfg();
-                case ResourceType.BINARY:
-                    return check_bin();
-                case ResourceType.SUPER_BINARY:
-                    return check_sbin();
-                case ResourceType.LIBRARY:
-                    return check_lib();
-                case ResourceType.LIBRARY_EXECUTABLE:
-                    return check_libexec();
-                case ResourceType.LIBRARY_RESOURCE:
-                    return check_libres();
-                case ResourceType.INFO_PAGE:
-                    return check_info();
-                case ResourceType.MANUAL_PAGE:
-                    return check_man();
-                case ResourceType.LOCALE:
-                    return check_locale();
-                case ResourceType.APPLICATION:
-                    return check_app();
-                case ResourceType.INCLUDE:
-                    return check_inc();
-                case ResourceType.PKG_CONFIG:
-                    return check_pc();
-                case ResourceType.VALA_API:
-                    return check_vapi();
-                case ResourceType.GOBJECT_IR:
-                    return check_gir();
-                case ResourceType.TYPELIB:
-                    return check_typelib();
-                case ResourceType.TAG:
-                    return check_tag();
-                default:
-                    assert_not_reached();
+        /**
+         * The dotted numeric version carried by a resource name after
+         * {@link prefix}, or null: "libcmark.so.0.31.1" over prefix
+         * "libcmark.so" yields 0.31.1, while "libcmark.so" itself (no
+         * tail) and "libcmark.so.0.31rc1" (non-numeric tail) yield null
+         * — a name without a fully numeric dotted tail declares no
+         * version.
+         */
+        public static Version? version_tail(string prefix, string name) {
+            if(!name.has_prefix(prefix + ".")) {
+                return null;
             }
-        }
-
-        private bool check_tag() {
-            return Tag.read(resource) != null;
-        }
-
-        private bool check_lib() {
-            try {
-                var proc = new Subprocess.newv(new string[] { "ldconfig", "-p" }, SubprocessFlags.STDOUT_PIPE);
-                var pipe = new DataInputStream(proc.get_stdout_pipe());
-                string line = null;
-                while((line = pipe.read_line()) != null) {
-                    if(line.has_prefix("\t")) {
-                        var name = line.substring(1).split(" ", 2)[0];
-                        if(resource == name) {
-                            return true;
-                        }
+            var tail = name.substring(prefix.length + 1);
+            foreach(var component in tail.split(".")) {
+                if(component.length == 0) {
+                    return null;
+                }
+                for(int index = 0; index < component.length; index++) {
+                    if(!component[index].isdigit()) {
+                        return null;
                     }
                 }
-                proc.wait_check();
+            }
+            try {
+                return new Version.from_string(tail);
             }
             catch(Error e) {
-                warning(@"Error checking for resource $this: $(e.message)");
+                return null;
             }
-            return false;
         }
 
-        private bool check_bin() {
-            var paths = Environment.get_variable("PATH").split(":");
-            foreach (var path in paths) {
-                var file = File.new_build_filename(path, resource);
-                if(file.query_exists()) {
-                    return true;
-                }
+        /**
+         * Enforces the dependency-side operator rules: version
+         * constraints are valid only where a resource's concrete names
+         * carry a version — `lib` (soname tails) and `pc` (the .pc
+         * `Version:` field / `==` declarations on provides).
+         */
+        public void ensure_valid_dependency() throws ManifestError {
+            if(constraint == null) {
+                return;
             }
-            return false;
-        }
-
-        private bool check_path() {
-            var file = File.new_build_filename("/usr", resource);
-            return file.query_exists();
-        }
-
-        private bool check_rootpath() {
-            var file = File.new_build_filename("/", resource);
-            return file.query_exists();
-        }
-
-        private bool check_res() {
-            var file = File.new_build_filename("/usr/share", resource);
-            return file.query_exists();
-        }
-
-        private bool check_opt() {
-            var file = File.new_build_filename("/opt", resource);
-            return file.query_exists();
-        }
-
-        private bool check_cfg() {
-            var file = File.new_build_filename("/etc", resource);
-            return file.query_exists();
-        }
-
-        private bool check_libres() {
-            var paths = new string[] { "/usr/lib", "/usr/lib64", "/lib", "/lib64" };
-            foreach (var path in paths) {
-                var file = File.new_build_filename(path, resource);
-                if(file.query_exists()) {
-                    return true;
-                }
+            if(resource_type != ResourceType.LIBRARY && resource_type != ResourceType.PKG_CONFIG) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(
+                    @"Version constraints are only available for \"lib\" and \"pc\" resource types, not \"$(resource_type.to_string())\" (in \"$(to_string())\")."
+                );
             }
-            return false;
         }
 
-        private bool check_app() {
-            var file = File.new_build_filename("/usr/share/applications", resource);
-            return file.query_exists();
-        }
-
-        private bool check_inc() {
-            var file = File.new_build_filename("/usr/include", resource);
-            return file.query_exists();
-        }
-
-        private bool check_sbin() {
-            var paths = new string[] { "/usr/sbin", "/sbin" };
-            foreach (var path in paths) {
-                var file = File.new_build_filename(path, resource);
-                if(file.query_exists()) {
-                    return true;
-                }
+        /**
+         * Enforces the provides-side rules: only the `==` operator may
+         * appear, only on `pc` provides, where it DECLARES the version
+         * of the concrete file. Sonames already declare their version
+         * in the filename tail, and every other type has no version
+         * source at all.
+         */
+        public void ensure_valid_provide() throws ManifestError {
+            if(constraint == null) {
+                return;
+            }
+            if(resource_type != ResourceType.PKG_CONFIG) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(
+                    @"Only \"pc\" provides may declare a version with \"==\"; \"$(resource_type.to_string())\" provides must stay concrete (in \"$(to_string())\")."
+                );
+            }
+            if(constraint.operation != VersionOperator.EQUALS) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(
+                    @"Provides may only carry the \"==\" version declaration, not \"$(constraint.operation.to_string())\" (in \"$(to_string())\")."
+                );
             }
-            return false;
-        }
-
-        private bool check_libexec() {
-            var file = File.new_build_filename("/usr/libexec", resource);
-            return file.query_exists();
-        }
-
-        private bool check_info() {
-            var file = File.new_build_filename("/usr/share/info", resource);
-            return file.query_exists();
-        }
-
-        private bool check_man() {
-            var file = File.new_build_filename("/usr/share/man", resource);
-            return file.query_exists();
         }
 
-        private bool check_locale() {
-            var file = File.new_build_filename("/usr/share/locale", resource);
-            return file.query_exists();
-        }
+        public bool satisfied_by(ResourceRef other) {
+            var type_compatible = resource_type == other.resource_type
+                // canonlib can satisfy lib dependencies of the same name
+                || (resource_type == ResourceType.LIBRARY && other.resource_type == ResourceType.CANONICAL_LIBRARY)
+                // canonlibres can satisfy libres dependencies of the same name
+                || (resource_type == ResourceType.LIBRARY_RESOURCE && other.resource_type == ResourceType.CANONICAL_LIBRARY_RESOURCE);
 
-        private bool check_vapi() {
-            var paths = new string[] { "/usr/share/vala/vapi", "/usr/share/vala-0.56/vapi" };
-            foreach (var path in paths) {
-                var file = File.new_build_filename(path, resource);
-                if(file.query_exists()) {
-                    return true;
-                }
+            if(constraint == null) {
+                // Plain dependency: matches any provide of the base name,
+                // whether or not the provide declares a version
+                return type_compatible && resource == other.base_name;
             }
-            return false;
-        }
 
-        private bool check_pc() {
-            var paths = Iterate.these("/usr/lib/pkgconfig", "/usr/lib64/pkgconfig", "/usr/share/pkgconfig");
-            var env = Environment.get_variable("PKG_CONFIG_PATH");
-            if(env != null) {
-                paths = paths.concat(Wrap.array(env.split(":")));
+            if(!type_compatible) {
+                return false;
             }
 
-            foreach (var path in paths) {
-                var file = File.new_build_filename(path, resource);
-                if(file.query_exists()) {
-                    return true;
-                }
+            Version? provided_version = null;
+            if(resource_type == ResourceType.LIBRARY) {
+                // Sonames carry their version in the filename tail: the
+                // provide extends the dependency's base ("libcmark.so"
+                // is satisfied by "libcmark.so.0.31.1"); a bare
+                // "libcmark.so" declares nothing
+                provided_version = version_tail(base_name, other.resource);
             }
-            return false;
-        }
-
-        private bool check_gir() {
-            var file = File.new_build_filename("/usr/share/gir", resource);
-            return file.query_exists();
-        }
-
-        private bool check_typelib() {
-            var paths = new string[] { "/usr/lib64/girepository-1.0", "/usr/lib/girepository-1.0", "/lib64/girepository-1.0", "/lib/girepository-1.0"  };
-            foreach (var path in paths) {
-                var file = File.new_build_filename(path, resource);
-                if(file.query_exists()) {
-                    return true;
-                }
+            else if(base_name == other.base_name && other.constraint != null) {
+                // A pc provide's == declaration
+                provided_version = other.constraint.version;
+            }
+            if(provided_version == null) {
+                return false;
             }
-            return false;
+            return constraint.satisfied_by(provided_version);
         }
     }
 

+ 45 - 6
src/lib/State/CachedPackage.vala

@@ -8,6 +8,16 @@ namespace Usm {
         public string package_path { get; private set; }
         public string package_name { get; private set; }
 
+        /**
+         * The package's manifest when already known without reading the
+         * cached .usmc — set from a repository listing entry so a plan
+         * can be described, confirmed and strategised before the package
+         * is downloaded. Null (the default) reads {@link package_path}
+         * as usual; execution itself always works from the extracted
+         * sources' own MANIFEST.usm.
+         */
+        public Manifest? known_manifest { get; set; }
+
         public CachedPackage(string path) {
             this.state_path = path;
             this.package_path = Path.build_filename(path, "package.usmc");
@@ -15,6 +25,9 @@ namespace Usm {
         }
 
         public Manifest get_manifest() throws Error {
+            if(known_manifest != null) {
+                return known_manifest;
+            }
             return new Manifest.from_package(package_path);
         }
 
@@ -50,7 +63,7 @@ namespace Usm {
             }
 
             var archive_path = build_archive_path();
-            if(!File.new_for_path(path).query_exists()) {
+            if(!File.new_for_path(archive_path).query_exists()) {
                 throw new StateError.NO_BUILD_ARTIFACT(@"The package \"$package_name\" has no build artifact");
             }
 
@@ -73,7 +86,14 @@ namespace Usm {
             return path;
         }
 
-        public void archive_build() throws Error {
+        public void archive_build(Usm.ProgressDelegate? progress = null) throws Error {
+            // An archive with no build directory is already the
+            // authoritative artifact: extracting it just to repack the
+            // same bytes is pure churn
+            if(!has_build_directory() && has_build_archive()) {
+                return;
+            }
+
             var build_dir = get_build_directory();
 
             // Delete existing archive, if it exists
@@ -82,7 +102,7 @@ namespace Usm {
                 archive_file.delete();
             }
 
-            Util.archive(build_dir, archive_file.get_path());
+            Util.archive_with_progress(build_dir, archive_file.get_path(), progress);
             clean_build_directory();
         }
 
@@ -98,14 +118,33 @@ namespace Usm {
             }
         }
 
-        public string get_source_directory() throws Error {
+        /**
+         * Removes the build directory AND the build.tar.xz archive,
+         * forcing the next build to compile from scratch.
+         */
+        public void clean_build_cache() throws Error {
+            clean_build_directory();
+            var archive = build_archive_path();
+            var archive_file = File.new_for_path(archive);
+            if(archive_file.query_exists()) {
+                archive_file.delete();
+            }
+        }
+
+        /**
+         * Extracts the cached .usmc into the package directory,
+         * forwarding extraction progress (see
+         * {@link Util.unarchive_with_progress}) to {@link progress}
+         * when supplied. A directory that already exists is returned
+         * as-is without extracting.
+         */
+        public string get_source_directory(Usm.ProgressDelegate? progress = null) throws Error {
             var path = source_directory_path();
             if(File.new_for_path(path).query_exists()) {
                 return path;
             }
 
-            print(@"Extracting $(package_path) to $(path)\n");
-            Util.unarchive(package_path, path);
+            Util.unarchive_with_progress(package_path, path, progress);
             return path;
         }
 

+ 21 - 3
src/lib/State/OriginInformation.vala

@@ -10,12 +10,30 @@ namespace Usm {
         public string original_path { get; set; }
         public bool signature_verified { get; set; }
 
+        /**
+         * Whether the user asked for this package by name (`usm install
+         * foo`) rather than receiving it as a resolved dependency; the
+         * `usm remove` orphan scan spares packages marked here. Records
+         * written before the field existed (and records the transaction
+         * writes without repository provenance) carry false/null and
+         * parse back as before.
+         */
+        public bool explicitly_installed { get; set; default = false; }
+
         public static PropertyMapper<OriginInformation> get_mapper() {
             return PropertyMapper.build_for<OriginInformation>(cfg => {
-                cfg.map<string>("repository", o => o.repository, (o, v) => o.repository = v);
-                cfg.map<string>("listfile", o => o.listfile, (o, v) => o.listfile = v);
-                cfg.map<string>("original_path", o => o.original_path, (o, v) => o.original_path = v);
+                cfg.map<string>("repository", o => o.repository, (o, v) => o.repository = v)
+                    .null_when(o => o.repository == null)
+                    .when_null(o => o.repository = null);
+                cfg.map<string>("listfile", o => o.listfile, (o, v) => o.listfile = v)
+                    .null_when(o => o.listfile == null)
+                    .when_null(o => o.listfile = null);
+                cfg.map<string>("original_path", o => o.original_path, (o, v) => o.original_path = v)
+                    .null_when(o => o.original_path == null)
+                    .when_null(o => o.original_path = null);
                 cfg.map<bool>("signature_verified", o => o.signature_verified, (o, v) => o.signature_verified = v);
+                cfg.map<bool>("explicitly_installed", o => o.explicitly_installed, (o, v) => o.explicitly_installed = v)
+                    .when_undefined(o => o.explicitly_installed = false);
             });
         }
 

+ 173 - 7
src/lib/State/State.vala

@@ -1,4 +1,5 @@
 using Invercargill;
+using Invercargill.DataStructures;
 
 namespace Usm {
 
@@ -33,6 +34,80 @@ namespace Usm {
                 .select<CachedPackage>(d => new CachedPackage(Path.build_filename(state_path, "installed", d)));
         }
 
+        /**
+         * The installed {@link CachedPackage} whose manifest name matches
+         * {@link name}, resolved through its `installed/` symlink to the
+         * real cache directory; null when nothing of that name is
+         * installed. When several versions are marked installed the
+         * newest wins. Unreadable installed entries are skipped with a
+         * warning — one corrupt install should not hide the rest.
+         */
+        public CachedPackage? find_installed(string name) throws Error {
+            CachedPackage? match = null;
+            Version? match_version = null;
+            foreach(var installed in get_installed_packages()) {
+                Manifest manifest;
+                try {
+                    manifest = installed.get_manifest();
+                }
+                catch(Error e) {
+                    warning(@"[Usm] Skipping unreadable installed package \"$(installed.package_name)\": $(e.message)");
+                    continue;
+                }
+                if(manifest.name != name) {
+                    continue;
+                }
+                if(match == null || manifest.version.greater_than((!)match_version)) {
+                    match = resolve_installed(installed);
+                    match_version = manifest.version;
+                }
+            }
+            return match;
+        }
+
+        /**
+         * Resolves an installed entry through its `installed/` symlink to
+         * the cache {@link CachedPackage} it points at: commands acting
+         * on an installed package need the real state directory (for
+         * cleanup), and re-marking an installed package must not relink
+         * its symlink onto itself.
+         */
+        public CachedPackage resolve_installed(CachedPackage installed) throws Error {
+            var info = File.new_for_path(installed.state_path).query_info(FileAttribute.STANDARD_SYMLINK_TARGET, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+            var target = info.get_symlink_target();
+            return target != null ? new CachedPackage(target) : installed;
+        }
+
+        /**
+         * Every cached version directory of {@link name} under
+         * `<state>/packages/` — `<name>-<version>` with a parsable
+         * version and a `package.usmc` — including the installed one;
+         * the siblings kept behind by updates are what makes downgrade
+         * possible. Directories that are not `<name>-<version>` (another
+         * package sharing a name prefix, for instance) never parse and
+         * are skipped.
+         */
+        public Vector<CachedPackage> get_cached_versions(string name) throws Error {
+            var versions = new Vector<CachedPackage>();
+            foreach(var directory in Iterate.directory(Path.build_filename(state_path, "packages"))) {
+                if(!directory.has_prefix(@"$(name)-")) {
+                    continue;
+                }
+                try {
+                    new Version.from_string(directory.substring(name.length + 1));
+                }
+                catch(Error e) {
+                    continue;
+                }
+                var path = Path.build_filename(state_path, "packages", directory);
+                if(!File.new_for_path(Path.build_filename(path, "package.usmc")).query_exists()) {
+                    continue;
+                }
+                versions.add(new CachedPackage(path));
+            }
+            return versions;
+        }
+
         public Enumerable<Repository> get_repositories() throws Error {
             return Iterate.directory(Path.build_filename(config_path, "repos.d"))
                 .attempt_select<Repository>(d => new Repository.from_file(Path.build_filename(config_path, "repos.d", d)))
@@ -45,18 +120,34 @@ 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);
         }
 
         public void refresh_list(Repository repository, RepositoryClientProgressCallback? callback = null) throws Error {
-            var client = repository.get_client();
+            var client = repository.get_client(config);
 
             var list_path = Path.build_filename(state_path, "lists", repository.name);
             if(!File.new_for_path(list_path).query_exists()) {
@@ -67,14 +158,89 @@ namespace Usm {
             client.download_repository_listing(Path.build_filename(list_path, filename), callback);
         }
 
+        /**
+         * Cache-directory names (e.g. "statum-0.1", the package's
+         * {@link CachedPackage.package_name}) of every INSTALLED package
+         * that depends on any resource {@link updated} provides.
+         *
+         * Every phase of each installed manifest is scanned — flat phases
+         * plus the union of all candidate groups of grouped phases — and a
+         * ref counts as a dependency when
+         * {@link ResourceRef.satisfied_by} matches it against any of the
+         * updated package's provides keys, so a `lib:` dependency is still
+         * found when the update provides `canonlib:`. Older installed
+         * versions of {@link updated} itself are never dependants of
+         * themselves. Throws when an installed package's manifest cannot
+         * be read — a corrupt install should surface, not silently skip
+         * rebuilds.
+         */
+        public Vector<string> find_dependant_names(CachedPackage updated) throws Error {
+            var dependants = new Vector<string>();
+            var updated_manifest = updated.get_manifest();
+
+            foreach(var installed in get_installed_packages()) {
+                var manifest = installed.get_manifest();
+                if(manifest.name == updated_manifest.name) {
+                    continue;
+                }
+
+                var refs = new HashSet<ResourceRef>();
+                refs.union_with(manifest.dependencies.runtime.all_refs());
+                refs.union_with(manifest.dependencies.build.all_refs());
+                refs.union_with(manifest.dependencies.manage.all_refs());
+                if(manifest.dependencies.acquire != null) {
+                    refs.union_with(manifest.dependencies.acquire.all_refs());
+                }
+
+                if(refs.any(d => updated_manifest.satisfies(d))) {
+                    dependants.add(installed.package_name);
+                }
+            }
+            return dependants;
+        }
+
         public string generate_cache_path(Manifest manifest) {
             var filename = @"$(manifest.name)-$(manifest.version)";
             return Path.build_filename(state_path, "packages", filename);
         }
 
+        /**
+         * Absolute path of the package's `installed/` mark symlink — the
+         * file {@link mark_installed} creates and
+         * {@link unmark_installed} deletes — so a transaction journal
+         * can record the mark itself for a rollback to re-create.
+         */
+        public string installed_mark_path(CachedPackage package) {
+            return Path.build_filename(state_path, "installed", Path.get_basename(package.state_path));
+        }
+
+        /**
+         * The mark's current symlink target — the cache directory the
+         * package is installed from — or null when nothing is marked.
+         * Read before {@link unmark_installed} so a rollback can
+         * re-link exactly what was there.
+         */
+        public string? read_installed_target(CachedPackage package) throws Error {
+            try {
+                var info = File.new_for_path(installed_mark_path(package))
+                    .query_info(FileAttribute.STANDARD_SYMLINK_TARGET, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+                return info.get_symlink_target();
+            }
+            catch(IOError.NOT_FOUND e) {
+                return null;
+            }
+        }
+
         public void mark_installed(CachedPackage package) throws Error {
             var filename = Path.get_basename(package.state_path);
             var symlink = File.new_build_filename(state_path, "installed", filename);
+            // A same-version rebuild re-marks an entry that is already
+            // linked: replace the link so a stale target cannot linger
+            try {
+                symlink.delete();
+            }
+            catch(IOError.NOT_FOUND e) {
+            }
             symlink.make_symbolic_link(package.state_path);
         }
 

+ 408 - 0
src/lib/SystemPackageManager.vala

@@ -0,0 +1,408 @@
+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());
+            });
+        }
+    }
+
+    /** The `plan` helper mode's result: the material install set's names. */
+    public class SystemPlanResult {
+
+        public Vector<string> packages { get; set; }
+
+        public static PropertyMapper<SystemPlanResult> get_mapper() {
+            return PropertyMapper.build_for<SystemPlanResult>(cfg => {
+                cfg.map_many<string>("packages", o => o.packages, (o, v) => o.packages = v.to_vector());
+                cfg.set_constructor(() => new SystemPlanResult());
+            });
+        }
+    }
+
+    /**
+     * 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.
+         *
+         * Stdout is read synchronously to EOF with stderr drained on a
+         * reader thread rather than through GLib's communicate helper:
+         * communicate's two-pipe machinery deadlocks under glibc
+         * compatibility loaders (musl systems running the glibc-built usm
+         * via gcompat), which the apk target deploys on.
+         */
+        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);
+
+            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)");
+            }
+
+            var stdout_buffer = new StringBuilder();
+            var lines = new DataInputStream(process.get_stdout_pipe());
+            string stdout_line = null;
+            while((stdout_line = lines.read_line()) != null) {
+                stdout_buffer.append(stdout_line).append("\n");
+            }
+            process.wait();
+            string? stderr_buffer = stderr_reader != null ? stderr_reader.join() : null;
+
+            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.str);
+            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).
+         *
+         * Events are read synchronously (stderr stays on a reader thread so a
+         * chatty helper can never block): GLib's async pipe reads fail with
+         * an error that carries no message under glibc compatibility loaders
+         * (musl systems running the glibc-built usm via gcompat) whenever
+         * the read has to wait for data, which the apk target deploys on.
+         */
+        /**
+         * Resolves the material install set for {@link native_names}: every
+         * package the manager would actually install — the chosen names
+         * plus every dependency of a dependency — by asking the helper's
+         * `plan` mode (same executable as the query mode, mode word
+         * swapped). The names come back in the manager's own transaction
+         * order. Helpers without a plan mode (or any failure — resolution
+         * problem, unknown names) degrade to the input names unchanged,
+         * so the caller always gets a usable action list.
+         */
+        public Vector<string> plan_install(Vector<string> native_names) {
+            var names = new Vector<string>();
+            foreach(var name in native_names) {
+                names.add(name);
+            }
+            if(!enabled) {
+                return names;
+            }
+
+            var plan_argv = new string[query_argv.length];
+            for(int index = 0; index < query_argv.length; index++) {
+                plan_argv[index] = query_argv[index];
+            }
+            plan_argv[plan_argv.length - 1] = "plan";
+            var arguments = new Vector<string>();
+            foreach(var name in native_names) {
+                arguments.add(name);
+            }
+
+            try {
+                var proc = new Subprocess.newv(build_argv(plan_argv, arguments), SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_SILENCE);
+                string stdout_buffer = null;
+                string stderr_buffer = null;
+                proc.communicate_utf8(null, null, out stdout_buffer, out stderr_buffer);
+                if(proc.get_exit_status() == 0 && stdout_buffer != null && stdout_buffer.strip().length > 0) {
+                    var result = SystemPlanResult.get_mapper().materialise(
+                        new JsonElement.from_string(stdout_buffer).as<Invercargill.Properties>());
+                    if(result.packages != null && result.packages.length > 0) {
+                        var planned = new Vector<string>();
+                        var seen = new HashSet<string>();
+                        foreach(var package_name in result.packages) {
+                            if(!seen.contains(package_name)) {
+                                seen.add(package_name);
+                                planned.add(package_name);
+                            }
+                        }
+                        return planned;
+                    }
+                }
+            }
+            catch(Error e) {
+            }
+            return names;
+        }
+
+        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);
+
+            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 = events.read_line();
+                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);
+                }
+            }
+
+            process.wait();
+            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;
+        }
+    }
+}

+ 2 - 2
src/lib/Tag/Tag.vala

@@ -27,7 +27,7 @@ namespace Usm {
         }
 
         public static Tag? read(string name) {
-            var paths = new Paths.usm_environ();
+            var paths = new Paths();
             var path = paths.get_tag_file_path(name);
             if(!File.new_for_path(path).query_exists()) {
                 return null;
@@ -54,7 +54,7 @@ namespace Usm {
         }
 
         public Enumerable<Tag> get_children() {
-            var paths = new Paths.usm_environ();
+            var paths = new Paths();
             var path = paths.get_tag_dir_path(name);
             if(!File.new_for_path(path).query_exists()) {
                 return Iterate.nothing<Tag>();

Fișier diff suprimat deoarece este prea mare
+ 839 - 80
src/lib/Transaction.vala


+ 191 - 0
src/lib/TransactionSummary.vala

@@ -5,6 +5,197 @@ using InvercargillJson;
 
 namespace Usm {
 
+    /**
+     * The plan of a transaction before it runs: what will be installed,
+     * removed, rebuilt and downgraded. The lifecycle commands (`install`,
+     * `update`, `remove`, `downgrade`) build one after resolution and
+     * render it through {@link describe} behind the shared plan + confirm
+     * prompt.
+     */
+    public class TransactionSummary : Object {
+
+        /** Packages the transaction installs; a {@link to_remove} entry of the same name renders as an update. */
+        public Vector<CachedPackage> to_install { get; set; default = new Vector<CachedPackage>(); }
+        /**
+         * System (native) packages the transaction installs itself
+         * before the first USM lot (see
+         * {@link Transaction.system_packages}); rendered first, in the
+         * order the native transaction installs them.
+         */
+        public Vector<Usm.SystemPackageCandidate> system_installs { get; set; default = new Vector<Usm.SystemPackageCandidate>(); }
+        /**
+         * The material system install set's extra packages — every
+         * native dependency of a dependency the manager pulls in for
+         * {@link system_installs} (from
+         * {@link SystemPackageManager.plan_install}, minus the chosen
+         * names); rendered dim after the chosen packages so the plan
+         * explains the whole native transaction.
+         */
+        public Vector<string> system_dependencies { get; set; default = new Vector<string>(); }
+        /** Installed packages the transaction removes; entries whose name is reinstalled render as updates. */
+        public Vector<CachedPackage> to_remove { get; set; default = new Vector<CachedPackage>(); }
+        /**
+         * Orphaned dependencies joining the removal plan: implicitly
+         * installed packages no package surviving the removal needs (the
+         * `usm remove` auto-orphan scan). Rendered with their own label
+         * so the plan explains why they go.
+         */
+        public Vector<CachedPackage> orphaned_removals { get; set; default = new Vector<CachedPackage>(); }
+        /** Planned rebuildDependants rebuilds, taken from {@link Transaction.rebuilds} after {@link Transaction.strategise}. */
+        public Vector<RebuildEntry> to_rebuild { get; set; default = new Vector<RebuildEntry>(); }
+        /** Version downgrades; each entry replaces {@link DowngradeEntry.current} with {@link DowngradeEntry.target}. */
+        public Vector<DowngradeEntry> to_downgrade { get; set; default = new Vector<DowngradeEntry>(); }
+        /**
+         * How many packages must be downloaded from a repository before
+         * the transaction can run (packages already in the local cache
+         * do not count); zero when everything is cached or only local
+         * packages are involved.
+         */
+        public uint download_count { get; set; default = 0; }
+        /**
+         * Total size in bytes of those downloads, or -1 when any size
+         * is unknown (a server that answers no Content-Length, for
+         * example); meaningless when {@link download_count} is zero.
+         */
+        public int64 download_bytes { get; set; default = -1; }
+
+        /** Byte units for {@link format_byte_size}, ascending. */
+        private const string[] BYTE_UNITS = { "B", "KiB", "MiB", "GiB", "TiB" };
+
+        /** Whether the summary describes no action at all. */
+        public bool is_empty() {
+            return to_install.length == 0 && system_installs.length == 0 && system_dependencies.length == 0
+                && to_remove.length == 0 && orphaned_removals.length == 0
+                && to_rebuild.length == 0 && to_downgrade.length == 0;
+        }
+
+        /**
+         * Human-readable multi-line rendering of the plan (without a
+         * trailing newline): one line per package with its version
+         * transition, categories ordered install, update, remove,
+         * downgrade, rebuild. Orphaned removals carry the `(orphaned)`
+         * tag after the package name. Unreadable manifests degrade to
+         * the cache directory name rather than failing the display.
+         * When {@link coloured} is true, ANSI colours highlight the
+         * action column.
+         */
+        public string describe(bool coloured = false) {
+            var c_green  = coloured ? "\033[32m" : "";
+            var c_cyan   = coloured ? "\033[36m" : "";
+            var c_yellow = coloured ? "\033[33m" : "";
+            var c_magenta= coloured ? "\033[35m" : "";
+            var c_dim    = coloured ? "\033[2m"  : "";
+            var c_reset  = coloured ? "\033[0m"  : "";
+
+            var builder = new StringBuilder();
+            builder.append_printf("%sTransaction plan:%s\n", coloured ? "\033[1m" : "", c_reset);
+
+            var removed_versions = new Dictionary<string, string>();
+            foreach(var package in to_remove) {
+                removed_versions.set(summary_name(package), summary_version(package));
+            }
+
+            foreach(var candidate in system_installs) {
+                builder.append_printf("  %s%-9s%s  %s %s(system package)%s\n",
+                    c_green, "install", c_reset, candidate.name, c_dim, c_reset);
+            }
+
+            foreach(var name in system_dependencies) {
+                builder.append_printf("  %s%-9s%s  %s %s(system dependency)%s\n",
+                    c_green, "install", c_reset, name, c_dim, c_reset);
+            }
+
+            var updated_names = new HashSet<string>();
+            foreach(var package in to_install.sort((a, b) => summary_name(a).collate(summary_name(b)))) {
+                var name = summary_name(package);
+                string removed_version;
+                if(removed_versions.try_get(name, out removed_version)) {
+                    updated_names.add(name);
+                    builder.append_printf("  %s%-9s%s  %s %s %s→%s %s\n",
+                        c_cyan, "update", c_reset, name, removed_version, c_dim, c_reset, summary_version(package));
+                }
+                else {
+                    builder.append_printf("  %s%-9s%s  %s %s\n",
+                        c_green, "install", c_reset, name, summary_version(package));
+                }
+            }
+
+            foreach(var package in to_remove.sort((a, b) => summary_name(a).collate(summary_name(b)))) {
+                if(updated_names.contains(summary_name(package))) {
+                    continue;
+                }
+                builder.append_printf("  %s%-9s%s  %s %s\n",
+                    c_yellow, "remove", c_reset, summary_name(package), summary_version(package));
+            }
+
+            foreach(var package in orphaned_removals.sort((a, b) => summary_name(a).collate(summary_name(b)))) {
+                builder.append_printf("  %s%-9s%s  %s %s %s(orphaned)%s\n",
+                    c_yellow, "remove", c_reset, summary_name(package), summary_version(package), c_dim, c_reset);
+            }
+
+            foreach(var entry in to_downgrade.sort((a, b) => summary_name(a.target).collate(summary_name(b.target)))) {
+                builder.append_printf("  %s%-9s%s  %s %s %s→%s %s\n",
+                    c_magenta, "downgrade", c_reset, summary_name(entry.target),
+                    summary_version(entry.current), c_dim, c_reset, summary_version(entry.target));
+            }
+
+            foreach(var entry in to_rebuild.sort((a, b) => summary_name(a.package).collate(summary_name(b.package)))) {
+                builder.append_printf("  %s%-9s%s  %s %s %s(triggered by %s %s)%s\n",
+                    c_yellow, "rebuild", c_reset,
+                    summary_name(entry.package), summary_version(entry.package),
+                    c_dim, summary_name(entry.trigger), summary_version(entry.trigger), c_reset);
+            }
+
+            if(download_count > 0) {
+                if(download_bytes >= 0) {
+                    builder.append_printf("  %s%u package(s) to download, %s total%s\n",
+                        c_dim, download_count, format_byte_size(download_bytes), c_reset);
+                }
+                else {
+                    builder.append_printf("  %s%u package(s) to download, size unknown%s\n",
+                        c_dim, download_count, c_reset);
+                }
+            }
+
+            if(builder.str.has_suffix("\n")) {
+                builder.truncate(builder.len - 1);
+            }
+            return builder.str;
+        }
+
+        /** Human-readable byte size, for example `24.5 MiB` or `512 B`. */
+        private static string format_byte_size(int64 bytes) {
+            double size = bytes;
+            foreach(var unit in BYTE_UNITS) {
+                if(size < 1024.0 || unit == BYTE_UNITS[BYTE_UNITS.length - 1]) {
+                    return unit == "B" ? @"$bytes B" : "%.1f %s".printf(size, unit);
+                }
+                size /= 1024.0;
+            }
+            assert_not_reached();
+        }
+
+        /** The manifest name of {@link package}, falling back to its cache directory name when unreadable. */
+        private static string summary_name(CachedPackage package) {
+            try {
+                return package.get_manifest().name;
+            }
+            catch(Error e) {
+                return package.package_name;
+            }
+        }
+
+        /** The manifest version of {@link package}, falling back to its cache directory name when unreadable. */
+        private static string summary_version(CachedPackage package) {
+            try {
+                return package.get_manifest().version.to_string();
+            }
+            catch(Error e) {
+                return package.package_name;
+            }
+        }
+    }
+
     public class TransactionRecord {
 
         public Vector<PackageState> state_changes { get; set; }

+ 113 - 24
src/lib/Util.vala

@@ -1,35 +1,33 @@
+using Invercargill.DataStructures;
 
 namespace Usm.Util {
 
     public static void delete_tree(string path) throws Error {
-        // Creating a new Directory object for the given folder path
-        var folder = File.new_for_path(path);
+        var file = File.new_for_path(path);
 
-        // Getting a list of all files and folders inside the directory
-        var enumerator = folder.enumerate_children("*", FileQueryInfoFlags.NONE);
-
-        // Looping through each file/folder and removing them
-        while (true) {
-            FileInfo? info = enumerator.next_file();
-            if (info == null) {
-                break;
-            }
+        // Never descend through a symbolic link: unlink it instead and
+        // leave the target tree alone (the buildInSourceTree build
+        // directory is one)
+        var info = file.query_info(FileAttribute.STANDARD_TYPE, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+        if(info.get_file_type() != FileType.DIRECTORY) {
+            file.delete();
+            return;
+        }
 
-            // Checking if the current item is a file or a folder
-            if (info.get_file_type() == FileType.REGULAR) {
-                // Removing the file
-                var file = folder.get_child(info.get_name());
-                    file.delete();
+        // Collect the whole listing before deleting anything: mutating the
+        // directory mid-enumeration can skip entries
+        var names = new Vector<string>();
+        var enumerator = file.enumerate_children(FileAttribute.STANDARD_NAME, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+        FileInfo child_info = null;
+        while((child_info = enumerator.next_file()) != null) {
+            names.add(child_info.get_name());
+        }
 
-            }
-            else if (info.get_file_type() == FileType.DIRECTORY) {
-                // Removing the folder recursively
-                var subfolder = folder.get_child(info.get_name());
-                delete_tree(subfolder.get_path());
-            }
+        foreach(var name in names) {
+            delete_tree(file.get_child(name).get_path());
         }
-        
-        folder.delete();
+
+        file.delete();
     }
     
 
@@ -44,6 +42,61 @@ namespace Usm.Util {
         }
     }
 
+    /**
+     * Extracts `archive` (tar.xz) into `destination`, reporting
+     * extraction progress by counting tar's verbose stdout lines. The
+     * member total comes from a `tar -tf <archive> | wc -l` pass first;
+     * the extraction pass (`tar -xvf`) then emits one line per member,
+     * so the callback receives `lines_read / total_members` as a 0..1
+     * fraction. A null {@link progress} delegates to
+     * {@link unarchive} unchanged.
+     */
+    public static void unarchive_with_progress(string archive, string destination, Usm.ProgressDelegate? progress) throws Error {
+        if(progress == null) {
+            unarchive(archive, destination);
+            return;
+        }
+
+        var dest = File.new_for_path(destination);
+        if(!dest.query_exists()) {
+            dest.make_directory();
+        }
+
+        var counter = new Subprocess.newv(
+            new string[] { "bash", "-c", @"tar -tf '$archive' | wc -l" },
+            SubprocessFlags.STDOUT_PIPE);
+        uint64 total_members = 0;
+        var counter_output = new DataInputStream(counter.get_stdout_pipe());
+        var count_line = counter_output.read_line(null);
+        if(count_line != null) {
+            total_members = uint64.parse(count_line.strip());
+        }
+        counter.wait_check();
+
+        var proc = new Subprocess.newv(
+            new string[] { "tar", "-xvf", archive, "-C", destination },
+            SubprocessFlags.STDOUT_PIPE);
+
+        var output = new DataInputStream(proc.get_stdout_pipe());
+        uint64 extracted = 0;
+        string line;
+        while((line = output.read_line(null)) != null) {
+            extracted++;
+            if(total_members > 0) {
+                var fraction = (float)((double)extracted / (double)total_members);
+                progress(fraction > 1.0f ? 1.0f : fraction);
+            }
+        }
+        if(!proc.wait_check()) {
+            throw new IOError.FAILED(@"Could not extract \"$archive\": tar returned non-zero exit status $(proc.get_status())");
+        }
+        // An archive whose listing came back empty never reported per
+        // member; completion is all the caller can be told
+        if(total_members == 0) {
+            progress(1.0f);
+        }
+    }
+
     public static void archive(string source, string archive) throws Error {
         var proc = new Subprocess.newv(new string[] { "tar", "-cJf", archive, "--directory", source, "." }, SubprocessFlags.INHERIT_FDS);
         if(!proc.wait_check()) {
@@ -51,4 +104,40 @@ namespace Usm.Util {
         }
     }
 
+    /**
+     * Archives `source` into `archive` (tar.xz), reporting compression
+     * progress by parsing xz's verbose stderr. The callback receives a
+     * 0..1 fraction.
+     */
+    public static void archive_with_progress(string source, string archive_path, Usm.ProgressDelegate? progress) throws Error {
+        if(progress == null) {
+            archive(source, archive_path);
+            return;
+        }
+
+        var proc = new Subprocess.newv(
+            new string[] { "bash", "-c",
+                @"tar -cf - --directory '$source' . | xz -T0 -v -c > '$(archive_path)'" },
+            SubprocessFlags.STDERR_PIPE);
+
+        var stderr_pipe = new DataInputStream(proc.get_stderr_pipe());
+        stderr_pipe.set_buffer_size(512);
+
+        string line;
+        while((line = stderr_pipe.read_line(null)) != null) {
+            var trimmed = line.strip();
+            if(trimmed.length > 0 && trimmed[0].isdigit()) {
+                var space = trimmed.index_of(" ");
+                if(space > 0) {
+                    var pct_str = trimmed.substring(0, space);
+                    var pct = double.parse(pct_str);
+                    if(pct >= 0.0 && pct <= 100.0) {
+                        progress((float)(pct / 100.0));
+                    }
+                }
+            }
+        }
+        proc.wait_check();
+    }
+
 }

+ 19 - 0
src/lib/Version.vala

@@ -61,6 +61,25 @@ namespace Usm {
             return release == other.release;
         }
 
+        /**
+         * Prefix-tolerant numeric equality ignoring `+release` suffixes:
+         * 0.31 matches 0.31.0, and 0.31+2 matches 0.31. Resource-level
+         * versions (soname tails, .pc `Version:` fields) never carry
+         * packaging releases and upstreams disagree about component
+         * counts, so this is the equality resource constraints use.
+         */
+        public bool matches_numeric(Version other) {
+            var mine = version_nums ?? new int[0];
+            var theirs = other.version_nums ?? new int[0];
+            var parts = int.min(theirs.length, mine.length);
+            for(int i = 0; i < parts; i++) {
+                if(mine[i] != theirs[i]) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
         public bool greater_than(Version other) {
             var parts = int.min(other.version_nums.length, version_nums.length);
             for(int i = 0; i < parts; i++) {

+ 165 - 0
src/lib/VersionConstraint.vala

@@ -0,0 +1,165 @@
+using Invercargill;
+
+namespace Usm {
+
+    /**
+     * The comparison a version constraint applies to a candidate version.
+     */
+    public enum VersionOperator {
+        /** The candidate's version equals the constraint's (numeric prefix equality). */
+        EQUALS,
+        /** The candidate's version is at least the constraint's. */
+        AT_LEAST,
+        /** The candidate's version is at most the constraint's. */
+        AT_MOST,
+        /**
+         * PEP 440 "compatible release": at least the constraint's version
+         * AND sharing every numeric component of it except the last, so
+         * `~=0.31.0` accepts 0.31.1 but not 0.32.0, while `~=0.31`
+         * accepts 0.33.0 but not 1.0.0.
+         */
+        COMPATIBLE;
+
+        public string to_string() {
+            switch (this) {
+                case VersionOperator.EQUALS:
+                    return "==";
+                case VersionOperator.AT_LEAST:
+                    return ">=";
+                case VersionOperator.AT_MOST:
+                    return "<=";
+                case VersionOperator.COMPATIBLE:
+                    return "~=";
+                default:
+                    assert_not_reached();
+            }
+        }
+
+        public static VersionOperator from_string(string str) throws ManifestError {
+            switch (str) {
+                case "==":
+                    return VersionOperator.EQUALS;
+                case ">=":
+                    return VersionOperator.AT_LEAST;
+                case "<=":
+                    return VersionOperator.AT_MOST;
+                case "~=":
+                    return VersionOperator.COMPATIBLE;
+                default:
+                    throw new ManifestError.INVALID_VERSION(@"Unknown version operator \"$str\" (expected ==, >=, <= or ~=).");
+            }
+        }
+    }
+
+    /**
+     * A version constraint attached to a dependency resource ref, e.g.
+     * the `>=0.30.0` of `lib:libcmark.so>=0.30.0`.
+     *
+     * Comparison semantics are deliberately prefix-tolerant on numeric
+     * components (0.31 == 0.31.0) and ignore `+release` suffixes:
+     * resource-level versions come from soname tails and .pc `Version:`
+     * fields, which never carry packaging releases, while upstream
+     * conventions disagree about how many components to write.
+     */
+    public class VersionConstraint : Object {
+
+        /** The operator applied to {@link version}. */
+        public VersionOperator operation { get; set; }
+        /** The constraint's own version. */
+        public Version version { get; set; }
+
+        public VersionConstraint(VersionOperator operation, Version version) {
+            this.operation = operation;
+            this.version = version;
+        }
+
+        public string to_string() {
+            return @"$(operation.to_string())$(version.to_string())";
+        }
+
+        /** Whether {@link candidate} satisfies this constraint. */
+        public bool satisfied_by(Version candidate) {
+            switch (operation) {
+                case VersionOperator.EQUALS:
+                    return candidate.matches_numeric(version);
+                case VersionOperator.AT_LEAST:
+                    return !candidate.less_than(version);
+                case VersionOperator.AT_MOST:
+                    return !candidate.greater_than(version);
+                case VersionOperator.COMPATIBLE:
+                    if(candidate.less_than(version)) {
+                        return false;
+                    }
+                    // Pin every component of the constraint except its last
+                    var pinned = version.version_nums.length - 1;
+                    for(int index = 0; index < pinned; index++) {
+                        var component = index < candidate.version_nums.length
+                            ? candidate.version_nums[index] : 0;
+                        if(component != version.version_nums[index]) {
+                            return false;
+                        }
+                    }
+                    return true;
+                default:
+                    assert_not_reached();
+            }
+        }
+
+        /**
+         * Locates and parses the FIRST version operator in {@link text}.
+         *
+         * Returns the constraint and sets {@link resource_base} to
+         * everything before the operator (the resource's base name);
+         * null with {@link resource_base} == {@link text} when no
+         * operator is present. A trailing/leading operator, an
+         * unparseable version, a `~=`
+         * with fewer than two numeric components or a second operator
+         * in the version all throw {@link ManifestError.INVALID_VERSION}.
+         */
+        public static VersionConstraint? find_in(string text, out string resource_base) throws ManifestError {
+            long found_at = -1;
+            string found_token = null;
+            foreach(var token in new string[] { "==", ">=", "<=", "~=" }) {
+                var at = text.index_of(token);
+                if(at >= 0 && (found_at < 0 || at < found_at)) {
+                    found_at = at;
+                    found_token = token;
+                }
+            }
+            if(found_at < 0) {
+                resource_base = text;
+                return null;
+            }
+
+            resource_base = text.substring(0, found_at);
+            var version_text = text.substring(found_at + found_token.length);
+            if(resource_base.length == 0 || version_text.length == 0) {
+                throw new ManifestError.INVALID_VERSION(
+                    @"Version operator \"$found_token\" must sit between a resource name and a version in \"$text\"."
+                );
+            }
+
+            Version parsed_version = null;
+            try {
+                parsed_version = new Version.from_string(version_text);
+            }
+            catch(Error e) {
+                throw new ManifestError.INVALID_VERSION(
+                    @"Could not parse the version of the constraint in \"$text\": $(e.message)"
+                );
+            }
+
+            var operation = found_token == "=="
+                ? VersionOperator.EQUALS
+                : found_token == ">=" ? VersionOperator.AT_LEAST
+                : found_token == "<=" ? VersionOperator.AT_MOST
+                : VersionOperator.COMPATIBLE;
+            if(operation == VersionOperator.COMPATIBLE && parsed_version.version_nums.length < 2) {
+                throw new ManifestError.INVALID_VERSION(
+                    @"The ~= operator needs at least two version components (e.g. ~=0.31), \"$text\" has fewer."
+                );
+            }
+            return new VersionConstraint(operation, parsed_version);
+        }
+    }
+}

+ 20 - 8
src/lib/meson.build

@@ -9,16 +9,23 @@ sources += files('Git.vala')
 sources += files('Licence.vala')
 sources += files('Paths.vala')
 sources += files('ResourceRef.vala')
+sources += files('ResourceFinder.vala')
+sources += files('PcFile.vala')
 sources += files('Version.vala')
+sources += files('VersionConstraint.vala')
 sources += files('Resolver.vala')
 sources += files('Util.vala')
 sources += files('Transaction.vala')
+sources += files('Journal.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')
 sources += files('Repository/RepositoryClient.vala')
 sources += files('Repository/GioRepositoryClient.vala')
+sources += files('Repository/CurlRepositoryClient.vala')
 sources += files('State/State.vala')
 sources += files('State/CachedPackage.vala')
 sources += files('State/OriginInformation.vala')
@@ -38,22 +45,27 @@ dependencies = [
     meson.get_compiler('c').find_library('sodium'),
 ]
 
-usm = shared_library('usm', sources,
+library_version = meson.project_version()
+usm = shared_library('usm-@0@'.format(library_version), sources,
     dependencies: dependencies,
     install: true,
-    vala_gir: 'usm-1.0.gir',
+    vala_gir: 'usm-@0@.gir'.format(library_version),
     install_dir: [true, true, true, true]
 )
 usm_dep = declare_dependency(link_with: usm, include_directories: include_directories('.'))
 
 pkg = import('pkgconfig')
 pkg.generate(usm,
-    version : '0.1',
-    name : 'usm',)
+    version : library_version,
+    name : 'usm-@0@'.format(library_version))
     
-g_ir_compiler = find_program('g-ir-compiler')
-custom_target('usm typelib', command: [g_ir_compiler, '--shared-library=libusm.so', '--output', '@OUTPUT@', meson.current_build_dir() / 'usm-1.0.gir'],
-              output: 'usm-1.0.typelib',
+# g-ir-compiler is unavailable on some platforms (e.g. musl/alpine);
+# generate the typelib only when the tool is present
+g_ir_compiler = find_program('g-ir-compiler', required: false)
+if g_ir_compiler.found()
+    custom_target('usm typelib', command: [g_ir_compiler, '--shared-library=libusm-@0@.so'.format(library_version), '--output', '@OUTPUT@', meson.current_build_dir() / 'usm-@0@.gir'.format(library_version)],
+              output: 'usm-@0@.typelib'.format(library_version),
               depends: usm,
               install: true,
-              install_dir: get_option('libdir') / 'girepository-1.0')
+              install_dir: get_option('libdir') / 'girepository-1.0')
+endif

+ 18 - 2
src/meson.build

@@ -1,5 +1,21 @@
-project('Universal Source Manifest', 'vala', 'c')
+project('Universal Source Manifest', 'vala', 'c', version: '0.1')
 vapi_dir = meson.current_source_dir() / 'vapi'
 
+# The SPM helpers live outside the source root; copying them in keeps the
+# install declarative and preserves the executable bit via install_mode
+spm_shims = []
+foreach shim : ['dnf', 'apt', 'apk', 'emerge', 'pacman']
+    spm_shims += configure_file(
+        input: '../spm' / shim / ('usm-spm-' + shim),
+        output: 'usm-spm-' + shim,
+        copy: true,
+    )
+endforeach
+install_data(spm_shims,
+    install_dir: get_option('bindir'),
+    install_mode: 'rwxr-xr-x',
+)
+
 subdir('lib')
-subdir('cli')
+subdir('cli')
+subdir('tests')

+ 2333 - 0
src/tests/TestMain.vala

@@ -0,0 +1,2333 @@
+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_rebuild_dependants_flag() throws Error {
+        check(Usm.ManifestFlag.REBUILD_DEPENDANTS.to_string() == "rebuildDependants", "rebuildDependants flag serialises");
+        check(Usm.ManifestFlag.from_string("rebuildDependants") == Usm.ManifestFlag.REBUILD_DEPENDANTS, "rebuildDependants flag parses");
+
+        var flagged = manifest_from_json(APP_MANIFEST.printf("invercargill", "invercargill", depends_with("runtime", "[]"))
+            .replace("\"flags\": []", "\"flags\": [\"rebuildDependants\"]"));
+        check(flagged.flags.contains(Usm.ManifestFlag.REBUILD_DEPENDANTS), "rebuildDependants parses within a manifest flags array");
+
+        var serialised = ((!)new JsonElement.from_properties(Usm.Manifest.get_mapper().map_from(flagged))).stringify_pretty();
+        check(manifest_from_json(serialised).flags.contains(Usm.ManifestFlag.REBUILD_DEPENDANTS),
+            "rebuildDependants round-trips through serialisation");
+    }
+
+    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());
+    }
+
+    // ---- GIO module resource type ------------------------------------------------
+
+    void test_gio_module_ref() throws Error {
+        var resource_ref = new Usm.ResourceRef("gio:libgiognutls.so");
+        check(resource_ref.resource_type == Usm.ResourceType.GIO_MODULE, "gio: parses as a GIO module ref");
+        check(resource_ref.resource == "libgiognutls.so", "the gio: name is the module filename");
+        check(Usm.ResourceType.GIO_MODULE.to_string() == "gio", "gio round-trips through to_string");
+        check(resource_ref.to_string() == "gio:libgiognutls.so", "the GIO module ref round-trips");
+
+        var located = new Usm.ResourceFinder().locate_resource(resource_ref);
+        check(located != null && located.has_suffix("/gio/modules/libgiognutls.so"),
+            "the ResourceFinder locates libgiognutls.so in a gio/modules directory");
+    }
+
+    // ---- Repository listing size field -------------------------------------------
+
+    void test_repository_listing_entry_size() throws Error {
+        var mapper = Usm.RepositoryListingEntry.get_mapper();
+        var entry = new Usm.RepositoryListingEntry() {
+            path = "sized-pkg-1.0.0.usmc",
+            manifest = manifest_from_json(APP_MANIFEST.printf("sized-pkg", "sized-pkg", depends_with("runtime", "[]"))),
+            sha512sum = Wrap.byte_array({ 1, 2, 3 }),
+            size = 4096
+        };
+
+        var serialised = ((!)new JsonElement.from_properties(mapper.map_from(entry))).stringify_pretty();
+        check(serialised.contains("\"size\"") && serialised.contains("4096"), "size serialises into the listing entry");
+
+        var reparsed = mapper.materialise(new JsonElement.from_string(serialised).as<Invercargill.Properties>());
+        check(reparsed.path == "sized-pkg-1.0.0.usmc" && reparsed.size == 4096, "size round-trips through serialisation");
+
+        JsonElement removed;
+        var legacy = new JsonElement.from_string(serialised).as<JsonObject>();
+        legacy.remove("size", out removed);
+        var pre_upgrade = mapper.materialise(legacy);
+        check(pre_upgrade.size == 0, "a pre-upgrade entry without size materialises as 0");
+    }
+
+    // ---- 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");
+    }
+
+    // ---- Version constraints -----------------------------------------------------
+
+    Usm.Version version(string text) throws Error {
+        return new Usm.Version.from_string(text);
+    }
+
+    void test_version_constraint_semantics() throws Error {
+        string parsed_base = null;
+        var constraint = Usm.VersionConstraint.find_in("libcmark.so>=0.30.0", out parsed_base);
+        check(constraint != null && parsed_base == "libcmark.so", "operator splits base from constraint");
+        check(constraint.to_string() == ">=0.30.0", "constraint round-trips");
+
+        var at_least = ((!)constraint);
+        check(at_least.satisfied_by(version("0.30.0")), ">= accepts the boundary");
+        check(at_least.satisfied_by(version("0.31.1")), ">= accepts newer");
+        check(at_least.satisfied_by(version("1.2.0")), ">= accepts major bumps");
+        check(!at_least.satisfied_by(version("0.29.9")), ">= rejects older");
+
+        string compatible_base = null;
+        var compatible = (!)Usm.VersionConstraint.find_in("libcmark.so~=0.31.0", out compatible_base);
+        check(compatible.satisfied_by(version("0.31.0")), "~= accepts the boundary");
+        check(compatible.satisfied_by(version("0.31.1")), "~= accepts patch bumps within the family");
+        check(!compatible.satisfied_by(version("0.32.0")), "~= rejects minor bumps");
+        check(!compatible.satisfied_by(version("1.0.0")), "~= rejects major bumps");
+
+        string wide_base = null;
+        var wide = (!)Usm.VersionConstraint.find_in("libcmark.so~=0.31", out wide_base);
+        check(wide.satisfied_by(version("0.33.0")), "two-component ~= pins only the major");
+        check(!wide.satisfied_by(version("1.0.0")), "two-component ~= still rejects major bumps");
+
+        string equals_base = null;
+        var equals = (!)Usm.VersionConstraint.find_in("libcmark.so==0.31", out equals_base);
+        check(equals.satisfied_by(version("0.31")), "== accepts the exact version");
+        check(equals.satisfied_by(version("0.31.0")), "== tolerates omitted trailing components");
+        check(equals.satisfied_by(version("0.31.5")), "== treats missing trailing components as unspecified");
+        check(!equals.satisfied_by(version("0.32.0")), "== rejects a differing minor");
+
+        string at_most_base = null;
+        var at_most = (!)Usm.VersionConstraint.find_in("libcmark.so<=0.31.1", out at_most_base);
+        check(at_most.satisfied_by(version("0.31.0")), "<= accepts older");
+        check(at_most.satisfied_by(version("0.31.1")), "<= accepts the boundary");
+        check(!at_most.satisfied_by(version("0.32.0")), "<= rejects newer");
+
+        check(Usm.VersionConstraint.find_in("libcmark.so", out parsed_base) == null && parsed_base == "libcmark.so",
+            "operator-free text carries no constraint");
+
+        var rejected = new Vector<string>();
+        foreach(var malformed in new string[] { "libcmark.so>=", ">=0.31.0", "libcmark.so>=0.31rc1", "libcmark.so~=0", "libcmark.so>=1<=2" }) {
+            try {
+                Usm.VersionConstraint.find_in(malformed, out parsed_base);
+                rejected.add(malformed);
+            }
+            catch(Error e) {
+            }
+        }
+        check(rejected.count() == 0, @"malformed constraints are rejected ($(rejected.to_string(s => s, ", ")))");
+    }
+
+    void test_resource_ref_operator_parsing() throws Error {
+        var constrained = new Usm.ResourceRef("lib:libcmark.so.0>=0.30.0");
+        check(constrained.base_name == "libcmark.so.0", "base name drops the constraint");
+        check(constrained.resource == "libcmark.so.0>=0.30.0", "full resource keeps the constraint");
+        check(constrained.to_string() == "lib:libcmark.so.0>=0.30.0", "ref round-trips");
+
+        var plain = new Usm.ResourceRef("lib:libcmark.so.0");
+        check(plain.base_name == "libcmark.so.0" && plain.constraint == null, "plain ref carries no constraint");
+
+        var malformed = new Vector<string>();
+        foreach(var broken in new string[] { "lib", "lib:", "", ":" }) {
+            try {
+                new Usm.ResourceRef(broken);
+                malformed.add(broken);
+            }
+            catch(Error e) {
+            }
+        }
+        check(malformed.count() == 0, @"structurally invalid refs are rejected ($(malformed.to_string(s => s, ", ")))");
+
+        var tail = Usm.ResourceRef.version_tail("libcmark.so", "libcmark.so.0.31.1");
+        check(tail != null && ((!)tail).to_string() == "0.31.1", "soname tail parses as a version");
+        check(Usm.ResourceRef.version_tail("libcmark.so", "libcmark.so") == null, "unversioned soname has no tail");
+        check(Usm.ResourceRef.version_tail("libcmark.so", "libcmark.so.0.31rc1") == null, "non-numeric tail is no version");
+        check(Usm.ResourceRef.version_tail("libcmark.so", "libcmark.so.0.31.1.2") != null, "four-component tails parse");
+        check(Usm.ResourceRef.version_tail("libcmark.so", "libothercmark.so.0.31.1") == null, "tails must extend the exact base");
+    }
+
+    void test_depends_operator_type_rules() throws Error {
+        var accepted = manifest_from_json(PHASE_MANIFEST.printf(
+            "{ \"runtime\": [\"lib:libcmark.so>=0.30.0\"], \"build\": [\"pc:libcmark.pc==0.31.1\"], \"manage\": [] }"));
+        check(accepted.dependencies.runtime.ordered_required().first().to_string() == "lib:libcmark.so>=0.30.0",
+            "lib dependencies accept operators");
+        check(accepted.dependencies.build.ordered_required().first().to_string() == "pc:libcmark.pc==0.31.1",
+            "pc dependencies accept operators");
+
+        var violations = new Vector<string>();
+        foreach(var rule in new string[] { "bin:python3>=3.8", "tag:family.feature>=1.0", "inc:glib-2.0>=2.0", "vapi:gtk-4.0.vapi~=4.0" }) {
+            try {
+                manifest_from_json(PHASE_MANIFEST.printf(@"{ \"runtime\": [\"$rule\"], \"build\": [], \"manage\": [] }"));
+                violations.add(rule);
+            }
+            catch(Error e) {
+            }
+        }
+        check(violations.count() == 0, @"operators on non-lib/pc dependency types are rejected ($(violations.to_string(s => s, ", ")))");
+    }
+
+    void test_provides_declaration_rules() throws Error {
+        var declared = manifest_from_json("""
+        {
+          "name": "cmark-lib",
+          "version": "0.31.1",
+          "summary": "cmark",
+          "licences": [],
+          "flags": [],
+          "provides": { "pc:cmark.pc==0.31.1": "as-expected", "lib:libcmark.so.0.31.1": "as-expected" },
+          "depends": { "runtime": [], "build": [], "manage": [] },
+          "execs": {}
+        }
+        """);
+        check(declared.provides.count() == 2, "pc == declarations and concrete lib provides parse");
+
+        var violations = new Vector<string>();
+        foreach(var rule in new string[] {
+            "\"lib:libcmark.so.0.31.1==0.31.1\"",
+            "\"pc:cmark.pc>=0.31.1\"",
+            "\"bin:cmark==1.0\""
+        }) {
+            try {
+                manifest_from_json(@"{ \"name\": \"x\", \"version\": \"1.0.0\", \"summary\": \"x\", \"licences\": [], \"flags\": [], \"provides\": { $rule: \"as-expected\" }, \"depends\": { \"runtime\": [], \"build\": [], \"manage\": [] }, \"execs\": {} }");
+                violations.add(rule);
+            }
+            catch(Error e) {
+            }
+        }
+        check(violations.count() == 0, @"only pc provides may carry == declarations ($(violations.to_string(s => s, ", ")))");
+    }
+
+    void test_satisfied_by_constraints() throws Error {
+        var floor = new Usm.ResourceRef("lib:libcmark.so>=0.30.0");
+        check(floor.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.31.1")), "floor matches a newer soname tail");
+        check(floor.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.30.0")), "floor matches the boundary tail");
+        check(!floor.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.29.9")), "floor rejects an older tail");
+        check(!floor.satisfied_by(new Usm.ResourceRef("lib:libcmark.so")), "floor rejects an unversioned name");
+        check(!floor.satisfied_by(new Usm.ResourceRef("lib:libother.so.0.31.1")), "floor requires the same base name");
+        check(floor.satisfied_by(new Usm.ResourceRef("canonlib:libcmark.so.0.31.1")), "floor accepts a canonlib provider");
+        check(!floor.satisfied_by(new Usm.ResourceRef("pc:libcmark.pc")), "floor does not cross resource types");
+
+        var family = new Usm.ResourceRef("lib:libcmark.so~=0.31.0");
+        check(family.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.31.1")), "family matches within the minor");
+        check(!family.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.32.0")), "family rejects the next minor");
+
+        var plain = new Usm.ResourceRef("lib:libcmark.so.0.31.1");
+        check(plain.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.31.1")), "plain dep matches the exact provide");
+        check(!plain.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.30.0")), "plain dep still requires exactness");
+        check(plain.satisfied_by(new Usm.ResourceRef("canonlib:libcmark.so.0.31.1")), "plain dep accepts canonlib providers");
+
+        var plain_pc = new Usm.ResourceRef("pc:cmark.pc");
+        check(plain_pc.satisfied_by(new Usm.ResourceRef("pc:cmark.pc==0.31.1")), "plain pc dep matches a declared provide");
+        var declared_pc = new Usm.ResourceRef("pc:cmark.pc>=0.30.0");
+        check(declared_pc.satisfied_by(new Usm.ResourceRef("pc:cmark.pc==0.31.1")), "pc floor matches a satisfying declaration");
+        check(!declared_pc.satisfied_by(new Usm.ResourceRef("pc:cmark.pc==0.29.0")), "pc floor rejects a low declaration");
+        check(!declared_pc.satisfied_by(new Usm.ResourceRef("pc:cmark.pc")), "pc floor cannot be proven by an undeclared provide");
+    }
+
+    void test_manifest_satisfies_pc_fallback() throws Error {
+        var provider = manifest_from_json("""
+        {
+          "name": "cmark-lib",
+          "version": "0.31.1",
+          "summary": "cmark",
+          "licences": [],
+          "flags": [],
+          "provides": { "pc:cmark.pc": "as-expected", "pc:other.pc==0.5.0": "as-expected" },
+          "depends": { "runtime": [], "build": [], "manage": [] },
+          "execs": {}
+        }
+        """);
+        check(provider.satisfies(new Usm.ResourceRef("pc:cmark.pc>=0.30.0")), "undeclared pc provide falls back to the package version");
+        check(provider.satisfies(new Usm.ResourceRef("pc:cmark.pc==0.31.1")), "fallback version is the package version");
+        check(!provider.satisfies(new Usm.ResourceRef("pc:cmark.pc>=0.32.0")), "fallback respects the floor");
+        check(provider.satisfies(new Usm.ResourceRef("pc:other.pc>=0.5.0")), "explicit declaration satisfies a floor");
+        check(!provider.satisfies(new Usm.ResourceRef("pc:other.pc>=0.6.0")), "explicit declaration wins over the package version");
+        check(!provider.satisfies(new Usm.ResourceRef("pc:absent.pc>=0.1")), "unrelated names never match");
+    }
+
+    // ---- 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
+    ;;
+  plan)
+    # Every material package: dependencies first, chosen names after
+    printf '{"packages":['
+    first=1
+    for name in "$@"; do
+      if [ $first -eq 0 ]; then printf ','; fi
+      first=0
+      printf '"dep-of-%s"' "$name"
+    done
+    for name in "$@"; do
+      printf ',"%s"' "$name"
+    done
+    printf ']}\n'
+    ;;
+  install)
+    total=$#
+    echo "{\"type\":\"begin\",\"total\":$total}"
+    # Like dnf: a download phase streaming package events in its own
+    # order, then an rpm transaction completing in a DIFFERENT order
+    # than the plan's — the display must not depend on either matching
+    for name in "$@"; do
+      echo "{\"type\":\"package\",\"name\":\"$name\",\"current\":$RANDOM,\"total\":999,\"progress\":0.4}"
+    done
+    for name in $(printf '%s\n' "$@" | tac); do
+      echo "{\"type\":\"package\",\"name\":\"dep-of-$name\",\"current\":$RANDOM,\"total\":999,\"progress\":0.5}"
+    done
+    n=0
+    for name in $(printf '%s\n' "$@" | tac); do
+      echo "{\"type\":\"package\",\"name\":\"dep-of-$name\",\"current\":$n,\"total\":999,\"progress\":0.9}"
+      echo "{\"type\":\"package-complete\",\"name\":\"dep-of-$name\"}"
+      echo "{\"type\":\"package\",\"name\":\"$name\",\"current\":$n,\"total\":999,\"progress\":0.95}"
+      echo "{\"type\":\"package-complete\",\"name\":\"$name\"}"
+      echo "$name" >> "$USM_SPM_INSTALL_LOG"
+      n=$((n+1))
+    done
+    # A duplicate completion, as dnf emits for repeated members
+    echo "{\"type\":\"package-complete\",\"name\":\"$1\"}"
+    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);
+    }
+
+    /** Builds a .usmc archive from a complete manifest JSON string. */
+    string make_archive_from_manifest(string scratch, string manifest_json) throws Error {
+        var source = Path.build_filename(scratch, @"src-$(Uuid.string_random())");
+        DirUtils.create(source, 0755);
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"), manifest_json);
+        var archive = Path.build_filename(scratch, @"pkg-$(Uuid.string_random()).usmc");
+        Usm.Util.archive(source, archive);
+        return archive;
+    }
+
+    void test_resolver_constrained_lib_dep() throws Error {
+        var scratch = make_scratch();
+        var resolver = new Usm.Resolver(new Usm.ResourceFinder());
+        resolver.supply_package(make_archive_from_manifest(scratch, """
+        {
+          "name": "usmt-cmark",
+          "version": "0.31.1",
+          "summary": "constrained provider",
+          "licences": [],
+          "flags": [],
+          "provides": { "lib:libusmt-cmark.so.0.31.1": "as-expected" },
+          "depends": { "runtime": [], "build": [], "manage": [] },
+          "execs": {}
+        }
+        """));
+
+        var roots = new Vector<Usm.AbstractPackage>();
+        roots.add(new Usm.AbstractPackage.from_manifest(manifest_from_json(
+            APP_MANIFEST.printf("cmark-app", "cmark-app", depends_with("runtime", "[\"lib:libusmt-cmark.so>=0.30.0\"]")))));
+        var result = resolver.resolve(roots, null);
+
+        check(result.packages.count() == 2, "a constrained lib dep pulls its USM provider");
+        check(result.install_order.to_string(p => p.manifest.name, ",") == "usmt-cmark,cmark-app",
+            "the provider installs before the dependent");
+        check(result.system_packages.length == 0, "no system packages without an SPM");
+
+        // The same provider does not satisfy a family constraint it breaks
+        var roots_mismatch = new Vector<Usm.AbstractPackage>();
+        roots_mismatch.add(new Usm.AbstractPackage.from_manifest(manifest_from_json(
+            APP_MANIFEST.printf("cmark-app2", "cmark-app2", depends_with("runtime", "[\"lib:libusmt-cmark.so~=0.30.0\"]")))));
+        try {
+            resolver.resolve(roots_mismatch, null);
+            check(false, "an unsatisfiable family constraint fails resolution");
+        }
+        catch(Error e) {
+            check(e is Usm.ResolverError, "an unsatisfiable family constraint fails resolution");
+        }
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_resolver_skips_spm_for_constrained_refs() throws Error {
+        var scratch = make_scratch();
+        var resolver = new Usm.Resolver(new Usm.ResourceFinder());
+        resolver.supply_package(make_archive_from_manifest(scratch, """
+        {
+          "name": "usmt-skip",
+          "version": "1.0.0",
+          "summary": "skip provider",
+          "licences": [],
+          "flags": [],
+          "provides": { "lib:libusmt-skip.so.2.1.0": "as-expected" },
+          "depends": { "runtime": [], "build": [], "manage": [] },
+          "execs": {}
+        }
+        """));
+
+        var roots = new Vector<Usm.AbstractPackage>();
+        roots.add(new Usm.AbstractPackage.from_manifest(manifest_from_json(
+            APP_MANIFEST.printf("skip-app", "skip-app",
+                depends_with("runtime", "[\"lib:libusmt-skip.so>=2.0.0\", \"bin:usmt-plain\"]")))));
+
+        var spm = stub_manager(scratch, spm_candidate("spm-owns-plain", "bin:usmt-plain", 2, 1));
+        var result = resolver.resolve(roots, spm);
+
+        check(result.packages.count() == 2, "the constrained dep resolves from the USM provider");
+        check(result.system_packages.length == 1 && result.system_packages[0].name == "spm-owns-plain",
+            "the plain dep still resolves from the SPM");
+
+        var log = "";
+        FileUtils.get_contents(Path.build_filename(scratch, "query.log"), out log);
+        check(log.contains("usmt-skip.so>=") == false, "the SPM is never queried for the constrained ref");
+        check(log.contains("bin:usmt-plain"), "the SPM is queried for plain refs in the same batch");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_pc_finder_version_constraint() throws Error {
+        var scratch = make_scratch();
+        var pcdir = Path.build_filename(scratch, "pkgconfig");
+        DirUtils.create(pcdir, 0755);
+        FileUtils.set_contents(Path.build_filename(pcdir, "usmt.pc"),
+            "prefix=/usr\n\nName: usmt\nDescription: test\nVersion: 1.2.3\nCflags: -I/usr/include\n");
+
+        var previous = Environment.get_variable("PKG_CONFIG_PATH");
+        Environment.set_variable("PKG_CONFIG_PATH", pcdir, true);
+        var finder = new Usm.ResourceFinder();
+        try {
+            check(finder.has_resource(new Usm.ResourceRef("pc:usmt.pc")), "plain pc ref found by existence");
+            check(finder.has_resource(new Usm.ResourceRef("pc:usmt.pc>=1.0.0")), "floor below the declared version satisfies");
+            check(finder.has_resource(new Usm.ResourceRef("pc:usmt.pc==1.2.3")), "== the declared version satisfies");
+            check(finder.has_resource(new Usm.ResourceRef("pc:usmt.pc~=1.2.0")), "~= within the declared minor satisfies");
+            check(!finder.has_resource(new Usm.ResourceRef("pc:usmt.pc>=2.0")), "floor above the declared version fails");
+            check(!finder.has_resource(new Usm.ResourceRef("pc:usmt.pc~=1.3.0")), "~= outside the declared minor fails");
+            check(!finder.has_resource(new Usm.ResourceRef("pc:usmt-absent.pc>=1.0")), "absent pc files fail");
+        }
+        finally {
+            if(previous != null) {
+                Environment.set_variable("PKG_CONFIG_PATH", previous, true);
+            }
+            else {
+                Environment.unset_variable("PKG_CONFIG_PATH");
+            }
+        }
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_pc_file_read_version() throws Error {
+        var scratch = make_scratch();
+        var path = Path.build_filename(scratch, "a.pc");
+
+        FileUtils.set_contents(path, "# comment\nName: a\n Version:  2.1.0  \nCflags: -I${prefix}/include\n");
+        var parsed = Usm.PcFile.read_version(path);
+        check(parsed != null && ((!)parsed).to_string() == "2.1.0", "version keyword parsed with surrounding whitespace");
+
+        FileUtils.set_contents(path, "prefix=/usr\nName: a\nVersion: ${pcfiledir}/ver\n");
+        check(Usm.PcFile.read_version(path) == null, "variable-expanding version yields null");
+
+        FileUtils.set_contents(path, "Name: a\nDescription: no version here\n");
+        check(Usm.PcFile.read_version(path) == null, "missing version keyword yields null");
+
+        FileUtils.set_contents(path, "Name: a\nVersion: 1.0rc1\n");
+        check(Usm.PcFile.read_version(path) == null, "non-numeric version yields null");
+
+        check(Usm.PcFile.read_version(Path.build_filename(scratch, "absent.pc")) == null, "unreadable file yields null");
+        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.contains("one") && install_log.contains("two"), "install helper received both package names");
+
+        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);
+    }
+
+    void test_transaction_preinstalled_resources_satisfy_build_deps() throws Error {
+        var scratch = make_scratch();
+        // A package whose build phase needs something nothing on this
+        // machine and nothing in the transaction provides — the exact
+        // shape of a system-package-provided toolchain dependency in a
+        // freshly provisioned container
+        make_build_package_archive(scratch, "s", "pc:lib-lot-s.pc", { "pc:no-such-file-anywhere-7f3.pc" });
+        var to_install = new HashSet<Usm.CachedPackage>();
+        to_install.add(cache_package(scratch, "s"));
+
+        var blocked = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>()
+        };
+        try {
+            blocked.strategise();
+            check(false, "a build ref nothing provides still blocks the transaction");
+        }
+        catch(Usm.TransactionError e) {
+            check(e.message.contains("pkg-s") && e.message.contains("pc:no-such-file-anywhere-7f3.pc"),
+                "the failure names the package and the unmet ref");
+        }
+
+        var preinstalled = new HashSet<Usm.ResourceRef>();
+        preinstalled.add(new Usm.ResourceRef("pc:no-such-file-anywhere-7f3.pc"));
+        var provisioned = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            preinstalled_resources = preinstalled,
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>()
+        };
+        provisioned.strategise();
+
+        check(provisioned.install_lots.length == 1, "preinstalled (system-provided) resources satisfy build-phase refs");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_transaction_deadlock_message_details_causes() throws Error {
+        var scratch = make_scratch();
+        // Two packages whose build phases need each other's provides —
+        // unschedulable under the earlier-lot rule for build-phase refs
+        make_build_package_archive(scratch, "x", "pc:lib-cyc-x.pc", { "pc:lib-cyc-y.pc" });
+        make_build_package_archive(scratch, "y", "pc:lib-cyc-y.pc", { "pc:lib-cyc-x.pc" });
+        make_build_package_archive(scratch, "z", "pc:lib-cyc-z.pc", { "pc:also-nowhere-at-all-2c9.pc" });
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        foreach(var name in new string[] { "x", "y", "z" }) {
+            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>()
+        };
+        try {
+            transaction.strategise();
+            check(false, "the mutual build dependency cannot be scheduled");
+        }
+        catch(Usm.TransactionError e) {
+            var message = e.message;
+            check(message.contains("3 package(s) could not be scheduled"), "the headline counts the stuck packages");
+            check(message.contains("pkg-x") && message.contains("pkg-y") && message.contains("pkg-z"), "every stuck package is named");
+            check(message.contains("pc:lib-cyc-y.pc (build phase) is part of a dependency cycle") && message.contains("pkg-y"),
+                "the cyclic ref names the ref, its phase and its stuck provider");
+            check(message.contains("pc:also-nowhere-at-all-2c9.pc (build phase) is unmet"), "the unmet ref names the ref and its phase");
+        }
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_transaction_installs_system_packages() throws Error {
+        var scratch = make_scratch();
+        var spm = stub_manager(scratch, "");
+
+        var candidates = new Vector<Usm.SystemPackageCandidate>();
+        foreach(var name in new string[] { "one", "two" }) {
+            var candidate = new Usm.SystemPackageCandidate();
+            candidate.name = name;
+            candidate.resources = new Vector<Usm.ResourceRef>();
+            candidates.add(candidate);
+        }
+
+        // The material set the stub's plan mode reports: dependencies
+        // first, chosen names after
+        var chosen_names = new Vector<string>();
+        chosen_names.add("one");
+        chosen_names.add("two");
+        var plan = spm.plan_install(chosen_names);
+        check(plan.length == 4 && plan[0] == "dep-of-one" && plan[3] == "two", "plan_install returns the material set in helper order");
+
+        var paths = new Usm.Paths.defaults();
+        paths.destination = scratch;
+
+        var transaction = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(),
+            system_package_manager = spm,
+            system_packages = candidates,
+            system_install_plan = plan,
+            to_install = new HashSet<Usm.CachedPackage>(),
+            to_remove = new HashSet<Usm.CachedPackage>()
+        };
+
+        var events = new Vector<string>();
+        uint last_total = 0;
+        transaction.progress_updated.connect((task, subject, index, total, progress) => {
+            var kind = task == TransactionTask.INSTALLING_SYSTEM ? "sys" : task == TransactionTask.STRATEGISING ? "plan" : "other";
+            events.add(@"$kind|$(subject ?? "")|$index|$total");
+            last_total = total;
+        });
+
+        transaction.strategise();
+        check(last_total == 5, "the task count covers every material system package");
+
+        transaction.run();
+
+        // Completions claim slots IN ARRIVAL ORDER: the stub completes
+        // the plan set in a different order than the plan lists it, and
+        // its download events stream in yet another order with nonsense
+        // positions — the actions must follow the real install sequence
+        check(events.any(e => e.has_prefix("sys|dep-of-two|1|5")), "the first completion claims the first slot");
+        check(events.any(e => e.has_prefix("sys|two|2|5")), "the second completion claims the second slot");
+        check(events.any(e => e.has_prefix("sys|dep-of-one|3|5")), "the third completion claims the third slot");
+        check(events.any(e => e.has_prefix("sys|one|4|5")), "the fourth completion claims the fourth slot");
+        check(!events.any(e => e.has_prefix("other|")), "no USM action runs when the transaction holds only system packages");
+
+        string install_log;
+        FileUtils.get_contents(Path.build_filename(scratch, "install.log"), out install_log);
+        check(install_log.split("\n").length == 3 && install_log.contains("one") && install_log.contains("two"), "the native helper received both chosen names, dependencies excluded");
+
+        var summary = new Usm.TransactionSummary() {
+            system_installs = candidates,
+            system_dependencies = plan.where(n => n != "one" && n != "two").to_vector()
+        };
+        var plan_preview = summary.describe(false);
+        check(plan_preview.contains("install    one (system package)") && plan_preview.contains("install    two (system package)"), "the plan preview lists the chosen system package installs");
+        check(plan_preview.contains("install    dep-of-one (system dependency)") && plan_preview.contains("install    dep-of-two (system dependency)"), "the plan preview lists the material dependencies");
+        check(!summary.is_empty(), "system installs alone are a non-empty plan");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    // ---- rebuildDependants: lookup, planning, dedup ------------------------------
+
+    /** Manifest for a package named <name> at <version> providing one key with the given flags and depends. */
+    const string REBUILD_MANIFEST = """
+    {
+      "name": "%s",
+      "version": "%s",
+      "summary": "rebuild fixtures",
+      "licences": [],
+      "flags": [%s],
+      "provides": { "%s": "as-expected" },
+      "depends": %s,
+      "execs": {}
+    }
+    """;
+
+    /**
+     * Caches a package under <state_dir>/packages/<name>-<version> built
+     * from {@link REBUILD_MANIFEST}, optionally marks it installed, and
+     * returns its {@link Usm.CachedPackage}.
+     */
+    Usm.CachedPackage scratch_package(string state_dir, string name, string version, string provides, string flags, string depends, bool installed) throws Error {
+        var source = Path.build_filename(state_dir, @"src-$name-$version");
+        DirUtils.create(source, 0755);
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"),
+            REBUILD_MANIFEST.printf(name, version, flags, provides, depends));
+
+        var cache_path = Path.build_filename(state_dir, "packages", @"$name-$version");
+        DirUtils.create_with_parents(cache_path, 0755);
+        Usm.Util.archive(source, Path.build_filename(cache_path, "package.usmc"));
+        Usm.Util.delete_tree(source);
+
+        if(installed) {
+            File.new_build_filename(state_dir, "installed", @"$name-$version").make_symbolic_link(cache_path);
+        }
+        return new Usm.CachedPackage(cache_path);
+    }
+
+    /** A managed {@link Usm.SystemState} over a scratch tree: usm.config plus empty state directories. */
+    Usm.SystemState make_state(string scratch) throws Error {
+        var config_dir = Path.build_filename(scratch, "config");
+        var state_dir = Path.build_filename(scratch, "state");
+        DirUtils.create_with_parents(config_dir, 0700);
+        DirUtils.create_with_parents(state_dir, 0700);
+        foreach(var part in new string[] { "packages", "installed", "lists" }) {
+            DirUtils.create(Path.build_filename(state_dir, part), 0700);
+        }
+        FileUtils.set_contents(Path.build_filename(config_dir, "usm.config"),
+            "{\"is_managed\": true, \"managed\": {\"state_path\": \"%s\"}}".printf(state_dir));
+
+        var paths = new Usm.Paths();
+        paths.usm_config_dir = config_dir;
+        return new Usm.SystemState(paths);
+    }
+
+    void test_find_dependant_names() throws Error {
+        var scratch = make_scratch();
+        var state = make_state(scratch);
+        var state_dir = Path.build_filename(scratch, "state");
+
+        var updated = scratch_package(state_dir, "dep-a", "1.0.0", "pc:dep-a.pc", "", depends_with("runtime", "[]"), false);
+        // an older installed dep-a depending on its own provides is not its own dependant
+        scratch_package(state_dir, "dep-a", "0.9.0", "pc:dep-a.pc", "", depends_with("runtime", "[\"pc:dep-a.pc\"]"), true);
+        scratch_package(state_dir, "dep-b", "0.1", "bin:dep-b", "", depends_with("build", "[\"pc:dep-a.pc\"]"), true);
+        scratch_package(state_dir, "dep-c", "2.0", "bin:dep-c", "",
+            "{ \"runtime\": [[\"pc:dep-a.pc\", \"opt:other\"], [\"pc:elsewhere.pc\"]], \"build\": [], \"manage\": [] }", true);
+        scratch_package(state_dir, "unrelated", "1.0", "bin:unrelated", "", depends_with("runtime", "[\"bin:bash\"]"), true);
+
+        var dependants = state.find_dependant_names(updated);
+        check(dependants.length == 2, "exactly the two dependants are found");
+        check(dependants.any(n => n == "dep-b-0.1"), "a build-phase dependant is found");
+        check(dependants.any(n => n == "dep-c-2.0"), "a grouped-phase dependant is found via any candidate group");
+        check(dependants.no(n => n == "unrelated-1.0"), "a package with no dependency on the update is not found");
+        check(dependants.no(n => n == "dep-a-0.9.0"), "an older installed version of the update is not its own dependant");
+
+        // resource-level matching: a canonlib: provide satisfies a lib: dependency
+        var canonical = scratch_package(state_dir, "dep-libc", "1.0", "canonlib:dep-lib.so", "", depends_with("runtime", "[]"), false);
+        scratch_package(state_dir, "dep-lib", "3.0", "lib:dep-lib.so", "", depends_with("runtime", "[\"lib:dep-lib.so\"]"), true);
+        check(state.find_dependant_names(canonical).any(n => n == "dep-lib-3.0"), "a canonlib provide satisfies a lib dependency");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_rebuild_planning() throws Error {
+        var scratch = make_scratch();
+        var state = make_state(scratch);
+        var state_dir = Path.build_filename(scratch, "state");
+
+        // two flagged providers, each with an incoming update
+        scratch_package(state_dir, "liba", "1.0.0", "pc:liba.pc", "\"rebuildDependants\"", depends_with("runtime", "[]"), true);
+        var liba_update = scratch_package(state_dir, "liba", "2.0.0", "pc:liba.pc", "\"rebuildDependants\"", depends_with("runtime", "[]"), false);
+        scratch_package(state_dir, "libx", "1.0.0", "pc:libx.pc", "\"rebuildDependants\"", depends_with("runtime", "[]"), true);
+        var libx_update = scratch_package(state_dir, "libx", "2.0.0", "pc:libx.pc", "\"rebuildDependants\"", depends_with("runtime", "[]"), false);
+        // statum depends on liba in its build phase and libx at runtime
+        scratch_package(state_dir, "statum", "0.1", "bin:statum", "",
+            "{ \"runtime\": [\"pc:libx.pc\"], \"build\": [\"pc:liba.pc\"], \"manage\": [] }", true);
+        // client 1.0.0 depends on liba, but client 2.0.0 joins the transaction explicitly
+        scratch_package(state_dir, "client", "1.0.0", "bin:client", "", depends_with("build", "[\"pc:liba.pc\"]"), true);
+        var client_update = scratch_package(state_dir, "client", "2.0.0", "bin:client", "", depends_with("build", "[\"pc:liba.pc\"]"), false);
+        // libz updates without the flag, so its dependant must NOT rebuild
+        var libz_update = scratch_package(state_dir, "libz", "2.0.0", "pc:libz.pc", "", depends_with("runtime", "[]"), false);
+        scratch_package(state_dir, "user", "1.0.0", "bin:user", "", depends_with("build", "[\"pc:libz.pc\"]"), true);
+
+        var dependants = state.find_dependant_names(liba_update);
+        check(dependants.length == 2 && dependants.any(n => n == "statum-0.1") && dependants.any(n => n == "client-1.0.0"),
+            "the lookup sees both of liba's installed dependants before filtering");
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        to_install.add(liba_update);
+        to_install.add(libx_update);
+        to_install.add(client_update);
+        to_install.add(libz_update);
+        var transaction = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            state = state
+        };
+        transaction.strategise();
+
+        check(transaction.rebuilds.length == 1, "one rebuild is planned across two flagged providers");
+        check(transaction.rebuilds[0].package.package_name == "statum-0.1", "the shared dependant statum rebuilds exactly once");
+        check(transaction.rebuilds[0].trigger.package_name == "liba-2.0.0" || transaction.rebuilds[0].trigger.package_name == "libx-2.0.0",
+            "the rebuild records the flagged trigger");
+        check(transaction.rebuilds[0].package.state_path == Path.build_filename(state_dir, "packages", "statum-0.1"),
+            "the rebuild reuses the installed package's own state path");
+
+        var rebuilt = new HashSet<string>();
+        foreach(var entry in transaction.rebuilds) {
+            rebuilt.add(entry.package.package_name);
+        }
+        check(!rebuilt.has("client-1.0.0"), "a dependant already in to_install is not added as a rebuild");
+        check(!rebuilt.has("user-1.0.0"), "dependants of unflagged providers are not rebuilt");
+
+        var lot_names = new StringBuilder();
+        foreach(var lot in transaction.install_lots) {
+            lot_names.append(lot.to_string(p => p.package_name, ","));
+        }
+        check(!lot_names.str.contains("statum"), "a rebuild target never also builds through the install lots");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    // ---- explicit-install tracking ------------------------------------------------
+
+    void test_origin_information_explicit_install() throws Error {
+        var scratch = make_scratch();
+        var cache_path = Path.build_filename(scratch, "cache-explicit-pkg-1.0.0");
+        DirUtils.create(cache_path, 0755);
+        var cached = new Usm.CachedPackage(cache_path);
+
+        var explicit_record = new Usm.OriginInformation() {
+            repository = "repo",
+            listfile = "2020-03-20T14:34:42.382748.usml",
+            original_path = "explicit-pkg-1.0.0.usmc",
+            signature_verified = true,
+            explicitly_installed = true
+        };
+        cached.update_origin_information(explicit_record);
+        var read_explicit = cached.get_origin_information();
+        check(read_explicit.explicitly_installed, "explicitly_installed true survives an origin-info round trip");
+        check(read_explicit.repository == "repo" && read_explicit.signature_verified, "provenance fields survive the round trip");
+
+        var implicit_record = new Usm.OriginInformation();
+        implicit_record.explicitly_installed = false;
+        cached.update_origin_information(implicit_record);
+        var read_implicit = cached.get_origin_information();
+        check(!read_implicit.explicitly_installed, "explicitly_installed false survives an origin-info round trip");
+        check(read_implicit.repository == null, "a transaction-written record without provenance round-trips null fields");
+
+        FileUtils.set_contents(Path.build_filename(cache_path, "origin-info"),
+            "{\"repository\": \"repo\", \"listfile\": \"2020-03-20T14:34:42.382748.usml\", \"original_path\": \"explicit-pkg-1.0.0.usmc\", \"signature_verified\": true}");
+        check(!cached.get_origin_information().explicitly_installed, "an origin-info file without the field defaults to false");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    /** No-op build script: the install pipeline only needs it to succeed. */
+    const string NOOP_BUILD_SCRIPT = """#!/bin/bash
+exit 0
+""";
+
+    /**
+     * Caches a build-only package named <name> at 1.0.0 with a
+     * succeeding build script under <scratch>'s managed state and
+     * returns its cache path.
+     */
+    string explicit_flag_package(string scratch, string name) throws Error {
+        var cache_path = Path.build_filename(scratch, "state", "packages", @"$name-1.0.0");
+        DirUtils.create_with_parents(cache_path, 0755);
+
+        var source = Path.build_filename(scratch, @"src-$name");
+        DirUtils.create(source, 0755);
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"), BUILD_MANIFEST.printf(name, "1.0.0"));
+        FileUtils.set_contents(Path.build_filename(source, "build.sh"), NOOP_BUILD_SCRIPT);
+        FileUtils.chmod(Path.build_filename(source, "build.sh"), 0755);
+        Usm.Util.archive(source, Path.build_filename(cache_path, "package.usmc"));
+        Usm.Util.delete_tree(source);
+        return cache_path;
+    }
+
+    void test_transaction_records_explicit_flags() throws Error {
+        var original_dir = Environment.get_current_dir();
+        var scratch = make_scratch();
+        var state = make_state(scratch);
+
+        var named_path = explicit_flag_package(scratch, "flag-named");
+        var resolved_path = explicit_flag_package(scratch, "flag-resolved");
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        to_install.add(new Usm.CachedPackage(named_path));
+        to_install.add(new Usm.CachedPackage(resolved_path));
+
+        var explicit_packages = new Vector<string>();
+        explicit_packages.add("flag-named");
+
+        var transaction = new Usm.Transaction() {
+            paths = scratch_paths(scratch),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            state = state,
+            explicit_packages = explicit_packages
+        };
+        transaction.run();
+
+        check(new Usm.CachedPackage(named_path).get_origin_information().explicitly_installed,
+            "a package named in explicit_packages installs with the explicit mark");
+        check(!new Usm.CachedPackage(resolved_path).get_origin_information().explicitly_installed,
+            "a package only the resolution pulled in installs without the explicit mark");
+
+        var rebuild = new Usm.Transaction() {
+            paths = scratch_paths(scratch),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = new HashSet<Usm.CachedPackage>(),
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            state = state
+        };
+        rebuild.rebuild_package(new Usm.CachedPackage(named_path));
+        check(new Usm.CachedPackage(named_path).get_origin_information().explicitly_installed,
+            "a rebuild of the same cache directory keeps the explicit mark");
+
+        Environment.set_current_dir(original_dir);
+        Usm.Util.delete_tree(scratch);
+    }
+
+    // ---- build archive restoration + clean retry ---------------------------------
+
+    /** Manifest for a build-only package: an executable build script, no provides. */
+    const string BUILD_MANIFEST = """
+    {
+      "name": "%s",
+      "version": "%s",
+      "summary": "build cache fixture",
+      "licences": [],
+      "flags": [],
+      "provides": {},
+      "depends": { "runtime": [], "build": [], "manage": [] },
+      "execs": { "build": "build.sh" }
+    }
+    """;
+
+    /** Always fails; logs whether the build directory carried the archived marker. */
+    const string FAILING_BUILD_SCRIPT = """#!/bin/bash
+if [ -f "$1/from-archive" ]; then
+    echo restored >> "$USM_TEST_BUILD_LOG"
+else
+    echo fresh >> "$USM_TEST_BUILD_LOG"
+fi
+exit 1
+""";
+
+    /** Succeeds only against the restored archive, so a retry (fresh) fails. */
+    const string INCREMENTAL_BUILD_SCRIPT = """#!/bin/bash
+if [ -f "$1/from-archive" ]; then
+    echo restored >> "$USM_TEST_BUILD_LOG"
+    exit 0
+fi
+echo fresh >> "$USM_TEST_BUILD_LOG"
+exit 1
+""";
+
+    /** Paths with the destination rooted in <scratch> so nothing touches the real filesystem. */
+    Usm.Paths scratch_paths(string scratch) {
+        var paths = new Usm.Paths();
+        paths.destination = Path.build_filename(scratch, "dest");
+        return paths;
+    }
+
+    /**
+     * Caches a build-only package named <name> running <script>, optionally
+     * seeding its cache with a build.tar.xz holding a "from-archive" marker,
+     * and returns a transaction over it.
+     */
+    Usm.Transaction build_transaction(string scratch, string name, string script, bool with_archive) throws Error {
+        var state = make_state(scratch);
+        var cache_path = Path.build_filename(scratch, "state", "packages", @"$name-1.0.0");
+        DirUtils.create_with_parents(cache_path, 0755);
+
+        var source = Path.build_filename(scratch, @"src-$name");
+        DirUtils.create(source, 0755);
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"), BUILD_MANIFEST.printf(name, "1.0.0"));
+        FileUtils.set_contents(Path.build_filename(source, "build.sh"), script);
+        FileUtils.chmod(Path.build_filename(source, "build.sh"), 0755);
+        Usm.Util.archive(source, Path.build_filename(cache_path, "package.usmc"));
+        Usm.Util.delete_tree(source);
+
+        if(with_archive) {
+            var stale_build = Path.build_filename(scratch, "stale-build");
+            DirUtils.create(stale_build, 0755);
+            FileUtils.set_contents(Path.build_filename(stale_build, "from-archive"), "stale");
+            Usm.Util.archive(stale_build, Path.build_filename(cache_path, "build.tar.xz"));
+        }
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        to_install.add(new Usm.CachedPackage(cache_path));
+        return new Usm.Transaction() {
+            paths = scratch_paths(scratch),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            state = state
+        };
+    }
+
+    void test_build_archive_restore_and_clean_retry() throws Error {
+        var original_dir = Environment.get_current_dir();
+        var scratch = make_scratch();
+        var log_path = Path.build_filename(scratch, "build.log");
+        Environment.set_variable("USM_TEST_BUILD_LOG", log_path, true);
+
+        var transaction = build_transaction(scratch, "fail-pkg", FAILING_BUILD_SCRIPT, true);
+        var threw = false;
+        try {
+            transaction.run();
+        }
+        catch(Usm.TransactionError e) {
+            threw = true;
+            check(e.message.contains("fail-pkg"), "the clean-retry failure names the package");
+        }
+        check(threw, "a failing restored build followed by a failing clean retry fails the transaction");
+
+        string log = "";
+        FileUtils.get_contents(log_path, out log);
+        check(log == "restored\nfresh\n", "the clean retry fires once after the restored build fails");
+
+        Environment.set_current_dir(original_dir);
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_build_archive_restored_incrementally() throws Error {
+        var original_dir = Environment.get_current_dir();
+        var scratch = make_scratch();
+        var log_path = Path.build_filename(scratch, "build.log");
+        Environment.set_variable("USM_TEST_BUILD_LOG", log_path, true);
+
+        var cache_path = Path.build_filename(scratch, "state", "packages", "inc-pkg-1.0.0");
+        var transaction = build_transaction(scratch, "inc-pkg", INCREMENTAL_BUILD_SCRIPT, true);
+        transaction.run();
+
+        string log = "";
+        FileUtils.get_contents(log_path, out log);
+        check(log == "restored\n", "a successful build runs against the restored archive without a retry");
+        check(File.new_for_path(Path.build_filename(cache_path, "build.tar.xz")).query_exists(),
+            "cleanup re-archives the build directory for the next transaction");
+
+        Environment.set_current_dir(original_dir);
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_build_failure_without_cache_propagates() throws Error {
+        var original_dir = Environment.get_current_dir();
+        var scratch = make_scratch();
+        var log_path = Path.build_filename(scratch, "build.log");
+        Environment.set_variable("USM_TEST_BUILD_LOG", log_path, true);
+
+        var transaction = build_transaction(scratch, "clean-pkg", FAILING_BUILD_SCRIPT, false);
+        var threw = false;
+        try {
+            transaction.run();
+        }
+        catch(Usm.TransactionError e) {
+            threw = true;
+        }
+        check(threw, "a failing already-clean build propagates without a retry");
+
+        string log = "";
+        FileUtils.get_contents(log_path, out log);
+        check(log == "fresh\n", "a build without any cache is attempted exactly once");
+
+        Environment.set_current_dir(original_dir);
+        Usm.Util.delete_tree(scratch);
+    }
+
+    // ---- Transaction rollback journal --------------------------------------------
+
+    /**
+     * Caches a package whose build script produces the provided `bin:`
+     * payload from the build directory (or fails, or produces nothing,
+     * per {@link script}), optionally marking it installed.
+     */
+    Usm.CachedPackage rollback_fixture_package(string state_dir, string name, string version, string script, string[] build_refs, bool installed) throws Error {
+        var source = Path.build_filename(state_dir, @"src-$name-$version");
+        DirUtils.create_with_parents(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": "%s",
+          "version": "%s",
+          "summary": "rollback fixture",
+          "licences": [],
+          "flags": [],
+          "provides": { "bin:%s": "build:out" },
+          "depends": { "runtime": [], "build": [%s], "manage": [] },
+          "execs": { "build": "build.sh" }
+        }
+        """.printf(name, version, name, refs.str));
+        FileUtils.set_contents(Path.build_filename(source, "build.sh"), script);
+        FileUtils.chmod(Path.build_filename(source, "build.sh"), 0755);
+
+        var cache_path = Path.build_filename(state_dir, "packages", @"$name-$version");
+        DirUtils.create_with_parents(cache_path, 0755);
+        Usm.Util.archive(source, Path.build_filename(cache_path, "package.usmc"));
+        Usm.Util.delete_tree(source);
+
+        if(installed) {
+            File.new_build_filename(state_dir, "installed", @"$name-$version").make_symbolic_link(cache_path);
+        }
+        return new Usm.CachedPackage(cache_path);
+    }
+
+    const string ROLLBACK_FAILING_BUILD = """#!/bin/bash
+exit 1
+""";
+
+    /** Builds cleanly but produces nothing, so the install step fails. */
+    const string ROLLBACK_EMPTY_BUILD = """#!/bin/bash
+exit 0
+""";
+
+    string rollback_payload_build(string payload) {
+        return "#!/bin/bash\nprintf '%s' > \"$1/out\"\n".printf(payload);
+    }
+
+    void test_journal_entry_complete_rollback_roundtrip() throws Error {
+        var scratch = make_scratch();
+
+        var package_root = Path.build_filename(scratch, "cache", "journalled-1.0.0");
+        var upgrade_root = Path.build_filename(scratch, "cache", "journalled-2.0.0");
+        DirUtils.create_with_parents(package_root, 0700);
+        DirUtils.create_with_parents(upgrade_root, 0700);
+        var pkg = new Usm.CachedPackage(package_root);
+        var upgrade = new Usm.CachedPackage(upgrade_root);
+
+        var lib_path = Path.build_filename(scratch, "dest", "usr", "lib", "libjournalled.so");
+        DirUtils.create_with_parents(Path.get_dirname(lib_path), 0700);
+        FileUtils.set_contents(lib_path, "old contents");
+        var mark_path = Path.build_filename(scratch, "state", "installed", "journalled-1.0.0");
+        DirUtils.create_with_parents(Path.get_dirname(mark_path), 0700);
+        File.new_for_path(mark_path).make_symbolic_link(package_root);
+
+        var journal = new Usm.Journal(Path.build_filename(scratch, "backup"));
+        journal.begin();
+        check(File.new_for_path(Path.build_filename(scratch, "backup", journal.transaction_id, "journal.json")).query_exists(),
+            "begin creates the journal file");
+
+        // a completed removal: the file is backed up and the mark target recorded
+        journal.entry_remove(pkg, { lib_path }, mark_path, package_root);
+        journal.backup_file(lib_path, pkg.package_name);
+        File.new_for_path(lib_path).delete();
+        File.new_for_path(mark_path).delete();
+        journal.complete(pkg.package_name);
+
+        // a half-applied install on top: one fresh file, the old path
+        // overwritten, the new mark already linked — never completed
+        var fresh_path = Path.build_filename(scratch, "dest", "usr", "bin", "journalled");
+        DirUtils.create_with_parents(Path.get_dirname(fresh_path), 0700);
+        var upgrade_mark_path = Path.build_filename(scratch, "state", "installed", "journalled-2.0.0");
+        FileUtils.set_contents(lib_path, "new contents");
+        journal.entry_install(upgrade, { fresh_path }, { lib_path }, upgrade_mark_path, null);
+        journal.backup_file(lib_path, upgrade.package_name);
+        FileUtils.set_contents(fresh_path, "binary");
+        File.new_for_path(upgrade_mark_path).make_symbolic_link(upgrade_root);
+
+        journal.rollback();
+
+        check(!File.new_for_path(fresh_path).query_exists(), "rollback removes the failed install's created file");
+        string restored;
+        FileUtils.get_contents(lib_path, out restored);
+        check(restored == "old contents", "rollback restores overwritten files from the backup");
+        check(!File.new_for_path(upgrade_mark_path).query_exists(), "rollback removes a mark the failed install created");
+        check(File.new_for_path(mark_path).query_exists(), "rollback re-creates the removal's installed mark");
+
+        var description = journal.describe_rollback();
+        check(description.contains("uninstalled \"journalled-2.0.0\"") && description.contains("reinstalled \"journalled-1.0.0\""),
+            "the rollback transcript names every undone operation");
+
+        string recorded;
+        FileUtils.get_contents(Path.build_filename(scratch, "backup", journal.transaction_id, "journal.json"), out recorded);
+        check(recorded.contains("\"status\":\"completed\"") && recorded.contains("\"files_removed\":[\"" + lib_path + "\"]"),
+            "journal.json records the operation, its status and its files");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_journal_finish_removes_backup_directory() throws Error {
+        var scratch = make_scratch();
+        var backup_root = Path.build_filename(scratch, "backup");
+
+        var journal = new Usm.Journal(backup_root);
+        journal.begin();
+        var transaction_directory = Path.build_filename(backup_root, journal.transaction_id);
+        check(File.new_for_path(transaction_directory).query_exists(), "begin creates the transaction backup directory");
+
+        journal.finish();
+        check(!File.new_for_path(transaction_directory).query_exists(), "finish removes the transaction backup directory");
+        check(!File.new_for_path(backup_root).query_exists(), "finish removes the emptied backup root");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_transaction_rollback_on_build_failure() throws Error {
+        var original_dir = Environment.get_current_dir();
+        var scratch = make_scratch();
+        var state = make_state(scratch);
+        var state_dir = Path.build_filename(scratch, "state");
+        var paths = scratch_paths(scratch);
+
+        // rb-b build-depends on rb-a, so rb-a and rb-c install in lot 1
+        // and rb-b builds in lot 2 — after both lot-1 installs completed
+        rollback_fixture_package(state_dir, "rb-a", "1.0.0", rollback_payload_build("payload-a"), {}, false);
+        rollback_fixture_package(state_dir, "rb-b", "1.0.0", ROLLBACK_FAILING_BUILD, { "bin:rb-a" }, false);
+        rollback_fixture_package(state_dir, "rb-c", "1.0.0", rollback_payload_build("payload-c"), {}, false);
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        foreach(var name in new string[] { "rb-a", "rb-b", "rb-c" }) {
+            to_install.add(new Usm.CachedPackage(Path.build_filename(state_dir, "packages", @"$name-1.0.0")));
+        }
+
+        var transaction = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            state = state
+        };
+
+        var threw = false;
+        try {
+            transaction.run();
+        }
+        catch(Usm.TransactionError e) {
+            threw = true;
+        }
+        check(threw, "a lot-2 build failure fails the transaction");
+
+        check(!File.new_for_path(paths.get_suggested_path_for_resource(new Usm.ResourceRef("bin:rb-a"))).query_exists(),
+            "the rollback removes the first package's installed file");
+        check(!File.new_for_path(paths.get_suggested_path_for_resource(new Usm.ResourceRef("bin:rb-c"))).query_exists(),
+            "the rollback removes the third package's installed file");
+        check(!File.new_for_path(paths.get_suggested_path_for_resource(new Usm.ResourceRef("bin:rb-b"))).query_exists(),
+            "the failing package's file never appeared");
+        check(!File.new_for_path(Path.build_filename(state_dir, "installed", "rb-a-1.0.0")).query_exists(),
+            "the rollback removes the first package's installed mark");
+        check(!File.new_for_path(Path.build_filename(state_dir, "installed", "rb-c-1.0.0")).query_exists(),
+            "the rollback removes the third package's installed mark");
+
+        var found_journal = false;
+        var backup_root = Path.build_filename(scratch, "dest", "var", "usm", "backup");
+        foreach(var transaction_id in Iterate.directory(backup_root)) {
+            var journal_path = Path.build_filename(backup_root, transaction_id, "journal.json");
+            if(!File.new_for_path(journal_path).query_exists()) {
+                continue;
+            }
+            found_journal = true;
+            string recorded;
+            FileUtils.get_contents(journal_path, out recorded);
+            check(recorded.contains("\"package\":\"rb-a-1.0.0\"") && recorded.contains("\"package\":\"rb-c-1.0.0\""),
+                "the kept journal records both rolled-back installs");
+        }
+        check(found_journal, "a rolled-back transaction keeps its journal for inspection");
+
+        Environment.set_current_dir(original_dir);
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_transaction_rollback_restores_previous_version() throws Error {
+        var original_dir = Environment.get_current_dir();
+        var scratch = make_scratch();
+        var state = make_state(scratch);
+        var state_dir = Path.build_filename(scratch, "state");
+        var paths = scratch_paths(scratch);
+
+        rollback_fixture_package(state_dir, "rb-u", "1.0.0", rollback_payload_build("payload-old"), {}, false);
+        var first = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = Iterate.these<Usm.CachedPackage>(new Usm.CachedPackage(Path.build_filename(state_dir, "packages", "rb-u-1.0.0"))).to_hash_set(),
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            state = state
+        };
+        first.run();
+
+        var bin_path = paths.get_suggested_path_for_resource(new Usm.ResourceRef("bin:rb-u"));
+        check(File.new_for_path(bin_path).query_exists(), "the old version installs before the update");
+
+        rollback_fixture_package(state_dir, "rb-u", "2.0.0", ROLLBACK_EMPTY_BUILD, {}, false);
+        var second = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = Iterate.these<Usm.CachedPackage>(new Usm.CachedPackage(Path.build_filename(state_dir, "packages", "rb-u-2.0.0"))).to_hash_set(),
+            to_remove = Iterate.these<Usm.CachedPackage>(new Usm.CachedPackage(Path.build_filename(state_dir, "packages", "rb-u-1.0.0"))).to_hash_set(),
+            state = state
+        };
+
+        var threw = false;
+        try {
+            second.run();
+        }
+        catch(Usm.TransactionError e) {
+            threw = true;
+        }
+        check(threw, "the new version's install failure fails the update");
+
+        string restored;
+        FileUtils.get_contents(bin_path, out restored);
+        check(restored == "payload-old", "the old version's file is restored from the backup");
+        check(state.read_installed_target(new Usm.CachedPackage(Path.build_filename(state_dir, "packages", "rb-u-1.0.0")))
+            == Path.build_filename(state_dir, "packages", "rb-u-1.0.0"),
+            "the old version's installed mark is re-created pointing at its cache directory");
+        check(!File.new_for_path(Path.build_filename(state_dir, "installed", "rb-u-2.0.0")).query_exists(),
+            "the failed version's installed mark never lingers");
+
+        Environment.set_current_dir(original_dir);
+        Usm.Util.delete_tree(scratch);
+    }
+
+    // ---- Granular progress reporting ----------------------------------------------
+
+    /** Whether the reports climb monotonically from start to end. */
+    bool monotonic(float[] fractions) {
+        for(int i = 1; i < fractions.length; i++) {
+            if(fractions[i] < fractions[i - 1]) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    void test_unarchive_with_progress() throws Error {
+        var scratch = make_scratch();
+        var source = Path.build_filename(scratch, "src");
+        DirUtils.create(source, 0755);
+        for(int i = 0; i < 5; i++) {
+            FileUtils.set_contents(Path.build_filename(source, @"file$i.txt"), @"content $i");
+        }
+        var archive = Path.build_filename(scratch, "pkg.usmc");
+        Usm.Util.archive(source, archive);
+
+        float[] fractions = {};
+        var destination = Path.build_filename(scratch, "out");
+        Usm.Util.unarchive_with_progress(archive, destination, f => { fractions += f; });
+        check(fractions.length == 6, "one progress report fires per extracted archive member (including the root)");
+        check(fractions[0] > 0.0f, "the first member already reports progress");
+        check(fractions[fractions.length - 1] == 1.0f, "the last member reports full progress");
+        check(monotonic(fractions), "extraction progress never regresses");
+        check(File.new_for_path(Path.build_filename(destination, "file3.txt")).query_exists(), "the archive extracts fully");
+
+        var bare = Path.build_filename(scratch, "out-bare");
+        Usm.Util.unarchive_with_progress(archive, bare, null);
+        check(File.new_for_path(Path.build_filename(bare, "file0.txt")).query_exists(), "a null delegate extracts without progress reporting");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_resolver_resolution_progress() throws Error {
+        var scratch = make_scratch();
+        var resolver = new Usm.Resolver(new Usm.ResourceFinder());
+        resolver.supply_package(make_package_archive(scratch, "pa", {}));
+        resolver.supply_package(make_package_archive(scratch, "pb", { "bin:pkg-pa" }));
+
+        var roots = new Vector<Usm.AbstractPackage>();
+        roots.add(((!)resolver.find_package("pkg-pb")));
+
+        float[] fractions = {};
+        resolver.resolution_progress = f => { fractions += f; };
+        var result = resolver.resolve(roots, null);
+
+        check(result.packages.count() == 2, "resolution still closes the closure with reporting enabled");
+        check(fractions.length == 3, "one report fires per resolved package plus a completion clamp");
+        check(fractions[0] == 0.5f, "the first package reports against the roots-x-2 estimate");
+        check(fractions[fractions.length - 1] == 1.0f, "completion clamps to full progress");
+        check(monotonic(fractions), "resolution progress never regresses");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_install_script_progress_parsing() throws Error {
+        var scratch = make_scratch();
+        var original_dir = Environment.get_current_dir();
+        var source = Path.build_filename(scratch, "src");
+        DirUtils.create(source, 0755);
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"), """
+        {
+          "name": "meson-pkg",
+          "version": "1.0.0",
+          "summary": "install progress fixture",
+          "licences": [],
+          "flags": ["ninjaStyleProgress"],
+          "provides": { "bin:meson-pkg": "as-expected" },
+          "depends": { "runtime": [], "build": [], "manage": [] },
+          "execs": { "install": "install.sh" }
+        }
+        """);
+        FileUtils.set_contents(Path.build_filename(source, "install.sh"), """#!/bin/bash
+echo "Installing meson-pkg to /usr/bin"
+echo "noise that is not an install line"
+echo "Installing libmeson-pkg.so to /usr/lib"
+""");
+        FileUtils.chmod(Path.build_filename(source, "install.sh"), 0755);
+
+        Environment.set_current_dir(source);
+        var manifest = new Usm.Manifest.from_file("MANIFEST.usm");
+        float[] fractions = {};
+        var install_dir = Path.build_filename(scratch, "install");
+        DirUtils.create(install_dir, 0755);
+        var proc = manifest.run_install(Path.build_filename(scratch, "build"), install_dir, new Usm.Paths(),
+            Usm.InstallType.FRESH, SubprocessFlags.STDOUT_SILENCE, f => { fractions += f; });
+        check(proc != null, "a ninjaStyleProgress install script spawns through the parsing path");
+        ((!)proc).wait_check();
+
+        check(fractions.length == 2, "each Installing line reports exactly once and noise is skipped");
+        check(fractions[0] == 0.5f, "reports run against the provides-count-x-2 estimate");
+        check(fractions[1] == 1.0f, "the last Installing line reaches full progress");
+
+        Environment.set_current_dir(original_dir);
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_transaction_unpack_progress() throws Error {
+        var original_dir = Environment.get_current_dir();
+        var scratch = make_scratch();
+        var state = make_state(scratch);
+
+        var cache_path = Path.build_filename(scratch, "state", "packages", "unpack-pkg-1.0.0");
+        DirUtils.create_with_parents(cache_path, 0755);
+        var source = Path.build_filename(scratch, "src-unpack-pkg");
+        DirUtils.create(source, 0755);
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"), BUILD_MANIFEST.printf("unpack-pkg", "1.0.0"));
+        FileUtils.set_contents(Path.build_filename(source, "build.sh"), NOOP_BUILD_SCRIPT);
+        FileUtils.chmod(Path.build_filename(source, "build.sh"), 0755);
+        for(int i = 0; i < 4; i++) {
+            FileUtils.set_contents(Path.build_filename(source, @"payload$i.txt"), "data");
+        }
+        Usm.Util.archive(source, Path.build_filename(cache_path, "package.usmc"));
+        Usm.Util.delete_tree(source);
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        to_install.add(new Usm.CachedPackage(cache_path));
+
+        var transaction = new Usm.Transaction() {
+            paths = scratch_paths(scratch),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            state = state
+        };
+
+        float[] unpack_fractions = {};
+        transaction.progress_updated.connect((task_type, subject, current_task, total_tasks, task_progress) => {
+            if(task_type == Usm.TransactionTask.UNPACKING) {
+                unpack_fractions += task_progress;
+            }
+        });
+        transaction.run();
+
+        check(unpack_fractions.length >= 6, "unpacking reports the clean, every extracted member and completion");
+        check(unpack_fractions[0] == 0.0f, "the task opens at zero before the source clean");
+        check(unpack_fractions[1] == 0.5f, "the source clean lands at the half-way report");
+        check(unpack_fractions[unpack_fractions.length - 1] == 1.0f, "unpacking completes at full progress");
+        check(monotonic(unpack_fractions), "unpack progress never regresses");
+
+        Environment.set_current_dir(original_dir);
+        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_rebuild_dependants_flag();
+            test_data_package_validation();
+            test_data_package_from_file();
+            test_configuration_system_package_manager();
+            test_gio_module_ref();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL data package test threw: %s\n", e.message);
+        }
+
+        try {
+            test_repository_listing_entry_size();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL repository listing size 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_version_constraint_semantics();
+            test_resource_ref_operator_parsing();
+            test_depends_operator_type_rules();
+            test_provides_declaration_rules();
+            test_satisfied_by_constraints();
+            test_manifest_satisfies_pc_fallback();
+            test_resolve_diamond_usm_only();
+            test_resolve_spm_before_usm();
+            test_resolver_constrained_lib_dep();
+            test_resolver_skips_spm_for_constrained_refs();
+            test_pc_finder_version_constraint();
+            test_pc_file_read_version();
+            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();
+            test_transaction_preinstalled_resources_satisfy_build_deps();
+            test_transaction_deadlock_message_details_causes();
+            test_transaction_installs_system_packages();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL resolver test threw: %s\n", e.message);
+        }
+
+        try {
+            test_find_dependant_names();
+            test_rebuild_planning();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL rebuild planning test threw: %s\n", e.message);
+        }
+
+        try {
+            test_origin_information_explicit_install();
+            test_transaction_records_explicit_flags();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL explicit install tracking test threw: %s\n", e.message);
+        }
+
+        try {
+            test_build_archive_restore_and_clean_retry();
+            test_build_archive_restored_incrementally();
+            test_build_failure_without_cache_propagates();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL build cache retry test threw: %s\n", e.message);
+        }
+
+        try {
+            test_journal_entry_complete_rollback_roundtrip();
+            test_journal_finish_removes_backup_directory();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL journal test threw: %s\n", e.message);
+        }
+
+        try {
+            test_transaction_rollback_on_build_failure();
+            test_transaction_rollback_restores_previous_version();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL transaction rollback test threw: %s\n", e.message);
+        }
+
+        try {
+            test_unarchive_with_progress();
+            test_resolver_resolution_progress();
+            test_install_script_progress_parsing();
+            test_transaction_unpack_progress();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL progress reporting 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)

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff