Procházet zdrojové kódy

feat: managed mode lifecycle (update/remove/downgrade/rebuild/clean with shared plan+confirm), search/provides/add-repo, SPM auto-detection in genconfig+installer, deploy -y

clanker před 1 týdnem
rodič
revize
fc989b01e2

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 947 - 988
installer/install-usm.sh


+ 34 - 0
installer/main.sh

@@ -113,6 +113,35 @@ check_existing_installation() {
     fi
 }
 
+# Install the system package manager helper matching the detected PM from
+# the extracted payload to /usr/bin. Runs before config generation so the
+# `usm genconfig` call in install_shim detects the system package manager
+# and wires the generated usm.config to this helper.
+install_spm_shim() {
+    local extract_dir="$1"
+    local shim_name="$PM_TYPE"
+    local source_shim="$extract_dir/spm/$shim_name/usm-spm-$shim_name"
+    local shim_path="/usr/bin/usm-spm-$shim_name"
+    local sudo=""
+    
+    if ! is_root; then
+        sudo=$(get_sudo)
+    fi
+    
+    if [[ ! -f "$source_shim" ]]; then
+        log_error "SPM helper \"$source_shim\" not found in the installer payload"
+        return 1
+    fi
+    
+    log_step "Installing SPM helper to $shim_path..."
+    
+    if [[ -n "$sudo" ]]; then
+        $sudo cp "$source_shim" "$shim_path" && $sudo chmod 755 "$shim_path"
+    else
+        cp "$source_shim" "$shim_path" && chmod 755 "$shim_path"
+    fi
+}
+
 # Main installation function
 run_installation() {
     # Install system dependencies first (before extracting sources)
@@ -138,6 +167,11 @@ run_installation() {
         show_error_and_exit "Failed to build components. Installation halted."
     fi
     
+    # Install the SPM helper before the shim step so config generation sees it
+    if ! install_spm_shim "$extract_dir"; then
+        show_error_and_exit "Failed to install the SPM helper. Installation halted."
+    fi
+    
     # Install shim
     install_shim "$TARGET_DIR"
     

+ 231 - 0
src/cli/AddRepo.vala

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

+ 213 - 0
src/cli/Clean.vala

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

+ 25 - 1
src/cli/Cli.vala

@@ -74,6 +74,30 @@ public static int main(string[] args) {
         if(command == "install") {
             return install_main(dispatch_args);
         }
+        if(command == "update") {
+            return update_main(dispatch_args);
+        }
+        if(command == "remove") {
+            return remove_main(dispatch_args);
+        }
+        if(command == "downgrade") {
+            return downgrade_main(dispatch_args);
+        }
+        if(command == "rebuild") {
+            return rebuild_main(dispatch_args);
+        }
+        if(command == "clean") {
+            return clean_main(dispatch_args);
+        }
+        if(command == "search") {
+            return search_main(dispatch_args);
+        }
+        if(command == "provides") {
+            return provides_main(dispatch_args);
+        }
+        if(command == "add-repo") {
+            return add_repo_main(dispatch_args);
+        }
         if(command == "deploy") {
             return deploy_main(dispatch_args);
         }
@@ -97,7 +121,7 @@ public static int main(string[] args) {
 }
 
 private void usage() {
-    printerr("USAGE:\n\tusm manifest\n\tusm info\n\tusm repository\n\tusm install\n\tusm deploy\n\tusm scaffold\n\tusm genconfig\n\tusm enroll [-y|--yes] [--state-path <path>]\n");
+    printerr("USAGE:\n\tusm manifest\n\tusm info\n\tusm repository\n\tusm install [-y|--yes] <packages>\n\tusm update [-y|--yes] [<package>]\n\tusm remove [-y|--yes] <package>\n\tusm downgrade [-y|--yes] <package> [<version>]\n\tusm rebuild <package>\n\tusm clean [lists|builds|sources|all]\n\tusm clean [build|source] <package>\n\tusm search <query>\n\tusm provides <resource>\n\tusm add-repo <url-or-path>\n\tusm deploy\n\tusm scaffold\n\tusm genconfig\n\tusm enroll [-y|--yes] [--state-path <path>]\n");
 }
 
 

+ 40 - 0
src/cli/Confirm.vala

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

+ 1 - 1
src/cli/Deploy.vala

@@ -778,7 +778,7 @@ private string deploy_containerfile(string base_image, string installer_url, boo
     }
     builder.append("\n");
     builder.append_printf("COPY package/package.usmc /var/usm/packages/%s-%s/package.usmc\n", package_name, version_string);
-    builder.append_printf("RUN mkdir -p /var/usm/lists /var/usm/installed && usm install %s%s\n\n", package_name, verbose_deploy ? " --verbose" : "");
+    builder.append_printf("RUN mkdir -p /var/usm/lists /var/usm/installed && usm install -y %s%s\n\n", package_name, verbose_deploy ? " --verbose" : "");
     builder.append_printf("ENTRYPOINT %s\n", deploy_entrypoint_json(entrypoint));
     return builder.str;
 }

+ 199 - 0
src/cli/Downgrade.vala

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

+ 34 - 0
src/cli/GenConfig.vala

@@ -1,3 +1,4 @@
+using Invercargill.DataStructures;
 using Invercargill.Mapping;
 using InvercargillJson;
 
@@ -17,6 +18,23 @@ public int genconfig_main() {
     
     config.paths = paths;
 
+    // Wire the detected system package manager to its shim; the installer
+    // has already copied the shim to /usr/bin by the time it runs genconfig
+    var spm_shim_name = detect_spm();
+    if (spm_shim_name != null) {
+        var shim_path = Path.build_filename("/", "usr", "bin", @"usm-spm-$spm_shim_name");
+        var query = new Vector<string>();
+        query.add(shim_path);
+        query.add("query");
+        var install = new Vector<string>();
+        install.add(shim_path);
+        install.add("install");
+        config.system_package_manager = new Usm.SystemPackageManagerConfig() {
+            query = query,
+            install = install
+        };
+    }
+
     // Map to properties and create JSON
     var properties = Usm.Configuration.get_mapper().map_from(config);
     var json_element = new JsonElement.from_properties(properties);
@@ -106,3 +124,19 @@ private string detect_lib_dir_fallback() {
     // Default fallback
     return "lib";
 }
+
+/**
+ * Detects the system package manager by which supported executable is on
+ * PATH (dnf, apt-get, apk, emerge — in that order), returning the matching
+ * usm-spm-<name> shim name or null when no supported manager is installed
+ * (in which case the config omits the system_package_manager section).
+ */
+private string? detect_spm() {
+    foreach (var executable in new string[] { "dnf", "apt-get", "apk", "emerge" }) {
+        if (Environment.find_program_in_path(executable) != null) {
+            // apt-get systems use the "apt" shim; the others share their name
+            return executable == "apt-get" ? "apt" : executable;
+        }
+    }
+    return null;
+}

+ 47 - 12
src/cli/Install.vala

@@ -3,7 +3,22 @@ using Invercargill.DataStructures;
 
 private int install_main(string[] args) {
 
-    if(args.length < 3) {
+    var assume_yes = false;
+    var package_names = new Vector<string>();
+    for(int i = 2; i < args.length; i++) {
+        var argument = args[i];
+        if(argument == "-y" || argument == "--yes") {
+            assume_yes = true;
+        }
+        else if(argument.has_prefix("-")) {
+            printerr(@"Unknown option \"$argument\"\n");
+            return install_usage();
+        }
+        else {
+            package_names.add(argument);
+        }
+    }
+    if(package_names.length == 0) {
         return install_usage();
     }
 
@@ -35,10 +50,10 @@ private int install_main(string[] args) {
         resolver.load_cache(paths);
 
         var roots = new Vector<Usm.AbstractPackage>();
-        for(int i = 2; i < args.length; i++) {
-            var target = resolver.find_package(args[i]);
+        foreach(var package_name in package_names) {
+            var target = resolver.find_package(package_name);
             if(target == null) {
-                printerr(@"No package named \"$(args[i])\" found in any repository or the cache\n");
+                printerr(@"No package named \"$package_name\" found in any repository or the cache\n");
                 return 254;
             }
             roots.add(target);
@@ -80,13 +95,6 @@ private int install_main(string[] args) {
             printerr("\n");
         }
 
-        // Chosen system packages install first, as one transaction
-        if(resolution.system_packages.any()) {
-            if(!install_system_packages(spm, resolution.system_packages)) {
-                return 239;
-            }
-        }
-
         var install_order = new Vector<string>();
         foreach(var package in resolution.install_order) {
             install_order.add(package.manifest.name);
@@ -101,6 +109,32 @@ private int install_main(string[] args) {
             state = state
         };
 
+        // Planning before the prompt populates the rebuildDependants
+        // entries so the summary shows the whole transaction
+        try {
+            transaction.strategise();
+        }
+        catch(Usm.TransactionError e) {
+            printerr(@"$(e.message)\n");
+            return 240;
+        }
+
+        var summary = new Usm.TransactionSummary() {
+            to_install = cached_packages.to_vector(),
+            to_rebuild = transaction.rebuilds
+        };
+        if(!Usm.Cli.confirm_transaction(summary, assume_yes)) {
+            print("Aborted.\n");
+            return 1;
+        }
+
+        // Chosen system packages install first, as one transaction
+        if(resolution.system_packages.any()) {
+            if(!install_system_packages(spm, resolution.system_packages)) {
+                return 239;
+            }
+        }
+
         printerr("\nRunning transaction...\n");
         transaction.progress_updated.connect(transaction.print_progress);
         transaction.run();
@@ -121,6 +155,7 @@ private int install_main(string[] args) {
  * Installs the resolution's chosen system packages as one transaction,
  * surfacing progress through the manager's {@link Usm.InstallProgressDelegate}
  * events; returns false (after printing why) when the transaction failed.
+ * Shared by `usm install` and `usm update`.
  */
 private bool install_system_packages(Usm.SystemPackageManager spm, Vector<Usm.SystemPackageCandidate> packages) {
     printerr("Installing system packages...\n");
@@ -177,6 +212,6 @@ private bool install_system_packages(Usm.SystemPackageManager spm, Vector<Usm.Sy
 }
 
 private int install_usage() {
-    printerr("USAGE:\n\tusm install <packages>\n");
+    printerr("USAGE:\n\tusm install [-y|--yes] <packages>\n");
     return 255;
 }

+ 163 - 0
src/cli/Provides.vala

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

+ 52 - 0
src/cli/Rebuild.vala

@@ -0,0 +1,52 @@
+/**
+ * `usm rebuild <package>` — rebuild an installed package from its
+ * cached sources through {@link Usm.Transaction.rebuild_package}, the
+ * standard pipeline with build-cache restore and a clean retry on
+ * failure, then reinstall its resources. Non-destructive (same version,
+ * same state directory), so no confirmation is needed.
+ */
+private int rebuild_main(string[] args) {
+
+    if(args.length != 3 || args[2].has_prefix("-")) {
+        return rebuild_usage();
+    }
+    var package_name = args[2];
+
+    Usm.SystemState state = null;
+    try {
+        state = new Usm.SystemState(paths);
+    }
+    catch(Error e) {
+        printerr(@"This system is not managed by usm: $(e.message)\n");
+        return 245;
+    }
+
+    try {
+        var target = state.find_installed(package_name);
+        if(target == null) {
+            printerr(@"No installed package named \"$package_name\"\n");
+            return 254;
+        }
+
+        var transaction = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(paths),
+            state = state
+        };
+        transaction.progress_updated.connect(transaction.print_progress);
+        transaction.rebuild_package(target);
+
+        print(@"Rebuilt $(target.package_name) from cached sources\n");
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return 238;
+    }
+
+    return 0;
+}
+
+private int rebuild_usage() {
+    printerr("USAGE:\n\tusm rebuild <package>\n");
+    return 255;
+}

+ 134 - 0
src/cli/Remove.vala

@@ -0,0 +1,134 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+/**
+ * `usm remove [-y|--yes] <package>` — remove an installed package and,
+ * by cascade, every installed package that transitively depends on it
+ * (discovered through {@link Usm.SystemState.find_dependant_names}, so
+ * the summary shows the full closure before anything happens).
+ *
+ * After the transaction each removed package's state directory —
+ * sources, build cache, `installed/` entry — is deleted; OTHER versions'
+ * directories (the siblings updates keep for downgrade) remain.
+ */
+private int remove_main(string[] args) {
+
+    var assume_yes = false;
+    string? package_name = null;
+    for(int i = 2; i < args.length; i++) {
+        var argument = args[i];
+        if(argument == "-y" || argument == "--yes") {
+            assume_yes = true;
+        }
+        else if(argument.has_prefix("-")) {
+            printerr(@"Unknown option \"$argument\"\n");
+            return remove_usage();
+        }
+        else if(package_name != null) {
+            return remove_usage();
+        }
+        else {
+            package_name = argument;
+        }
+    }
+    if(package_name == null) {
+        return remove_usage();
+    }
+
+    Usm.SystemState state = null;
+    try {
+        state = new Usm.SystemState(paths);
+    }
+    catch(Error e) {
+        printerr(@"This system is not managed by usm: $(e.message)\n");
+        return 245;
+    }
+
+    try {
+        var target = state.find_installed(package_name);
+        if(target == null) {
+            printerr(@"No installed package named \"$package_name\"\n");
+            return 254;
+        }
+
+        var by_package_name = new Dictionary<string, Usm.CachedPackage>();
+        foreach(var installed in state.get_installed_packages()) {
+            by_package_name.set(installed.package_name, state.resolve_installed(installed));
+        }
+
+        var closure = new Vector<Usm.CachedPackage>();
+        remove_collect_cascade(state, by_package_name, new HashSet<string>(), closure, target);
+
+        var to_remove = new HashSet<Usm.CachedPackage>();
+        to_remove.union_with(closure);
+
+        var transaction = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(paths),
+            to_remove = to_remove,
+            state = state
+        };
+
+        // Planning before the prompt lets the removal order (dependents
+        // before providers) be computed and shown
+        try {
+            transaction.strategise();
+        }
+        catch(Usm.TransactionError e) {
+            printerr(@"$(e.message)\n");
+            return 240;
+        }
+
+        var summary = new Usm.TransactionSummary() {
+            to_remove = closure
+        };
+        if(!Usm.Cli.confirm_transaction(summary, assume_yes)) {
+            print("Aborted.\n");
+            return 1;
+        }
+
+        printerr("\nRunning transaction...\n");
+        transaction.progress_updated.connect(transaction.print_progress);
+        transaction.run();
+
+        // The state directories go only after a successful transaction;
+        // other versions' directories are never touched
+        foreach(var package in closure) {
+            Usm.Util.delete_tree(package.state_path);
+        }
+        print(@"Removed $(closure.length) package(s) ($(closure.to_string(p => p.package_name, ", ")))\n");
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return 238;
+    }
+
+    return 0;
+}
+
+/**
+ * Depth-first reverse-dependency closure: {@link package} plus every
+ * installed package that transitively depends on it, appended to
+ * {@link closure} target-first. {@link visited} keys on the versioned
+ * cache-directory name so shared dependants collapse to one removal.
+ */
+private void remove_collect_cascade(Usm.SystemState state, Dictionary<string, Usm.CachedPackage> by_package_name,
+        HashSet<string> visited, Vector<Usm.CachedPackage> closure, Usm.CachedPackage package) throws Error {
+    if(visited.contains(package.package_name)) {
+        return;
+    }
+    visited.add(package.package_name);
+    closure.add(package);
+
+    foreach(var dependant_name in state.find_dependant_names(package)) {
+        Usm.CachedPackage dependant;
+        if(by_package_name.try_get(dependant_name, out dependant)) {
+            remove_collect_cascade(state, by_package_name, visited, closure, dependant);
+        }
+    }
+}
+
+private int remove_usage() {
+    printerr("USAGE:\n\tusm remove [-y|--yes] <package>\n");
+    return 255;
+}

+ 206 - 0
src/cli/Search.vala

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

+ 226 - 0
src/cli/Update.vala

@@ -0,0 +1,226 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+/**
+ * `usm update [-y|--yes] [<package>]` — bring installed packages up to
+ * the newest version their repositories offer.
+ *
+ * Without a package name every installed package with a newer repository
+ * version becomes a resolution root — one resolution, one
+ * {@link Usm.TransactionSummary}, one confirm. With a package name only
+ * that package updates (an already-current package reports "up to date").
+ *
+ * Dependencies already installed at the resolved version are left alone;
+ * an installed package moving to a different version is removed
+ * (RemoveType.UPGRADE) and the new version installed in its place. The
+ * old version's state directory is kept, which is what makes `usm
+ * downgrade` possible.
+ */
+private int update_main(string[] args) {
+
+    var assume_yes = false;
+    string? package_name = null;
+    for(int i = 2; i < args.length; i++) {
+        var argument = args[i];
+        if(argument == "-y" || argument == "--yes") {
+            assume_yes = true;
+        }
+        else if(argument.has_prefix("-")) {
+            printerr(@"Unknown option \"$argument\"\n");
+            return update_usage();
+        }
+        else if(package_name != null) {
+            return update_usage();
+        }
+        else {
+            package_name = argument;
+        }
+    }
+
+    Usm.SystemState state = null;
+    try {
+        state = new Usm.SystemState(paths);
+    }
+    catch(Error e) {
+        printerr(@"This system is not managed by usm: $(e.message)\n");
+        return 245;
+    }
+
+    try {
+        printerr("Refreshing repositories...\n");
+        var resolver = new Usm.Resolver(new Usm.ResourceFinder());
+        foreach(var repo in state.get_repositories()) {
+            state.refresh_list(repo, (f, c, t) => printerr(@"Refreshing list for $(repo.name): downloading $f $c/$t bytes\r"));
+            var listing = state.get_latest_list(repo);
+            if(listing != null) {
+                resolver.load_listing(repo, listing);
+            }
+            printerr("\n");
+        }
+
+        // Cached packages can satisfy dependencies, including the old
+        // versions kept behind by earlier updates
+        resolver.load_cache(paths);
+
+        var installed_by_name = new Dictionary<string, Usm.CachedPackage>();
+        var installed_versions = new Dictionary<string, Usm.Version>();
+        foreach(var installed in state.get_installed_packages()) {
+            var manifest = installed.get_manifest();
+            Usm.Version seen;
+            if(installed_versions.try_get(manifest.name, out seen) && !manifest.version.greater_than(seen)) {
+                continue;
+            }
+            installed_by_name.set(manifest.name, state.resolve_installed(installed));
+            installed_versions.set(manifest.name, manifest.version);
+        }
+
+        var roots = new Vector<Usm.AbstractPackage>();
+        if(package_name != null) {
+            Usm.CachedPackage installed;
+            if(!installed_by_name.try_get(package_name, out installed)) {
+                printerr(@"No installed package named \"$package_name\"\n");
+                return 254;
+            }
+            var target = resolver.find_package(package_name);
+            if(target == null) {
+                printerr(@"No package named \"$package_name\" found in any repository or the cache\n");
+                return 254;
+            }
+            Usm.Version installed_version;
+            installed_versions.try_get(package_name, out installed_version);
+            if(!target.manifest.version.greater_than(installed_version)) {
+                print(@"\"$package_name\" is already up to date.\n");
+                return 0;
+            }
+            roots.add(target);
+        }
+        else {
+            foreach(var pair in installed_by_name) {
+                var target = resolver.find_package(pair.key);
+                if(target != null && target.manifest.version.greater_than(installed_versions[pair.key])) {
+                    roots.add(target);
+                }
+            }
+            if(roots.length == 0) {
+                print("All installed packages are up to date.\n");
+                return 0;
+            }
+        }
+
+        var spm = new Usm.SystemPackageManager(state.config);
+        Usm.ResolutionResult resolution;
+        try {
+            resolution = resolver.resolve(roots, spm);
+        }
+        catch(Usm.ResolverError e) {
+            printerr("%s\n".printf(e.message));
+            return 240;
+        }
+
+        // Download and collect every changed package; a dependency already
+        // installed at the resolved version stays as-is, so an update plan
+        // names only what actually moves
+        var cached_packages = new HashSet<Usm.CachedPackage>();
+        foreach(var package in resolution.install_order) {
+            Usm.Version installed_version;
+            var already_current = installed_versions.try_get(package.manifest.name, out installed_version)
+                && installed_version.compare(package.manifest.version) == 0;
+            if(already_current) {
+                continue;
+            }
+            if(package.repository == null) {
+                if(package.package_path != null) {
+                    cached_packages.add(new Usm.CachedPackage(Path.get_dirname((!)package.package_path)));
+                }
+                continue;
+            }
+            var client = package.repository.get_client(state.config);
+            var path = state.generate_cache_path(package.manifest);
+            var cache_dir = File.new_for_path(path);
+            if(!cache_dir.query_exists()) {
+                cache_dir.make_directory();
+            }
+            var package_path = Path.build_filename(path, "package.usmc");
+            client.download_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Downloading $f $c/$t bytes\r"));
+            client.verify_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Verifying $f $c/$t bytes\r"));
+            cached_packages.add(new Usm.CachedPackage(path));
+            printerr("\n");
+        }
+
+        // The installed versions being replaced by a different version
+        var to_remove = new HashSet<Usm.CachedPackage>();
+        foreach(var package in resolution.install_order) {
+            Usm.CachedPackage installed;
+            Usm.Version installed_version;
+            if(!installed_by_name.try_get(package.manifest.name, out installed)) {
+                continue;
+            }
+            if(installed_versions.try_get(package.manifest.name, out installed_version)
+                    && installed_version.compare(package.manifest.version) != 0) {
+                to_remove.add(installed);
+            }
+        }
+
+        var install_order = new Vector<string>();
+        foreach(var package in resolution.install_order) {
+            install_order.add(package.manifest.name);
+        }
+
+        // The exact reverse: dependents are removed before their providers
+        var remove_order = new Vector<string>();
+        foreach(var package in resolution.removal_order) {
+            remove_order.add(package.manifest.name);
+        }
+
+        var transaction = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(paths),
+            to_remove = to_remove,
+            to_install = cached_packages,
+            install_order = install_order,
+            remove_order = remove_order,
+            state = state
+        };
+
+        // Planning before the prompt populates the rebuildDependants
+        // entries so the summary shows the whole transaction
+        try {
+            transaction.strategise();
+        }
+        catch(Usm.TransactionError e) {
+            printerr(@"$(e.message)\n");
+            return 240;
+        }
+
+        var summary = new Usm.TransactionSummary() {
+            to_install = cached_packages.to_vector(),
+            to_remove = to_remove.to_vector(),
+            to_rebuild = transaction.rebuilds
+        };
+        if(!Usm.Cli.confirm_transaction(summary, assume_yes)) {
+            print("Aborted.\n");
+            return 1;
+        }
+
+        if(resolution.system_packages.any()) {
+            if(!install_system_packages(spm, resolution.system_packages)) {
+                return 239;
+            }
+        }
+
+        printerr("\nRunning transaction...\n");
+        transaction.progress_updated.connect(transaction.print_progress);
+        transaction.run();
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return 238;
+    }
+
+    return 0;
+}
+
+private int update_usage() {
+    printerr("USAGE:\n\tusm update [-y|--yes] [<package>]\n");
+    return 255;
+}

+ 9 - 0
src/cli/meson.build

@@ -4,6 +4,15 @@ sources += files('Manifest.vala')
 sources += files('ManifestBump.vala')
 sources += files('Repository.vala')
 sources += files('Install.vala')
+sources += files('Update.vala')
+sources += files('Remove.vala')
+sources += files('Downgrade.vala')
+sources += files('Rebuild.vala')
+sources += files('Clean.vala')
+sources += files('Confirm.vala')
+sources += files('Search.vala')
+sources += files('Provides.vala')
+sources += files('AddRepo.vala')
 sources += files('Deploy.vala')
 sources += files('Scaffold.vala')
 sources += files('GenConfig.vala')

+ 7 - 0
src/lib/State/CachedPackage.vala

@@ -74,6 +74,13 @@ namespace Usm {
         }
 
         public void archive_build() throws Error {
+            // An archive with no build directory is already the
+            // authoritative artifact: extracting it just to repack the
+            // same bytes is pure churn
+            if(!has_build_directory() && has_build_archive()) {
+                return;
+            }
+
             var build_dir = get_build_directory();
 
             // Delete existing archive, if it exists

+ 74 - 0
src/lib/State/State.vala

@@ -34,6 +34,80 @@ namespace Usm {
                 .select<CachedPackage>(d => new CachedPackage(Path.build_filename(state_path, "installed", d)));
         }
 
+        /**
+         * The installed {@link CachedPackage} whose manifest name matches
+         * {@link name}, resolved through its `installed/` symlink to the
+         * real cache directory; null when nothing of that name is
+         * installed. When several versions are marked installed the
+         * newest wins. Unreadable installed entries are skipped with a
+         * warning — one corrupt install should not hide the rest.
+         */
+        public CachedPackage? find_installed(string name) throws Error {
+            CachedPackage? match = null;
+            Version? match_version = null;
+            foreach(var installed in get_installed_packages()) {
+                Manifest manifest;
+                try {
+                    manifest = installed.get_manifest();
+                }
+                catch(Error e) {
+                    warning(@"[Usm] Skipping unreadable installed package \"$(installed.package_name)\": $(e.message)");
+                    continue;
+                }
+                if(manifest.name != name) {
+                    continue;
+                }
+                if(match == null || manifest.version.greater_than((!)match_version)) {
+                    match = resolve_installed(installed);
+                    match_version = manifest.version;
+                }
+            }
+            return match;
+        }
+
+        /**
+         * Resolves an installed entry through its `installed/` symlink to
+         * the cache {@link CachedPackage} it points at: commands acting
+         * on an installed package need the real state directory (for
+         * cleanup), and re-marking an installed package must not relink
+         * its symlink onto itself.
+         */
+        public CachedPackage resolve_installed(CachedPackage installed) throws Error {
+            var info = File.new_for_path(installed.state_path).query_info(FileAttribute.STANDARD_SYMLINK_TARGET, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+            var target = info.get_symlink_target();
+            return target != null ? new CachedPackage(target) : installed;
+        }
+
+        /**
+         * Every cached version directory of {@link name} under
+         * `<state>/packages/` — `<name>-<version>` with a parsable
+         * version and a `package.usmc` — including the installed one;
+         * the siblings kept behind by updates are what makes downgrade
+         * possible. Directories that are not `<name>-<version>` (another
+         * package sharing a name prefix, for instance) never parse and
+         * are skipped.
+         */
+        public Vector<CachedPackage> get_cached_versions(string name) throws Error {
+            var versions = new Vector<CachedPackage>();
+            foreach(var directory in Iterate.directory(Path.build_filename(state_path, "packages"))) {
+                if(!directory.has_prefix(@"$(name)-")) {
+                    continue;
+                }
+                try {
+                    new Version.from_string(directory.substring(name.length + 1));
+                }
+                catch(Error e) {
+                    continue;
+                }
+                var path = Path.build_filename(state_path, "packages", directory);
+                if(!File.new_for_path(Path.build_filename(path, "package.usmc")).query_exists()) {
+                    continue;
+                }
+                versions.add(new CachedPackage(path));
+            }
+            return versions;
+        }
+
         public Enumerable<Repository> get_repositories() throws Error {
             return Iterate.directory(Path.build_filename(config_path, "repos.d"))
                 .attempt_select<Repository>(d => new Repository.from_file(Path.build_filename(config_path, "repos.d", d)))

+ 128 - 33
src/lib/Transaction.vala

@@ -8,8 +8,8 @@ namespace Usm {
         public Paths paths { get; set; }
         public ResourceFinder resource_finder { get; set; }
         public SystemState state { get; set; }
-        public Set<CachedPackage> to_install { get; set; }
-        public Set<CachedPackage> to_remove { get; set; }
+        public Set<CachedPackage> to_install { get; set; default = new HashSet<CachedPackage>(); }
+        public Set<CachedPackage> to_remove { get; set; default = new HashSet<CachedPackage>(); }
 
         /**
          * Optional install order (package names) taken from a
@@ -25,6 +25,14 @@ namespace Usm {
          */
         public Vector<string>? remove_order { get; set; }
 
+        /**
+         * Version downgrades executed by this transaction: each entry
+         * removes {@link DowngradeEntry.current}'s installed resources —
+         * keeping its state directory for a later downgrade back — and
+         * installs {@link DowngradeEntry.target} in its place.
+         */
+        public Vector<DowngradeEntry> to_downgrade { get; set; default = new Vector<DowngradeEntry>(); }
+
         public signal void progress_updated(TransactionTask task_type, string subject, uint current_task, uint total_tasks, float task_progress);
 
         private uint task_count = 0;
@@ -32,6 +40,19 @@ namespace Usm {
         private string current_subject = "transaction";
         private TransactionTask current_task_type = TransactionTask.STRATEGISING;
 
+        /**
+         * Working sets computed by {@link strategise}: {@link to_install}
+         * plus downgrade targets, and {@link to_remove} plus downgrade
+         * currents — the whole transaction runs against these.
+         */
+        private Set<CachedPackage> planned_install = new HashSet<CachedPackage>();
+        private Set<CachedPackage> planned_removal = new HashSet<CachedPackage>();
+        /** Downgrade targets (installed with {@link InstallType.DOWNGRADE}) and currents (removed with {@link RemoveType.DOWNGRADE}). */
+        private Set<CachedPackage> downgrade_targets = new HashSet<CachedPackage>();
+        private Set<CachedPackage> downgrade_currents = new HashSet<CachedPackage>();
+        /** Removals whose package name this transaction reinstalls — update removals ({@link RemoveType.UPGRADE}). */
+        private Set<CachedPackage> upgrade_removals = new HashSet<CachedPackage>();
+
         /**
          * Lots computed by {@link strategise}: each lot builds, tests and
          * installs together, and lots run in dependency order. A package
@@ -60,7 +81,7 @@ namespace Usm {
             // 1. Verify the transaction is valid
             strategise();
 
-            var all_packages = to_remove.concat(to_install);
+            var all_packages = planned_removal.concat(planned_install);
             var rebuild_packages = rebuilds.select<CachedPackage>(r => r.package).to_vector();
 
             // 2. Unpack packages
@@ -73,10 +94,10 @@ namespace Usm {
             foreach (var lot in install_lots) {
                 // 3. Build packages
                 do_for(lot, build_package, TransactionTask.BUILDING);
-    
+
                 // 4. Test packages
                 do_for(lot, test_package, TransactionTask.TESTING);
-    
+
                 // 5. Install packages
                 do_for(lot, install_package, TransactionTask.INSTALLING);
             }
@@ -93,6 +114,30 @@ namespace Usm {
 
         }
 
+        /**
+         * Rebuilds one already-installed package through the standard
+         * pipeline — build-cache restore, clean-retry on failure,
+         * reinstall of its resources — tagged
+         * {@link TransactionTask.REBUILDING} throughout; the `usm
+         * rebuild` entry point. No confirmation is needed: nothing
+         * installed changes version.
+         */
+        public void rebuild_package(CachedPackage package) throws TransactionError {
+            var packages = new Vector<CachedPackage>();
+            packages.add(package);
+
+            task_count = 5;
+            current_task = 0;
+            current_subject = package.package_name;
+            current_task_type = TransactionTask.REBUILDING;
+
+            do_for(packages, unpack_package, TransactionTask.REBUILDING);
+            do_for(packages, build_package, TransactionTask.REBUILDING);
+            do_for(packages, test_package, TransactionTask.REBUILDING);
+            do_for(packages, install_package, TransactionTask.REBUILDING);
+            do_for(packages, cleanup_package, TransactionTask.CLEANING_UP);
+        }
+
         private string previous_key = "";
         public void print_progress(TransactionTask task_type, string subject, uint current_task, uint total_tasks, float task_progress) {
             var verb = task_type.get_verb();
@@ -114,8 +159,39 @@ namespace Usm {
         }
 
         public void strategise() throws TransactionError {
-            task_count = (to_install.count() * 5) + (to_remove.count() * 3) + 1;
-            uint strategise_worst_case_task_count = (to_remove.count() * to_remove.count()) + (to_install.count() * to_install.count());
+            planned_install = new HashSet<CachedPackage>();
+            planned_install.union_with(to_install);
+            planned_removal = new HashSet<CachedPackage>();
+            planned_removal.union_with(to_remove);
+            downgrade_targets = new HashSet<CachedPackage>();
+            downgrade_currents = new HashSet<CachedPackage>();
+            foreach(var entry in to_downgrade) {
+                planned_install.add(entry.target);
+                downgrade_targets.add(entry.target);
+                planned_removal.add(entry.current);
+                downgrade_currents.add(entry.current);
+            }
+
+            // A removal whose package name this transaction reinstalls is
+            // an update removal, not a final one
+            upgrade_removals = new HashSet<CachedPackage>();
+            try {
+                var install_names = new HashSet<string>();
+                foreach(var package in planned_install) {
+                    install_names.add(package.get_manifest().name);
+                }
+                foreach(var package in planned_removal) {
+                    if(install_names.contains(package.get_manifest().name)) {
+                        upgrade_removals.add(package);
+                    }
+                }
+            }
+            catch(Error e) {
+                throw new TransactionError.UNKNOWN_ERROR(@"Failed to read manifest while planning removals: $(e.message)");
+            }
+
+            task_count = (planned_install.count() * 5) + (planned_removal.count() * 3) + 1;
+            uint strategise_worst_case_task_count = (planned_removal.count() * planned_removal.count()) + (planned_install.count() * planned_install.count());
             uint strategise_current_task = 0;
 
             report_progress(TransactionTask.STRATEGISING, 0.0f);
@@ -124,10 +200,10 @@ namespace Usm {
             install_lots = new Vector<Vector<CachedPackage>>();
             var touched = new HashSet<CachedPackage>();
             var installed_by_earlier_lots = new HashSet<ResourceRef>();
-            var ordered_install = ordered_by_names(to_install, install_order);
+            var ordered_install = ordered_by_names(planned_install, install_order);
             var round = 0;
             while(true) {
-                strategise_current_task = round * to_install.count();
+                strategise_current_task = round * planned_install.count();
                 var lot = new Vector<CachedPackage>();
                 var installed_by_this_lot = new HashSet<ResourceRef>();
                 var remaining = ordered_install.exclude(touched);
@@ -174,19 +250,19 @@ namespace Usm {
                 install_lots.add(lot);
                 round++;
             }
-            var current_task_baseline = (to_install.count() * to_install.count());
+            var current_task_baseline = (planned_install.count() * planned_install.count());
             strategise_current_task = current_task_baseline;
             report_progress(TransactionTask.STRATEGISING, (float)strategise_current_task / (float)strategise_worst_case_task_count);
 
             // Removal strategy
             removal_order = new Vector<CachedPackage>();
             if(remove_order != null) {
-                removal_order = ordered_by_names(to_remove, remove_order);
+                removal_order = ordered_by_names(planned_removal, remove_order);
             }
             else {
                 Set<CachedPackageManifest> remaining_to_remove;
                 try {
-                    remaining_to_remove = to_remove
+                    remaining_to_remove = planned_removal
                         .attempt_select<CachedPackageManifest>(p => new CachedPackageManifest(p))
                         .to_set();
 
@@ -197,7 +273,7 @@ namespace Usm {
 
                 round = 0;
                 while(true) {
-                    strategise_current_task = current_task_baseline + (round * to_remove.count());
+                    strategise_current_task = current_task_baseline + (round * planned_removal.count());
                     if(remaining_to_remove.count() == 0) {
                         break;
                     }
@@ -259,14 +335,14 @@ namespace Usm {
                 }
 
                 var planned = new HashSet<string>();
-                foreach(var package in to_install) {
+                foreach(var package in planned_install) {
                     planned.add(package.get_manifest().name);
                 }
-                foreach(var package in to_remove) {
+                foreach(var package in planned_removal) {
                     planned.add(package.get_manifest().name);
                 }
 
-                foreach(var package in to_install) {
+                foreach(var package in planned_install) {
                     var manifest = package.get_manifest();
                     if(manifest.flags == null || !manifest.flags.has(ManifestFlag.REBUILD_DEPENDANTS)) {
                         continue;
@@ -357,21 +433,14 @@ namespace Usm {
         }
 
         public void unpack_package(CachedPackage package) throws Error {
-            var will_remove = to_remove.has(package);
-
-            // Get a clean copy of the sources
+            // A clean copy of the sources is all any phase needs — builds,
+            // tests, installs and remove scripts all run from the source
+            // directory — so removals must not require a build artifact
+            // to exist (see {@link cleanup_package})
             package.clean_source();
-            report_progress(current_task_type, will_remove ? 0.25f : 0.5f);
+            report_progress(current_task_type, 0.5f);
             package.get_source_directory();
-            report_progress(current_task_type, will_remove ? 0.5f : 1.0f);
-
-            if(will_remove) {
-                // Get a clean copy of the build artifact
-                package.clean_build_directory();
-                report_progress(current_task_type, 0.75f);
-                package.get_build_directory();
-                report_progress(current_task_type, 1.0f);
-            }
+            report_progress(current_task_type, 1.0f);
         }
 
         /**
@@ -453,8 +522,15 @@ namespace Usm {
             Environment.set_current_dir(source_dir);
             var manifest = new Usm.Manifest.from_file("MANIFEST.usm");
 
+            // A downgrade removal keeps the state directory for the way
+            // back; an update removal is followed by a reinstall; anything
+            // else is final
+            var removal_type = downgrade_currents.has(package)
+                ? RemoveType.DOWNGRADE
+                : upgrade_removals.has(package) ? RemoveType.UPGRADE : RemoveType.FINAL;
+
             // Run remove process if present
-            var build_proc = manifest.run_remove(RemoveType.FINAL, SubprocessFlags.STDOUT_SILENCE);
+            var build_proc = manifest.run_remove(removal_type, SubprocessFlags.STDOUT_SILENCE);
             if(build_proc != null)
                 build_proc.wait_check();
 
@@ -473,10 +549,14 @@ namespace Usm {
             report_progress(current_task_type, 0.0f);
 
             string? install_dir = null;
+            // Downgrade targets install over their current version rather
+            // than fresh
+            var install_type = downgrade_targets.has(package) ? InstallType.DOWNGRADE : InstallType.FRESH;
+
             // Run install process if present
             if(manifest.executables.install != null) {
                 install_dir = package.create_install_directory();
-                var install_proc = manifest.run_install(build_dir, install_dir, paths, InstallType.FRESH, SubprocessFlags.STDOUT_SILENCE);
+                var install_proc = manifest.run_install(build_dir, install_dir, paths, install_type, SubprocessFlags.STDOUT_SILENCE);
                 install_proc.wait_check();
             }
 
@@ -485,7 +565,7 @@ namespace Usm {
 
             // Run post install process if present
             report_progress(current_task_type, 1.0f);
-            var post_install_proc = manifest.run_post_install(build_dir, InstallType.FRESH, SubprocessFlags.STDOUT_SILENCE);
+            var post_install_proc = manifest.run_post_install(build_dir, install_type, SubprocessFlags.STDOUT_SILENCE);
             if(post_install_proc != null)
             post_install_proc.wait_check();
 
@@ -494,7 +574,11 @@ namespace Usm {
         }
 
         private void cleanup_package(CachedPackage package) throws Error {
-            package.archive_build();
+            // A package with no build artifact (for example after `usm
+            // clean builds`) has nothing to archive; its cache stays as-is
+            if(package.has_build_directory() || package.has_build_archive()) {
+                package.archive_build();
+            }
             report_progress(TransactionTask.CLEANING_UP, 0.33334f);
             package.clean_source();
             report_progress(TransactionTask.CLEANING_UP, 0.66667f);
@@ -515,6 +599,17 @@ namespace Usm {
         public CachedPackage trigger { get; set; }
     }
 
+    /**
+     * One planned version downgrade: {@link current} is the installed
+     * {@link CachedPackage} whose resources are removed — its state
+     * directory is kept, enabling the downgrade back — and {@link target}
+     * is the older version installed in its place.
+     */
+    public class DowngradeEntry {
+        public CachedPackage target { get; set; }
+        public CachedPackage current { get; set; }
+    }
+
     public enum TransactionTask {
         STRATEGISING,
         UNPACKING,

+ 98 - 0
src/lib/TransactionSummary.vala

@@ -5,6 +5,104 @@ using InvercargillJson;
 
 namespace Usm {
 
+    /**
+     * The plan of a transaction before it runs: what will be installed,
+     * removed, rebuilt and downgraded. The lifecycle commands (`install`,
+     * `update`, `remove`, `downgrade`) build one after resolution and
+     * render it through {@link describe} behind the shared plan + confirm
+     * prompt.
+     */
+    public class TransactionSummary : Object {
+
+        /** Packages the transaction installs; a {@link to_remove} entry of the same name renders as an update. */
+        public Vector<CachedPackage> to_install { get; set; default = new Vector<CachedPackage>(); }
+        /** Installed packages the transaction removes; entries whose name is reinstalled render as updates. */
+        public Vector<CachedPackage> to_remove { get; set; default = new Vector<CachedPackage>(); }
+        /** Planned rebuildDependants rebuilds, taken from {@link Transaction.rebuilds} after {@link Transaction.strategise}. */
+        public Vector<RebuildEntry> to_rebuild { get; set; default = new Vector<RebuildEntry>(); }
+        /** Version downgrades; each entry replaces {@link DowngradeEntry.current} with {@link DowngradeEntry.target}. */
+        public Vector<DowngradeEntry> to_downgrade { get; set; default = new Vector<DowngradeEntry>(); }
+
+        /** Whether the summary describes no action at all. */
+        public bool is_empty() {
+            return to_install.length == 0 && to_remove.length == 0
+                && to_rebuild.length == 0 && to_downgrade.length == 0;
+        }
+
+        /**
+         * Human-readable multi-line rendering of the plan (without a
+         * trailing newline): one line per package with its version
+         * transition, categories ordered install, update, remove,
+         * downgrade, rebuild. Unreadable manifests degrade to the cache
+         * directory name rather than failing the display.
+         */
+        public string describe() {
+            var builder = new StringBuilder();
+            builder.append("Transaction plan:\n");
+
+            var removed_versions = new Dictionary<string, string>();
+            foreach(var package in to_remove) {
+                removed_versions.set(summary_name(package), summary_version(package));
+            }
+
+            var updated_names = new HashSet<string>();
+            foreach(var package in to_install.sort((a, b) => summary_name(a).collate(summary_name(b)))) {
+                var name = summary_name(package);
+                string removed_version;
+                if(removed_versions.try_get(name, out removed_version)) {
+                    updated_names.add(name);
+                    builder.append_printf("  %-9s  %s %s → %s\n", "update", name, removed_version, summary_version(package));
+                }
+                else {
+                    builder.append_printf("  %-9s  %s %s\n", "install", name, summary_version(package));
+                }
+            }
+
+            foreach(var package in to_remove.sort((a, b) => summary_name(a).collate(summary_name(b)))) {
+                if(updated_names.contains(summary_name(package))) {
+                    continue;
+                }
+                builder.append_printf("  %-9s  %s %s\n", "remove", summary_name(package), summary_version(package));
+            }
+
+            foreach(var entry in to_downgrade.sort((a, b) => summary_name(a.target).collate(summary_name(b.target)))) {
+                builder.append_printf("  %-9s  %s %s → %s\n", "downgrade",
+                    summary_name(entry.target), summary_version(entry.current), summary_version(entry.target));
+            }
+
+            foreach(var entry in to_rebuild.sort((a, b) => summary_name(a.package).collate(summary_name(b.package)))) {
+                builder.append_printf("  %-9s  %s %s (triggered by %s %s)\n", "rebuild",
+                    summary_name(entry.package), summary_version(entry.package),
+                    summary_name(entry.trigger), summary_version(entry.trigger));
+            }
+
+            if(builder.str.has_suffix("\n")) {
+                builder.truncate(builder.len - 1);
+            }
+            return builder.str;
+        }
+
+        /** The manifest name of {@link package}, falling back to its cache directory name when unreadable. */
+        private static string summary_name(CachedPackage package) {
+            try {
+                return package.get_manifest().name;
+            }
+            catch(Error e) {
+                return package.package_name;
+            }
+        }
+
+        /** The manifest version of {@link package}, falling back to its cache directory name when unreadable. */
+        private static string summary_version(CachedPackage package) {
+            try {
+                return package.get_manifest().version.to_string();
+            }
+            catch(Error e) {
+                return package.package_name;
+            }
+        }
+    }
+
     public class TransactionRecord {
 
         public Vector<PackageState> state_changes { get; set; }

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů