diff --git a/docs/.vitepress/cli_commands.ts b/docs/.vitepress/cli_commands.ts index 3974e359e1..b63088a13c 100644 --- a/docs/.vitepress/cli_commands.ts +++ b/docs/.vitepress/cli_commands.ts @@ -322,6 +322,12 @@ export const commands: { [key: string]: Command } = { status: { hide: false, }, + upgrade: { + hide: false, + }, + use: { + hide: false, + }, }, }, tasks: { diff --git a/docs/cli/index.md b/docs/cli/index.md index 81ef5e371b..0409149646 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -167,6 +167,8 @@ Can also use `MISE_NO_HOOKS=1` - [`mise system `](/cli/system.md) - [`mise system install [FLAGS] [PACKAGE]…`](/cli/system/install.md) - [`mise system status [-J --json] [--missing]`](/cli/system/status.md) +- [`mise system upgrade [FLAGS] [PACKAGE]…`](/cli/system/upgrade.md) +- [`mise system use [FLAGS] …`](/cli/system/use.md) - [`mise tasks [FLAGS] [TASK] `](/cli/tasks.md) - [`mise tasks add [FLAGS] [-- RUN]…`](/cli/tasks/add.md) - [`mise tasks deps [--dot] [--hidden] [TASKS]…`](/cli/tasks/deps.md) diff --git a/docs/cli/system.md b/docs/cli/system.md index d2e7d9eb06..582c43faf6 100644 --- a/docs/cli/system.md +++ b/docs/cli/system.md @@ -15,3 +15,5 @@ ever installed when explicitly requested with `mise system install`. - [`mise system install [FLAGS] [PACKAGE]…`](/cli/system/install.md) - [`mise system status [-J --json] [--missing]`](/cli/system/status.md) +- [`mise system upgrade [FLAGS] [PACKAGE]…`](/cli/system/upgrade.md) +- [`mise system use [FLAGS] …`](/cli/system/use.md) diff --git a/docs/cli/system/upgrade.md b/docs/cli/system/upgrade.md new file mode 100644 index 0000000000..3e9dffbe61 --- /dev/null +++ b/docs/cli/system/upgrade.md @@ -0,0 +1,52 @@ + +# `mise system upgrade` + +- **Usage**: `mise system upgrade [FLAGS] [PACKAGE]…` +- **Aliases**: `up` +- **Source code**: [`src/cli/system/upgrade.rs`](https://github.com/jdx/mise/blob/main/src/cli/system/upgrade.rs) + +Upgrade installed system packages from `[system.packages]` + +Refreshes package manager metadata and upgrades the configured packages +that are already installed: apt/dnf/pacman upgrade to the newest available +version (apt and dnf honor a version pinned in config), brew pours the +formula's current bottle and replaces the old keg. Packages that are not +installed yet are skipped — use `mise system install` for those. + +Packages can also be given explicitly in `manager:package` form. + +## Arguments + +### `[PACKAGE]…` + +Packages in `manager:package` form; defaults to everything configured in [system.packages] + +## Flags + +### `-m --manager ` + +Only upgrade packages for this manager, e.g. `apt` or `brew` + +**Choices:** + +- `apt` +- `brew` +- `dnf` +- `pacman` + +### `-n --dry-run` + +Print the commands that would run without running them + +### `-y --yes` + +Skip the confirmation prompt + +Examples: + +``` +mise system upgrade +mise system upgrade brew:postgresql@17 +mise system upgrade --manager apt --yes +mise system upgrade --dry-run +``` diff --git a/docs/cli/system/use.md b/docs/cli/system/use.md new file mode 100644 index 0000000000..e9f511b336 --- /dev/null +++ b/docs/cli/system/use.md @@ -0,0 +1,53 @@ + +# `mise system use` + +- **Usage**: `mise system use [FLAGS] …` +- **Aliases**: `u` +- **Source code**: [`src/cli/system/use.rs`](https://github.com/jdx/mise/blob/main/src/cli/system/use.rs) + +Add system packages to [system.packages] and install them + +Like `mise use` for tools: writes `"manager:package" = "version"` entries +to mise.toml (the local config by default, the global one with `-g`) and +then installs whatever is missing. + +Versions are pinned with `@`: `mise system use apt:curl@8.5.0-2`. Without +`@` (or with `@latest`) no pin is written. brew formulae version through +their names instead (`brew:postgresql@17`), so `@` is always part of the +formula name there. + +## Arguments + +### `…` + +Packages in `manager:package[@version]` form + +## Flags + +### `-e --env ` + +Write to the config file for this environment (mise.<ENV>.toml) + +### `-g --global` + +Write to the global config (~/.config/mise/config.toml) instead of the local one + +### `-n --dry-run` + +Print the commands that would run without writing config or installing + +### `-p --path ` + +Write to this config file or directory + +### `-y --yes` + +Skip the confirmation prompt + +Examples: + +``` +mise system use apt:curl brew:jq +mise system use -g brew:postgresql@17 +mise system use apt:curl@8.5.0-2 +``` diff --git a/docs/system-packages/apt.md b/docs/system-packages/apt.md index b80bb86a74..6cdb3af806 100644 --- a/docs/system-packages/apt.md +++ b/docs/system-packages/apt.md @@ -18,6 +18,9 @@ System packages for Debian-family Linux (Debian, Ubuntu, Mint, ...). `name:arch` qualifiers pass through in the package name. - `DEBIAN_FRONTEND=noninteractive` is set so installs never block on configuration prompts. +- `mise system upgrade` runs `apt-get update` and then + `apt-get install --only-upgrade` for the configured packages, so nothing + not already installed gets pulled in. ## Metadata refresh @@ -35,4 +38,5 @@ mise system install --update A pinned entry (`"apt:curl" = "8.5.0-2ubuntu10"`) shows as `version mismatch` in `mise system status` when a different version is installed, and `mise system install` passes the pin to apt to correct it. `"latest"` entries -are satisfied by any installed version — mise does not upgrade them. +are satisfied by any installed version — use `mise system upgrade` to move +them to the newest available version. diff --git a/docs/system-packages/brew.md b/docs/system-packages/brew.md index e446b27449..d21ee7a3a8 100644 --- a/docs/system-packages/brew.md +++ b/docs/system-packages/brew.md @@ -82,6 +82,15 @@ For each formula in the dependency closure (dependencies first): [keg-only](https://docs.brew.sh/FAQ#what-does-keg-only-mean) formulae get the `opt` link but are not linked into the prefix, same as brew. +## Upgrades + +`mise system upgrade` re-resolves the configured formulae against the +formulae.brew.sh API and pours any whose current version differs from the +linked keg — the new keg replaces the old one and the links are repointed, +the same dance `brew upgrade` does. Since bottles only exist for a formula's +current version, "upgrade" and "install the current bottle" are the same +operation. + ## Limitations - **Formulae only.** Casks (GUI apps) and `brew services` are not diff --git a/docs/system-packages/dnf.md b/docs/system-packages/dnf.md index f1dd696faa..33147c1bd0 100644 --- a/docs/system-packages/dnf.md +++ b/docs/system-packages/dnf.md @@ -20,6 +20,8 @@ Alma, ...). release of that version. - `mise system install --update` adds `--refresh` to force a metadata refresh; otherwise dnf manages its own metadata expiry. +- `mise system upgrade` runs `dnf upgrade -y --refresh` for the configured + packages — only already-installed packages are touched. ::: info Only `dnf` is supported — not legacy `yum`-only systems. On RHEL/CentOS 8+ diff --git a/docs/system-packages/index.md b/docs/system-packages/index.md index c259108c4f..4dcfa889d4 100644 --- a/docs/system-packages/index.md +++ b/docs/system-packages/index.md @@ -63,8 +63,31 @@ mise system install --dry-run # print the commands without running them mise system install --yes # skip the confirmation prompt mise system install --manager apt mise system install --update # refresh package manager metadata first + +mise system use apt:curl brew:jq # add to [system.packages] and install +mise system use -g brew:ffmpeg # write to the global config instead +mise system use apt:curl@8.5.0-2 # pin a version (brew pins via the + # formula name: brew:postgresql@17) + +mise system upgrade # upgrade installed packages to current versions +mise system upgrade --manager brew ``` +`mise system use` is `mise use` for system packages: it writes +`"manager:package" = "version"` entries to mise.toml (the local file by +default, the global one with `-g`) and installs whatever is missing. Entries +for managers that aren't available on the current machine are written without +installing — that's how a shared config picks up `apt:` lines authored on a +Mac. + +`mise system upgrade` refreshes package manager metadata and upgrades the +configured packages that are already installed to the newest available +version — apt and dnf also honor a version pinned in config (pacman and brew +[can't install pins](/system-packages/pacman.html), so pinned entries are +skipped with a warning). Packages that aren't installed yet are skipped — +that's `mise system install`'s job. For brew this pours the formula's current +bottle and replaces the old keg. + `mise doctor` also reports configured system packages and warns when any are missing. diff --git a/docs/system-packages/pacman.md b/docs/system-packages/pacman.md index 69ad8933f8..341799be38 100644 --- a/docs/system-packages/pacman.md +++ b/docs/system-packages/pacman.md @@ -18,6 +18,11 @@ System packages for Arch-family Linux (Arch, Manjaro, EndeavourOS, ...). - If `/var/lib/pacman/sync` contains no databases (fresh containers), mise runs `pacman -Sy` automatically before installing. Force a refresh with `mise system install --update`. +- `mise system upgrade` runs `pacman -Sy` and then upgrades only the + configured packages. Note that Arch officially supports only full-system + upgrades (`pacman -Syu`) — upgrading individual packages is a + [partial upgrade](https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported), + so prefer running `pacman -Syu` yourself on a rolling-release system. ::: warning Arch repositories only carry the latest version of each package, so pacman diff --git a/e2e/cli/test_system_install_apt b/e2e/cli/test_system_install_apt index 82bdd83c2b..c12111ab22 100644 --- a/e2e/cli/test_system_install_apt +++ b/e2e/cli/test_system_install_apt @@ -33,3 +33,16 @@ assert_succeed "bc --version" # second install is a no-op assert_contains "mise system install --yes 2>&1" "already installed" + +# upgrade refreshes lists and only touches installed packages +assert_contains "mise system upgrade --dry-run" "apt-get update" +assert_contains "mise system upgrade --dry-run" "apt-get install -y --only-upgrade -- bc" +assert_succeed "mise system upgrade --yes" + +# `use` writes the config entry and installs in one step +rm mise.toml +apt-get remove -y bc >/dev/null 2>&1 || true +assert_succeed "mise system use --yes apt:bc" +assert_contains "cat mise.toml" '"apt:bc" = "latest"' +assert_contains "mise system status" "installed" +assert_succeed "bc --version" diff --git a/e2e/cli/test_system_use b/e2e/cli/test_system_use new file mode 100644 index 0000000000..106a848d69 --- /dev/null +++ b/e2e/cli/test_system_use @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +# `mise system use` config-writing works on any platform — managers that +# aren't available on this machine are added to the config without installing +# (brew specs are avoided here: even a dry-run resolves the formula closure +# over the network) + +# dry-run prints what would be written without creating the file +assert_contains "mise system use --dry-run apt:zlib1g-dev" '"apt:zlib1g-dev" = "latest"' +assert_fail "cat mise.toml" + +# version pins use @, like mise use +assert_contains "mise system use --dry-run apt:curl@8.5.0-2" '"apt:curl" = "8.5.0-2"' + +# bad specs and unknown managers fail before anything is written +assert_fail "mise system use noprefix" +assert_fail "mise system use not-a-real-manager:pkg" +assert_fail "cat mise.toml" + +# writes the entry even when the manager isn't available on this machine +# (guarded so a real Arch box doesn't actually install ripgrep) +if ! command -v pacman >/dev/null; then + assert_succeed "mise system use --yes pacman:ripgrep" + assert_contains "cat mise.toml" '"pacman:ripgrep" = "latest"' +fi diff --git a/man/man1/mise.1 b/man/man1/mise.1 index 60e05e8acb..752a9c9281 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -472,6 +472,18 @@ Show the status of system packages from `[system.packages]` \fIAliases: \fRls .RE .TP +\fBsystem upgrade\fR +Upgrade installed system packages from `[system.packages]` +.RS +\fIAliases: \fRup +.RE +.TP +\fBsystem use\fR +Add system packages to [system.packages] and install them +.RS +\fIAliases: \fRu +.RE +.TP \fBtasks\fR Manage tasks .RS @@ -2777,6 +2789,71 @@ Output in JSON format .TP \fB\-\-missing\fR Exit with code 1 if any configured packages are missing +.SH "MISE SYSTEM UPGRADE" +Upgrade installed system packages from `[system.packages]` + +Refreshes package manager metadata and upgrades the configured packages +that are already installed: apt/dnf/pacman upgrade to the newest available +version (apt and dnf honor a version pinned in config), brew pours the +formula's current bottle and replaces the old keg. Packages that are not +installed yet are skipped — use `mise system install` for those. + +Packages can also be given explicitly in `manager:package` form. +.PP +\fBUsage:\fR mise system upgrade [OPTIONS] [] ... +.PP +\fBOptions:\fR +.PP +.TP +\fB\-m, \-\-manager\fR \fI\fR +Only upgrade packages for this manager, e.g. `apt` or `brew` +.TP +\fB\-n, \-\-dry\-run\fR +Print the commands that would run without running them +.TP +\fB\-y, \-\-yes\fR +Skip the confirmation prompt +\fBArguments:\fR +.PP +.TP +\fB\fR +Packages in `manager:package` form; defaults to everything configured in [system.packages] +.SH "MISE SYSTEM USE" +Add system packages to [system.packages] and install them + +Like `mise use` for tools: writes `"manager:package" = "version"` entries +to mise.toml (the local config by default, the global one with `\-g`) and +then installs whatever is missing. + +Versions are pinned with `@`: `mise system use apt:curl@8.5.0\-2`. Without +`@` (or with `@latest`) no pin is written. brew formulae version through +their names instead (`brew:postgresql@17`), so `@` is always part of the +formula name there. +.PP +\fBUsage:\fR mise system use [OPTIONS] ... +.PP +\fBOptions:\fR +.PP +.TP +\fB\-e, \-\-env\fR \fI\fR +Write to the config file for this environment (mise..toml) +.TP +\fB\-g, \-\-global\fR +Write to the global config (~/.config/mise/config.toml) instead of the local one +.TP +\fB\-n, \-\-dry\-run\fR +Print the commands that would run without writing config or installing +.TP +\fB\-p, \-\-path\fR \fI\fR +Write to this config file or directory +.TP +\fB\-y, \-\-yes\fR +Skip the confirmation prompt +\fBArguments:\fR +.PP +.TP +\fB\fR +Packages in `manager:package[@version]` form .SH "MISE TASKS" Manage tasks .PP diff --git a/mise.usage.kdl b/mise.usage.kdl index 85553a1dd5..a35456a67c 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -2903,6 +2903,70 @@ Examples: flag "-J --json" help="Output in JSON format" flag --missing help="Exit with code 1 if any configured packages are missing" } + cmd upgrade help="Upgrade installed system packages from `[system.packages]`" { + alias up + long_help #""" +Upgrade installed system packages from `[system.packages]` + +Refreshes package manager metadata and upgrades the configured packages +that are already installed: apt/dnf/pacman upgrade to the newest available +version (apt and dnf honor a version pinned in config), brew pours the +formula's current bottle and replaces the old keg. Packages that are not +installed yet are skipped — use `mise system install` for those. + +Packages can also be given explicitly in `manager:package` form. +"""# + after_long_help #""" +Examples: + + $ mise system upgrade + $ mise system upgrade brew:postgresql@17 + $ mise system upgrade --manager apt --yes + $ mise system upgrade --dry-run + +"""# + flag "-m --manager" help="Only upgrade packages for this manager, e.g. `apt` or `brew`" { + arg { + choices apt brew dnf pacman + } + } + flag "-n --dry-run" help="Print the commands that would run without running them" + flag "-y --yes" help="Skip the confirmation prompt" + arg "[PACKAGE]…" help="Packages in `manager:package` form; defaults to everything configured in [system.packages]" required=#false var=#true + } + cmd use help="Add system packages to [system.packages] and install them" { + alias u + long_help #""" +Add system packages to [system.packages] and install them + +Like `mise use` for tools: writes `"manager:package" = "version"` entries +to mise.toml (the local config by default, the global one with `-g`) and +then installs whatever is missing. + +Versions are pinned with `@`: `mise system use apt:curl@8.5.0-2`. Without +`@` (or with `@latest`) no pin is written. brew formulae version through +their names instead (`brew:postgresql@17`), so `@` is always part of the +formula name there. +"""# + after_long_help #""" +Examples: + + $ mise system use apt:curl brew:jq + $ mise system use -g brew:postgresql@17 + $ mise system use apt:curl@8.5.0-2 + +"""# + flag "-e --env" help="Write to the config file for this environment (mise..toml)" { + arg + } + flag "-g --global" help="Write to the global config (~/.config/mise/config.toml) instead of the local one" + flag "-n --dry-run" help="Print the commands that would run without writing config or installing" + flag "-p --path" help="Write to this config file or directory" { + arg + } + flag "-y --yes" help="Skip the confirmation prompt" + arg … help="Packages in `manager:package[@version]` form" var=#true + } } cmd tasks help="Manage tasks" { alias t diff --git a/src/cli/system/driver.rs b/src/cli/system/driver.rs new file mode 100644 index 0000000000..4f129a11fb --- /dev/null +++ b/src/cli/system/driver.rs @@ -0,0 +1,183 @@ +//! Shared per-manager execution loop for `mise system install`/`upgrade`/`use`. + +use std::collections::HashMap; + +use eyre::{Result, bail}; + +use crate::config::Settings; +use crate::system::ManagerPackages; +use crate::system::packages::{InstallOpts, PackageState}; +use crate::ui::prompt; + +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum Action { + Install, + Upgrade, +} + +impl Action { + fn verb(self) -> &'static str { + match self { + Action::Install => "install", + Action::Upgrade => "upgrade", + } + } +} + +pub(crate) struct DriverOpts { + /// `--manager` filter + pub manager: Option, + /// packages were named explicitly on the CLI — unavailable managers are + /// then a hard error instead of a silent (cross-platform config) skip + pub explicit: bool, + pub dry_run: bool, + pub update: bool, + pub yes: bool, +} + +/// Run `action` for every manager in `mgrs`, honoring the `--manager` filter, +/// disabled/unavailable managers, unsatisfiable version pins, and the +/// confirmation prompt. +pub(crate) async fn run(mgrs: Vec, action: Action, d: &DriverOpts) -> Result<()> { + if let Some(only) = &d.manager + && !mgrs.iter().any(|mp| mp.manager.name() == only) + { + // distinguish "not configured" from "filtered out by settings" — + // the aggregation drops managers excluded by + // system_packages.managers before we ever see them + if let Some(enabled) = &Settings::get().system_packages.managers + && !enabled.contains(only) + { + bail!( + "manager '{only}' is excluded by the system_packages.managers setting \ + (currently: {})", + enabled.join(", ") + ); + } + bail!("no packages requested for manager '{only}'"); + } + if mgrs.is_empty() { + info!("no system packages configured in [system.packages]"); + return Ok(()); + } + let opts = InstallOpts { + dry_run: d.dry_run, + update: d.update, + }; + for mp in mgrs { + if let Some(only) = &d.manager + && mp.manager.name() != only + { + continue; + } + let name = mp.manager.name(); + if mp.disabled { + if d.manager.is_some() { + bail!("manager '{name}' is excluded by the system_packages.managers setting"); + } + debug!("{name}: skipping, excluded by system_packages.managers"); + continue; + } + if !mp.manager.is_available() { + if d.manager.is_some() || d.explicit { + // explicitly requested (via --manager or manager:package + // specs) — failing silently would be a lie + bail!( + "{name} is not available: {}", + mp.manager.unavailable_reason() + ); + } + debug!("{name}: skipping, {}", mp.manager.unavailable_reason()); + continue; + } + let statuses = mp.manager.installed(&mp.requests).await?; + let mut targets: Vec<_> = statuses + .iter() + .filter(|s| match action { + Action::Install => !matches!(s.state, PackageState::Installed { .. }), + // upgrade acts on whatever is present (the manager no-ops + // already-current packages); missing packages are skipped + // below with a pointer at `install` + Action::Upgrade => !matches!(s.state, PackageState::Missing), + }) + .map(|s| s.request.clone()) + .collect(); + let skipped = statuses.len() - targets.len(); + if action == Action::Upgrade && skipped > 0 { + warn!("{name}: {skipped} package(s) not installed — run `mise system install` first"); + } + // a pin this manager can never satisfy must not block the rest + // of the batch — it stays visible in `status` as a mismatch + if !mp.manager.supports_version_pins() { + targets.retain(|r| { + if r.version.is_some() { + warn!( + "{name}: cannot {} pinned version '{r}', skipping", + action.verb() + ); + false + } else { + true + } + }); + } + if action == Action::Install && skipped > 0 { + info!("{name}: {skipped} package(s) already installed"); + } + if targets.is_empty() { + continue; + } + let list = targets.iter().map(|r| r.to_string()).collect::>(); + if !d.dry_run && !d.yes && console::user_attended_stderr() { + let msg = format!("{name}: {} {}?", action.verb(), list.join(", ")); + if !prompt::confirm(msg)? { + info!("{name}: skipped"); + continue; + } + } + match action { + Action::Install => { + mp.manager.install(&targets, &opts).await?; + if !d.dry_run { + info!("{name}: installed {}", list.join(", ")); + } + } + Action::Upgrade => { + // managers no-op packages that are already current, so + // re-query afterwards and report only what actually changed + let prior: HashMap = statuses + .iter() + .filter_map(|s| match &s.state { + PackageState::Installed { version } + | PackageState::VersionMismatch { installed: version } => { + Some((s.request.name.clone(), version.clone())) + } + PackageState::Missing => None, + }) + .collect(); + mp.manager.upgrade(&targets, &opts).await?; + if !d.dry_run { + let after = mp.manager.installed(&targets).await?; + let changed: Vec = after + .iter() + .filter_map(|s| match &s.state { + PackageState::Installed { version } + | PackageState::VersionMismatch { installed: version } => { + let old = prior.get(&s.request.name)?; + (old != version) + .then(|| format!("{} {old} -> {version}", s.request.name)) + } + PackageState::Missing => None, + }) + .collect(); + if changed.is_empty() { + info!("{name}: already up to date"); + } else { + info!("{name}: upgraded {}", changed.join(", ")); + } + } + } + } + } + Ok(()) +} diff --git a/src/cli/system/install.rs b/src/cli/system/install.rs index e506d6d21d..9e12c3e6e5 100644 --- a/src/cli/system/install.rs +++ b/src/cli/system/install.rs @@ -1,9 +1,8 @@ -use eyre::{Result, bail}; +use eyre::Result; +use super::driver::{self, Action, DriverOpts}; use crate::config::{Config, Settings}; use crate::system; -use crate::system::packages::{InstallOpts, PackageState}; -use crate::ui::prompt; /// Install missing system packages from `[system.packages]` /// @@ -48,96 +47,14 @@ impl SystemInstall { } else { system::packages_from_specs(&self.packages)? }; - if let Some(only) = &self.manager - && !mgrs.iter().any(|mp| mp.manager.name() == only) - { - // distinguish "not configured" from "filtered out by settings" — - // the aggregation above drops managers excluded by - // system_packages.managers before we ever see them - if let Some(enabled) = &Settings::get().system_packages.managers - && !enabled.contains(only) - { - bail!( - "manager '{only}' is excluded by the system_packages.managers setting \ - (currently: {})", - enabled.join(", ") - ); - } - bail!("no packages requested for manager '{only}'"); - } - if mgrs.is_empty() { - info!("no system packages configured in [system.packages]"); - return Ok(()); - } - let opts = InstallOpts { + let opts = DriverOpts { + manager: self.manager, + explicit: !self.packages.is_empty(), dry_run: self.dry_run, update: self.update, + yes: self.yes, }; - for mp in mgrs { - if let Some(only) = &self.manager - && mp.manager.name() != only - { - continue; - } - let name = mp.manager.name(); - if mp.disabled { - if self.manager.is_some() { - bail!("manager '{name}' is excluded by the system_packages.managers setting"); - } - debug!("{name}: skipping, excluded by system_packages.managers"); - continue; - } - if !mp.manager.is_available() { - if self.manager.is_some() || !self.packages.is_empty() { - // explicitly requested (via --manager or manager:package - // specs) — failing silently would be a lie - bail!( - "{name} is not available: {}", - mp.manager.unavailable_reason() - ); - } - debug!("{name}: skipping, {}", mp.manager.unavailable_reason()); - continue; - } - let statuses = mp.manager.installed(&mp.requests).await?; - let mut missing: Vec<_> = statuses - .iter() - .filter(|s| !matches!(s.state, PackageState::Installed { .. })) - .map(|s| s.request.clone()) - .collect(); - let satisfied = statuses.len() - missing.len(); - // a pin this manager can never satisfy must not block the rest - // of the batch — it stays visible in `status` as a mismatch - if !mp.manager.supports_version_pins() { - missing.retain(|r| { - if r.version.is_some() { - warn!("{name}: cannot install pinned version '{r}', skipping"); - false - } else { - true - } - }); - } - if satisfied > 0 { - info!("{name}: {satisfied} package(s) already installed"); - } - if missing.is_empty() { - continue; - } - let list = missing.iter().map(|r| r.to_string()).collect::>(); - if !self.dry_run && !self.yes && console::user_attended_stderr() { - let msg = format!("{name}: install {}?", list.join(", ")); - if !prompt::confirm(msg)? { - info!("{name}: skipped"); - continue; - } - } - mp.manager.install(&missing, &opts).await?; - if !self.dry_run { - info!("{name}: installed {}", list.join(", ")); - } - } - Ok(()) + driver::run(mgrs, Action::Install, &opts).await } } diff --git a/src/cli/system/mod.rs b/src/cli/system/mod.rs index 7d848cb02e..3d02836a28 100644 --- a/src/cli/system/mod.rs +++ b/src/cli/system/mod.rs @@ -1,8 +1,12 @@ use clap::Subcommand; use eyre::Result; +mod driver; mod install; mod status; +mod upgrade; +#[path = "use.rs"] +mod r#use; /// [experimental] Manage system packages from `[system.packages]` /// @@ -21,6 +25,8 @@ pub struct System { enum Commands { Install(install::SystemInstall), Status(status::SystemStatus), + Upgrade(upgrade::SystemUpgrade), + Use(r#use::SystemUse), } impl System { @@ -28,6 +34,8 @@ impl System { match self.command { Commands::Install(cmd) => cmd.run().await, Commands::Status(cmd) => cmd.run().await, + Commands::Upgrade(cmd) => cmd.run().await, + Commands::Use(cmd) => cmd.run().await, } } } diff --git a/src/cli/system/upgrade.rs b/src/cli/system/upgrade.rs new file mode 100644 index 0000000000..d7a69b2433 --- /dev/null +++ b/src/cli/system/upgrade.rs @@ -0,0 +1,67 @@ +use eyre::Result; + +use super::driver::{self, Action, DriverOpts}; +use crate::config::{Config, Settings}; +use crate::system; + +/// Upgrade installed system packages from `[system.packages]` +/// +/// Refreshes package manager metadata and upgrades the configured packages +/// that are already installed: apt/dnf/pacman upgrade to the newest available +/// version (apt and dnf honor a version pinned in config), brew pours the +/// formula's current bottle and replaces the old keg. Packages that are not +/// installed yet are skipped — use `mise system install` for those. +/// +/// Packages can also be given explicitly in `manager:package` form. +#[derive(Debug, clap::Args)] +#[clap(visible_alias = "up", verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] +pub struct SystemUpgrade { + /// Packages in `manager:package` form; defaults to everything configured + /// in [system.packages] + #[clap(value_name = "PACKAGE")] + packages: Vec, + + /// Only upgrade packages for this manager, e.g. `apt` or `brew` + #[clap(long, short, value_parser = ["apt", "brew", "dnf", "pacman"])] + manager: Option, + + /// Print the commands that would run without running them + #[clap(long, short = 'n')] + dry_run: bool, + + /// Skip the confirmation prompt + #[clap(long, short)] + yes: bool, +} + +impl SystemUpgrade { + pub async fn run(self) -> Result<()> { + Settings::get().ensure_experimental("mise system")?; + let mgrs = if self.packages.is_empty() { + let config = Config::get().await?; + system::packages_from_config(&config) + } else { + system::packages_from_specs(&self.packages)? + }; + let opts = DriverOpts { + manager: self.manager, + explicit: !self.packages.is_empty(), + dry_run: self.dry_run, + // upgrades refresh metadata themselves (stale lists would make + // them silent no-ops), so no separate --update flag + update: false, + yes: self.yes, + }; + driver::run(mgrs, Action::Upgrade, &opts).await + } +} + +static AFTER_LONG_HELP: &str = color_print::cstr!( + r#"Examples: + + $ mise system upgrade + $ mise system upgrade brew:postgresql@17 + $ mise system upgrade --manager apt --yes + $ mise system upgrade --dry-run +"# +); diff --git a/src/cli/system/use.rs b/src/cli/system/use.rs new file mode 100644 index 0000000000..96cb167818 --- /dev/null +++ b/src/cli/system/use.rs @@ -0,0 +1,145 @@ +use std::path::PathBuf; + +use eyre::Result; +use indexmap::IndexMap; + +use super::driver::{self, Action, DriverOpts}; +use crate::config::config_file::ConfigFile; +use crate::config::config_file::mise_toml::MiseToml; +use crate::config::{ConfigPathOptions, Settings, resolve_target_config_path}; +use crate::file::display_path; +use crate::system; +use crate::system::packages::PackageRequest; + +/// Add system packages to [system.packages] and install them +/// +/// Like `mise use` for tools: writes `"manager:package" = "version"` entries +/// to mise.toml (the local config by default, the global one with `-g`) and +/// then installs whatever is missing. +/// +/// Versions are pinned with `@`: `mise system use apt:curl@8.5.0-2`. Without +/// `@` (or with `@latest`) no pin is written. brew formulae version through +/// their names instead (`brew:postgresql@17`), so `@` is always part of the +/// formula name there. +#[derive(Debug, clap::Args)] +#[clap(visible_alias = "u", verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] +pub struct SystemUse { + /// Packages in `manager:package[@version]` form + #[clap(value_name = "PACKAGE", required = true)] + packages: Vec, + + /// Write to the config file for this environment (mise..toml) + #[clap(long, short, value_name = "ENV", conflicts_with_all = ["global", "path"])] + env: Option, + + /// Write to the global config (~/.config/mise/config.toml) instead of the + /// local one + #[clap(long, short)] + global: bool, + + /// Print the commands that would run without writing config or installing + #[clap(long, short = 'n')] + dry_run: bool, + + /// Write to this config file or directory + #[clap(long, short, value_name = "PATH", conflicts_with = "global")] + path: Option, + + /// Skip the confirmation prompt + #[clap(long, short)] + yes: bool, +} + +impl SystemUse { + pub async fn run(self) -> Result<()> { + Settings::get().ensure_experimental("mise system")?; + let mut by_mgr: IndexMap> = IndexMap::new(); + let mut entries: Vec<(String, String)> = vec![]; + for spec in &self.packages { + let (mgr, request) = system::parse_use_spec(spec)?; + let key = format!("{mgr}:{}", request.name); + let version = request.version.clone().unwrap_or_else(|| "latest".into()); + // the same package twice: the last version wins, in the config + // entry and the install request alike + match entries.iter_mut().find(|(k, _)| k == &key) { + Some(entry) => entry.1 = version, + None => entries.push((key, version)), + } + let requests = by_mgr.entry(mgr).or_default(); + match requests.iter_mut().find(|r| r.name == request.name) { + Some(r) => *r = request, + None => requests.push(request), + } + } + // resolve managers before touching the config file so a typo'd + // manager doesn't get written + let mgrs = system::packages_from_requests(by_mgr)?; + + let path = resolve_target_config_path(ConfigPathOptions { + global: self.global, + path: self.path.clone(), + env: self.env.clone(), + cwd: None, + prefer_toml: true, // [system] only exists in mise.toml + prevent_home_local: true, // in $HOME, write the global config + })?; + if self.dry_run { + for (key, version) in &entries { + miseprintln!("{}: \"{key}\" = \"{version}\"", display_path(&path)); + } + } else { + let mut cf = if path.exists() { + MiseToml::from_file(&path)? + } else { + MiseToml::init(&path) + }; + for (key, version) in &entries { + cf.update_system_package(key, version)?; + } + cf.save()?; + info!( + "{}: added {}", + display_path(&path), + entries + .iter() + .map(|(k, _)| k.as_str()) + .collect::>() + .join(", ") + ); + } + + // unlike `mise system install apt:x`, an unavailable manager is not + // an error here: writing apt: entries from a mac into a shared repo + // config is the point of a declarative file. Say so (except in + // dry-run, where nothing was written), then install best-effort for + // this machine. + if !self.dry_run { + for mp in &mgrs { + if !mp.disabled && !mp.manager.is_available() { + info!( + "{}: {} — added to config without installing", + mp.manager.name(), + mp.manager.unavailable_reason() + ); + } + } + } + let opts = DriverOpts { + manager: None, + explicit: false, + dry_run: self.dry_run, + update: false, + yes: self.yes, + }; + driver::run(mgrs, Action::Install, &opts).await + } +} + +static AFTER_LONG_HELP: &str = color_print::cstr!( + r#"Examples: + + $ mise system use apt:curl brew:jq + $ mise system use -g brew:postgresql@17 + $ mise system use apt:curl@8.5.0-2 +"# +); diff --git a/src/config/config_file/mise_toml.rs b/src/config/config_file/mise_toml.rs index 2e5740cabb..a645071f4e 100644 --- a/src/config/config_file/mise_toml.rs +++ b/src/config/config_file/mise_toml.rs @@ -487,6 +487,33 @@ impl MiseToml { Ok(()) } + /// Set `[system.packages].":" = ""`, + /// creating the tables as needed ("latest" means no pin) + pub fn update_system_package(&mut self, spec: &str, version: &str) -> eyre::Result<()> { + self.system + .get_or_insert_with(Default::default) + .packages + .insert(spec.to_string(), version.to_string()); + let mut doc = self.doc_mut()?; + let system = doc + .get_mut() + .unwrap() + .entry("system") + .or_insert_with(table) + .as_table_mut() + .unwrap(); + // don't render an empty [system] header above [system.packages] + system.set_implicit(true); + let packages = system + .entry("packages") + .or_insert_with(table) + .as_table_mut() + .unwrap(); + let key = get_key_with_decor(packages, spec); + packages.insert_formatted(&key, toml_edit::value(version)); + Ok(()) + } + pub fn update_env_age( &mut self, key: &str, @@ -2048,6 +2075,32 @@ mod tests { file::write(&p, "[tools]\n").unwrap(); let cf = MiseToml::from_file(&p).unwrap(); assert!(cf.system_config().is_none()); + file::remove_file(&p).unwrap(); + } + + #[tokio::test] + async fn test_update_system_package() { + let _config = Config::get().await.unwrap(); + let p = CWD.as_ref().unwrap().join(".test.mise.toml"); + // creates [system.packages] when absent, preserves other sections + file::write(&p, "[tools]\njq = \"latest\"\n").unwrap(); + let mut cf = MiseToml::from_file(&p).unwrap(); + cf.update_system_package("apt:curl", "latest").unwrap(); + cf.update_system_package("brew:postgresql@17", "latest") + .unwrap(); + // overrides an existing pin in place + cf.update_system_package("apt:curl", "8.5.0-2").unwrap(); + assert_snapshot!(cf.dump().unwrap(), @r#" + [tools] + jq = "latest" + + [system.packages] + "apt:curl" = "8.5.0-2" + "brew:postgresql@17" = "latest" + "#); + let system = cf.system_config().unwrap(); + assert_eq!(system.packages.get("apt:curl").unwrap(), "8.5.0-2"); + file::remove_file(&p).unwrap(); } #[tokio::test] diff --git a/src/system/mod.rs b/src/system/mod.rs index 5cfbe3370c..65563ef41c 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -52,6 +52,53 @@ pub fn parse_spec(spec: &str) -> eyre::Result<(String, String)> { } } +/// Split a `mise system use` spec `manager:package[@version]` into its parts. +/// +/// `@version` mirrors `mise use tool@version`; `@latest` (or no `@`) means no +/// pin. brew is exempt from `@` parsing: `@` is part of brew formula *names* +/// (`postgresql@17` — that name IS brew's versioning mechanism), and brew +/// bottles can't be installed at a pinned version anyway. +pub fn parse_use_spec(spec: &str) -> eyre::Result<(String, PackageRequest)> { + let (mgr, rest) = parse_spec(spec)?; + if mgr == "brew" { + return Ok(( + mgr, + PackageRequest { + name: rest, + version: None, + }, + )); + } + match rest.rsplit_once('@') { + Some((name, version)) if !name.is_empty() && !version.is_empty() => Ok(( + mgr, + PackageRequest { + name: name.to_string(), + version: (version != "latest").then(|| version.to_string()), + }, + )), + Some(_) => { + bail!("invalid system package spec '{spec}': expected ':[@version]'") + } + None => Ok(( + mgr, + PackageRequest { + name: rest, + version: None, + }, + )), + } +} + +/// Build [`ManagerPackages`] from already-parsed requests (used by +/// `mise system use`, where version pins come from the CLI spec). Unknown or +/// settings-excluded managers are hard errors. +pub fn packages_from_requests( + by_mgr: IndexMap>, +) -> eyre::Result> { + resolve_managers(by_mgr, true) +} + /// Aggregate `[system.packages]` across all loaded config files. /// /// Keys union global -> local; a more local config overrides the version pin @@ -144,3 +191,40 @@ fn resolve_managers( } Ok(out) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_use_spec() { + let (mgr, req) = parse_use_spec("apt:curl").unwrap(); + assert_eq!( + (mgr.as_str(), req.name.as_str(), req.version), + ("apt", "curl", None) + ); + + let (mgr, req) = parse_use_spec("apt:curl@8.5.0-2").unwrap(); + assert_eq!(mgr, "apt"); + assert_eq!(req.name, "curl"); + assert_eq!(req.version.as_deref(), Some("8.5.0-2")); + + // @latest is the same as no pin + let (_, req) = parse_use_spec("dnf:bash@latest").unwrap(); + assert_eq!(req.version, None); + + // apt arch qualifiers stay in the name + let (_, req) = parse_use_spec("apt:gcc:arm64@13.2").unwrap(); + assert_eq!(req.name, "gcc:arm64"); + assert_eq!(req.version.as_deref(), Some("13.2")); + + // brew formula names contain '@' — never treated as a version + let (mgr, req) = parse_use_spec("brew:postgresql@17").unwrap(); + assert_eq!(mgr, "brew"); + assert_eq!(req.name, "postgresql@17"); + assert_eq!(req.version, None); + + assert!(parse_use_spec("apt:curl@").is_err()); + assert!(parse_use_spec("noprefix").is_err()); + } +} diff --git a/src/system/packages/apt.rs b/src/system/packages/apt.rs index 1a00bbeb3d..a1999f7735 100644 --- a/src/system/packages/apt.rs +++ b/src/system/packages/apt.rs @@ -168,6 +168,31 @@ impl SystemPackageManager for AptManager { } sudo::run("apt-get", &args, &debian_frontend()) } + + async fn upgrade(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> { + // upgrading against stale lists is a no-op, so always refresh first + self.update(opts)?; + // `--only-upgrade` keeps a race (package removed between our status + // check and this call) from turning an upgrade into a fresh install + let mut args = vec![ + "install".to_string(), + "-y".to_string(), + "--only-upgrade".to_string(), + "--".to_string(), + ]; + args.extend(pkgs.iter().map(|p| match &p.version { + Some(v) => format!("{}={v}", p.name), + None => p.name.clone(), + })); + if opts.dry_run { + miseprintln!( + "{}", + sudo::argv_with_env("apt-get", &args, &debian_frontend()).join(" ") + ); + return Ok(()); + } + sudo::run("apt-get", &args, &debian_frontend()) + } } #[cfg(test)] diff --git a/src/system/packages/dnf.rs b/src/system/packages/dnf.rs index 5c6d2af16b..742cb688d4 100644 --- a/src/system/packages/dnf.rs +++ b/src/system/packages/dnf.rs @@ -124,6 +124,28 @@ impl SystemPackageManager for DnfManager { } sudo::run("dnf", &args, &[]) } + + async fn upgrade(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> { + // --refresh: expire cached metadata so "upgrade" actually sees new + // versions; `dnf upgrade ` only touches already-installed + // packages (a pin downgrade would need `dnf install name-version`, + // which the install path already provides) + let mut args = vec![ + "upgrade".to_string(), + "-y".to_string(), + "--refresh".to_string(), + "--".to_string(), + ]; + args.extend(pkgs.iter().map(|p| match &p.version { + Some(v) => format!("{}-{v}", p.name), + None => p.name.clone(), + })); + if opts.dry_run { + miseprintln!("{}", sudo::argv("dnf", &args).join(" ")); + return Ok(()); + } + sudo::run("dnf", &args, &[]) + } } #[cfg(test)] diff --git a/src/system/packages/mod.rs b/src/system/packages/mod.rs index e1e4cbe0b4..4c3cdd31c1 100644 --- a/src/system/packages/mod.rs +++ b/src/system/packages/mod.rs @@ -82,6 +82,15 @@ pub trait SystemPackageManager: Send + Sync { /// Install the given packages (already filtered to missing/mismatched). async fn install(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()>; + /// Upgrade the given packages (already filtered to installed ones). + /// Defaults to `install` — for brew that is exactly right (pouring a + /// formula whose current version differs replaces the old keg), and apt/ + /// dnf/pacman override to refresh metadata first and use their native + /// upgrade invocation. + async fn upgrade(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> { + self.install(pkgs, opts).await + } + /// Can `install` satisfy a version pin? pacman (Arch repos only carry /// the latest version) and brew (bottles only exist for a formula's /// current version) cannot — their pins are status-only, and the diff --git a/src/system/packages/pacman.rs b/src/system/packages/pacman.rs index 354833893b..91c99b6477 100644 --- a/src/system/packages/pacman.rs +++ b/src/system/packages/pacman.rs @@ -152,6 +152,26 @@ impl SystemPackageManager for PacmanManager { } sudo::run("pacman", &args, &[]) } + + async fn upgrade(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> { + // refresh sync DBs, then -S --needed upgrades exactly the named + // packages that are outdated. Note: Arch officially supports only + // full-system upgrades (-Syu); upgrading individual packages is a + // partial upgrade — documented as a caveat in the pacman docs page. + self.refresh(opts)?; + let mut args = vec![ + "-S".to_string(), + "--noconfirm".to_string(), + "--needed".to_string(), + "--".to_string(), + ]; + args.extend(pkgs.iter().map(|p| p.name.clone())); + if opts.dry_run { + miseprintln!("{}", sudo::argv("pacman", &args).join(" ")); + return Ok(()); + } + sudo::run("pacman", &args, &[]) + } } #[cfg(test)] diff --git a/xtasks/fig/src/mise.ts b/xtasks/fig/src/mise.ts index 0be4f9d7bf..acf8a8ebf3 100644 --- a/xtasks/fig/src/mise.ts +++ b/xtasks/fig/src/mise.ts @@ -3274,6 +3274,88 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ["upgrade", "up"], + description: + "Upgrade installed system packages from `[system.packages]`", + options: [ + { + name: ["-m", "--manager"], + description: + "Only upgrade packages for this manager, e.g. `apt` or `brew`", + isRepeatable: false, + args: { + name: "manager", + suggestions: ["apt", "brew", "dnf", "pacman"], + }, + }, + { + name: ["-n", "--dry-run"], + description: + "Print the commands that would run without running them", + isRepeatable: false, + }, + { + name: ["-y", "--yes"], + description: "Skip the confirmation prompt", + isRepeatable: false, + }, + ], + args: { + name: "package", + description: + "Packages in `manager:package` form; defaults to everything configured in [system.packages]", + isOptional: true, + isVariadic: true, + }, + }, + { + name: ["use", "u"], + description: + "Add system packages to [system.packages] and install them", + options: [ + { + name: ["-e", "--env"], + description: + "Write to the config file for this environment (mise..toml)", + isRepeatable: false, + args: { + name: "env", + }, + }, + { + name: ["-g", "--global"], + description: + "Write to the global config (~/.config/mise/config.toml) instead of the local one", + isRepeatable: false, + }, + { + name: ["-n", "--dry-run"], + description: + "Print the commands that would run without writing config or installing", + isRepeatable: false, + }, + { + name: ["-p", "--path"], + description: "Write to this config file or directory", + isRepeatable: false, + args: { + name: "path", + template: "filepaths", + }, + }, + { + name: ["-y", "--yes"], + description: "Skip the confirmation prompt", + isRepeatable: false, + }, + ], + args: { + name: "package", + description: "Packages in `manager:package[@version]` form", + isVariadic: true, + }, + }, ], }, {