Browse Source

feat: apt/apk/emerge SPM helpers, deploy --spm wiring and cross-distro fixes

Four system-package-manager helpers now ship and install to the bindir:
usm-spm-apt (python3-apt with apt-file contents index), usm-spm-apk
(pure busybox shell over apk's native pc:/so:/cmd: provides) and
usm-spm-emerge (portage vdb plus a curated atom exception table), beside
the existing dnf helper. deploy gains --spm dnf|apt|apk|emerge|none: the
generated usm.config wires the matching helper and sets the install lib
directory per target (lib for the musl/Alpine and Debian multiarch
layouts, lib64 otherwise), and the Containerfile gains each SPM's
bootstrap RUN (curl joined the apt/apk sets for base images that ship no
fetcher for the installer).

Cross-distro resolution fixes surfaced by the debian/alpine/gentoo
deploy e2es: the apt helper answers sonames whose Debian package lags
the manifest's (SONAME_PACKAGES) and remaps split toolchain owners to
their meta-packages (PACKAGE_REMAPS: valac-bin to valac,
gobject-introspection-bin to gobject-introspection); the apk helper
completes gcc's install with musl-dev (INSTALL_COMPANIONS) since apk's
gcc ships no crt objects; the emerge helper links unversioned tool
names Gentoo ships versioned (UNVERSIONED_TOOL_LINKS, e.g. valac).
ResourceFinder enumerates Debian multiarch pkgconfig directories and
falls back to conventional lib directories where ldconfig -p does not
exist. SystemPackageManager reads helper IO synchronously with a stderr
drain thread: GLib's two-pipe communicate and waiting async pipe reads
deadlock under the gcompat loader the apk target runs on.

The installer bundles spm/ alongside its sources, builds pm_apk and
pm_emerge into the compiled script, and detects all four package
managers.
clanker 1 tuần trước cách đây
mục cha
commit
61dd92b41b

+ 13 - 2
README.md

@@ -307,7 +307,16 @@ For detailed information, see `slopdocs/utility.usm.manifest.validate.md`.
 
 ## System package manager integration
 
-USM can delegate missing resources to the system package manager (DNF and friends) before falling back to USM repositories. The integration is configured in `usm.config` and talks to a helper executable through two stable contracts; a reference DNF helper ships at `spm/dnf/usm-spm-dnf` (provided as `libexec:usm-spm-dnf`).
+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; four 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) |
+
+All four implement the identical JSON/JSONL contract below; `usm deploy --spm` (see [Container deployment](#container-deployment)) wires the matching one into generated images.
 
 ### Configuration
 
@@ -355,6 +364,7 @@ The query subcommand never modifies system state (at most it refreshes package-m
 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
@@ -365,11 +375,12 @@ usm deploy example-app-1.0.0.usmc                    # package form
 |---|---|
 | `--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\|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 and Debian-multiarch 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; 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`, SPM wired to the DNF helper the installer ships at `/opt/usm/bin/usm-spm-dnf`), `repos/` with repository descriptors, `repo-trees/` for `file://` repositories, and `package/package.usmc`. The image pre-seeds the package into the USM cache and runs `usm install` in-container: the SPM (DNF) provides platform and toolchain resources, USM repositories provide the rest, and failures fail the build loudly. Only repository PUBLIC keys ever enter a context or image; application secrets such as `web-config.json` are never packaged and belong at runtime, e.g. `podman run --rm -v ./web-config.json:/etc/my-app/web-config.json:ro <image>`.
+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:
 

+ 5 - 5
installer/compile.sh

@@ -57,11 +57,11 @@ cp -r "$USM_SOURCE_DIR"/* "$SOURCES_DIR/usm/" 2>/dev/null || true
 cp "$USM_SOURCE_DIR/../MANIFEST.usm" "$SOURCES_DIR/usm/" 2>/dev/null || true
 cp "$USM_SOURCE_DIR/../usm.config" "$SOURCES_DIR/usm/" 2>/dev/null || true
 
-# The DNF SPM helper lives in spm/ at the project root, outside src/, but
-# src/meson.build configures it via '../spm/dnf/usm-spm-dnf' — the payload
-# must therefore carry spm/ as a SIBLING of usm/ so the relative input
-# resolves inside the extracted tree
-echo "Copying USM SPM helper..."
+# 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"

+ 1 - 1
installer/main.sh

@@ -157,7 +157,7 @@ main() {
     
     # Detect package manager
     if ! detect_package_manager; then
-        show_error_and_exit "Could not detect a supported package manager (apt or dnf)"
+        show_error_and_exit "Could not detect a supported package manager (apk, apt, dnf or emerge)"
     fi
     
     log_info "Detected package manager: ${PM_TYPE}"

+ 2 - 0
installer/meson.build

@@ -24,8 +24,10 @@ installer_sources = files(
     'utils.sh',
     'ui.sh',
     'pm_base.sh',
+    'pm_apk.sh',
     'pm_apt.sh',
     'pm_dnf.sh',
+    'pm_emerge.sh',
     'build_config.sh',
     'main.sh'
 )

+ 2 - 0
installer/pm_base.sh

@@ -32,6 +32,7 @@ pm_get_missing_deps() {
         apk) pm_apk_get_missing_deps "$@" ;;
         apt) pm_apt_get_missing_deps "$@" ;;
         dnf) pm_dnf_get_missing_deps "$@" ;;
+        emerge) pm_emerge_get_missing_deps "$@" ;;
         *)
             log_error "No package manager detected"
             echo ""
@@ -45,6 +46,7 @@ pm_install_missing_deps() {
         apk) pm_apk_install_missing_deps "$@" ;;
         apt) pm_apt_install_missing_deps "$@" ;;
         dnf) pm_dnf_install_missing_deps "$@" ;;
+        emerge) pm_emerge_install_missing_deps "$@" ;;
         *)
             log_error "No package manager detected"
             return 1

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

+ 464 - 0
spm/apk/usm-spm-apk

@@ -0,0 +1,464 @@
+#!/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:, 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 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"
+            ;;
+        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")"
+}
+
+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 "$@"
+            ;;
+        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 "$@"

+ 655 - 0
spm/apt/usm-spm-apt

@@ -0,0 +1,655 @@
+#!/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)
+    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 == "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_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)
+
+    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())

+ 669 - 0
spm/emerge/usm-spm-emerge

@@ -0,0 +1,669 @@
+#!/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 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",
+    ],
+    "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",
+    "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_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)
+
+    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())

+ 125 - 18
src/cli/Deploy.vala

@@ -30,6 +30,75 @@ const string DEPLOY_USM_PREFIX = "/opt/usm";
 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,
+    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 "none":
+                parsed = DeploySpm.NONE;
+                return true;
+            default:
+                printerr(@"\"$value\" is not a valid --spm value (expected dnf, apt, apk, emerge 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";
+            default:
+                return "none";
+        }
+    }
+
+    /**
+     * The library directory the generated config installs `lib:`
+     * resources under: apk targets musl/Alpine and apt targets Debian
+     * multiarch, neither of which searches /usr/lib64 (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 ? "lib" : "lib64";
+    }
+}
+
+
 /**
  * `usm deploy <package.usmc|directory> [flags…]` — the package-then-deploy
  * convenience wrapper.
@@ -157,6 +226,11 @@ public int deploy_main(string[] args) {
  *   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|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
@@ -171,6 +245,7 @@ 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
@@ -206,6 +281,15 @@ public int manifest_deploy(string[] args) {
                 }
                 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) {
@@ -314,7 +398,7 @@ public int manifest_deploy(string[] args) {
     }
 
     try {
-        deploy_write_container_config(context_dir);
+        deploy_write_container_config(context_dir, deploy_spm);
 
         var containerfile = deploy_containerfile(
             base_image ?? DEPLOY_DEFAULT_BASE_IMAGE,
@@ -324,7 +408,8 @@ public int manifest_deploy(string[] args) {
             version_string,
             entrypoint,
             file_trees_provisioned,
-            verbose_deploy);
+            verbose_deploy,
+            deploy_spm);
         FileUtils.set_data(Path.build_filename(context_dir, "Containerfile"), containerfile.data);
     }
     catch(Error e) {
@@ -368,14 +453,14 @@ public int manifest_deploy(string[] args) {
 
 
 private int deploy_usage() {
-    printerr("USAGE:\n\tusm deploy <package.usmc|directory> [--exec CMD] [--base IMAGE] [--repository FILE]... [--no-build] [--installer-url URL] [--verbose]\n");
+    printerr("USAGE:\n\tusm deploy <package.usmc|directory> [--exec CMD] [--base IMAGE] [--spm dnf|apt|apk|emerge|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 == "--repository" || option == "--installer-url";
+    return option == "--exec" || option == "--base" || option == "--spm" || option == "--repository" || option == "--installer-url";
 }
 
 
@@ -576,26 +661,31 @@ private void deploy_copy_repository_tree(string source, string destination) thro
 
 
 /**
- * Writes the minimal in-image `usm.config`: managed state under /var/usm and
- * the system package manager wired to the DNF helper the installer ships, so
- * in-container resolution asks DNF before USM repositories. Hand-authored on
+ * 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) throws Error {
-    var helper = @"$(DEPLOY_USM_PREFIX)/bin/usm-spm-dnf";
+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\": \"lib64\"
-    },
-    \"system_package_manager\": {
-        \"query\": [\"$helper\", \"query\"],
-        \"install\": [\"$helper\", \"install\"]
-    }
+        \"lib\": \"$(spm.lib_path())\"
+    }$spm_section
 }
 ";
     FileUtils.set_data(Path.build_filename(context_dir, "usm.config"), config.data);
@@ -607,10 +697,12 @@ private void deploy_write_container_config(string context_dir) throws Error {
  * 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) {
+        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);
@@ -620,8 +712,23 @@ private string deploy_containerfile(string base_image, string installer_url, boo
     if(bundled_installer) {
         builder.append("COPY installer-local/install-usm.sh /usm-installer-local/install-usm.sh\n\n");
     }
-    builder.append("# DNF4 python bindings for the usm SPM helper (python3 is absent from the base image)\n");
-    builder.append("RUN dnf install -y python3-dnf && dnf clean all\n\n");
+    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.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");

+ 28 - 0
src/lib/ResourceFinder.vala

@@ -71,6 +71,22 @@ namespace Usm {
         }
 
         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 file = File.new_build_filename(directory, resource.resource);
+                if(file.query_exists()) {
+                    return file.get_path();
+                }
+            }
+            return null;
+        }
+
+        private string? locate_lib_ldconfig(ResourceRef resource) {
             try {
                 var proc = new Subprocess.newv(new string[] { "ldconfig", "-p" }, SubprocessFlags.STDOUT_PIPE);
                 var pipe = new DataInputStream(proc.get_stdout_pipe());
@@ -225,6 +241,18 @@ namespace Usm {
 
         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(":")));

+ 33 - 9
src/lib/SystemPackageManager.vala

@@ -164,6 +164,12 @@ namespace Usm {
          * 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) {
@@ -174,14 +180,29 @@ namespace Usm {
                 refs.add(resource.to_string());
             }
             var process = new Subprocess.newv(build_argv(query_argv, refs), SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
-            string stdout_buffer = null;
-            string stderr_buffer = null;
-            process.communicate_utf8(null, null, out stdout_buffer, out stderr_buffer);
+
+            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 ?? "");
+            var element = new JsonElement.from_string(stdout_buffer.str);
             return SystemQueryResult.get_mapper().materialise(element.as<Invercargill.Properties>());
         }
 
@@ -193,6 +214,12 @@ namespace Usm {
          * 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.
          */
         public async bool install(Vector<string> native_names, InstallProgressDelegate? progress = null) throws Error {
             if(!enabled) {
@@ -201,9 +228,6 @@ namespace Usm {
             }
             var process = new Subprocess.newv(build_argv(install_argv, native_names), SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
 
-            // Stderr is drained on a reader thread so a chatty helper can never
-            // block on it while the event loop below waits on stdout (mirrors
-            // Manifest.run_build's progress thread).
             Thread<string>? stderr_reader = null;
             try {
                 stderr_reader = new Thread<string>.try(null, () => drain_pipe_sync(process.get_stderr_pipe()));
@@ -215,7 +239,7 @@ namespace Usm {
             bool error_seen = false;
             var events = new DataInputStream(process.get_stdout_pipe());
             while(true) {
-                var line = yield events.read_line_async(Priority.DEFAULT);
+                var line = events.read_line();
                 if(line == null) {
                     break;
                 }
@@ -232,7 +256,7 @@ namespace Usm {
                 }
             }
 
-            yield process.wait_async(null);
+            process.wait();
             var drained_stderr = stderr_reader != null ? stderr_reader.join() : "";
             if(process.get_exit_status() != 0 && !error_seen) {
                 var detail = drained_stderr.chomp();

+ 10 - 7
src/meson.build

@@ -1,14 +1,17 @@
 project('Universal Source Manifest', 'vala', 'c', version: '0.1')
 vapi_dir = meson.current_source_dir() / 'vapi'
 
-# The DNF SPM helper lives outside the source root; copying it in keeps the
+# The SPM helpers live outside the source root; copying them in keeps the
 # install declarative and preserves the executable bit via install_mode
-spm_dnf_helper = configure_file(
-    input: '../spm/dnf/usm-spm-dnf',
-    output: 'usm-spm-dnf',
-    copy: true,
-)
-install_data(spm_dnf_helper,
+spm_shims = []
+foreach shim : ['dnf', 'apt', 'apk', 'emerge']
+    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',
 )