diff --git a/docs/bootstrap/packages/index.md b/docs/bootstrap/packages/index.md index f9fd7b1da7..f2def33c53 100644 --- a/docs/bootstrap/packages/index.md +++ b/docs/bootstrap/packages/index.md @@ -10,6 +10,7 @@ mise can ensure machine-global system packages are installed via the "brew:postgresql@17" = "latest" "brew:ffmpeg" = "latest" "brew-cask:firefox" = "latest" +"mas:497799835" = "latest" ``` Each entry is keyed `"manager:package"` — the manager prefix is required — @@ -45,6 +46,7 @@ applied by `mise bootstrap user apply` or [`mise bootstrap`](/cli/bootstrap.html | `pacman` | Arch, Manjaro | [pacman](/bootstrap/packages/pacman.html) | | `brew` | macOS (arm64), Linux (x86_64/arm64) — **no Homebrew required** | [brew](/bootstrap/packages/brew.html) | | `brew-cask` | macOS — **no Homebrew required** | [brew](/bootstrap/packages/brew.html) | +| `mas` | macOS with the `mas` CLI on `PATH` | [mas](/bootstrap/packages/mas.html) | ## Semantics @@ -55,8 +57,9 @@ applied by `mise bootstrap user apply` or [`mise bootstrap`](/cli/bootstrap.html - **OS-filtered** — entries for a manager that isn't available on the current machine are not acted on, so the same config works across platforms: `apt` entries are ignored on macOS, `dnf` entries on Ubuntu, and so on. `brew` - works on both macOS and Linux; `brew-cask` works on macOS. Status commands - still list unavailable managers so nothing is silently invisible. + works on both macOS and Linux; `brew-cask` works on macOS; `mas` works on + macOS when the `mas` CLI is on `PATH`. Status commands still list + unavailable managers so nothing is silently invisible. - **Manual installation only** — mise never installs system packages implicitly. `mise install` will print a one-time hint when packages are missing, but only `mise bootstrap packages install` ever installs anything. @@ -86,7 +89,7 @@ mise bootstrap packages install --yes # skip the confirmation prompt mise bootstrap packages install --manager apt mise bootstrap packages install --update # refresh package manager metadata first -mise bootstrap packages use apt:curl brew:jq brew-cask:firefox # add and install +mise bootstrap packages use apt:curl brew:jq brew-cask:firefox mas:497799835 mise bootstrap packages use -g brew:ffmpeg # write globally mise bootstrap packages use apt:curl@8.5.0-2 # pin a version (brew pins via the # formula name: brew:postgresql@17) @@ -94,6 +97,7 @@ mise bootstrap packages use apt:curl@8.5.0-2 # pin a version (brew pins via th mise bootstrap packages upgrade # upgrade installed packages to current versions mise bootstrap packages upgrade --manager brew mise bootstrap packages upgrade --manager brew-cask +mise bootstrap packages upgrade --manager mas ``` `mise bootstrap packages use` is `mise use` for system packages: it writes @@ -106,11 +110,12 @@ Mac. `mise bootstrap packages 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, brew, -and brew-cask [can't install pins](/bootstrap/packages/pacman.html), so +brew-cask, and mas [can't install pins](/bootstrap/packages/pacman.html), so pinned entries are skipped with a warning). Packages that aren't installed yet are skipped — that's `mise bootstrap packages install`'s job. For brew this pours the formula's current bottle and replaces the old keg; for -brew-cask this installs the current cask artifact. +brew-cask this installs the current cask artifact; for mas this runs +`mas upgrade`. `mise doctor` also reports configured system packages and warns when any are missing. diff --git a/docs/bootstrap/packages/mas.md b/docs/bootstrap/packages/mas.md new file mode 100644 index 0000000000..29a5157986 --- /dev/null +++ b/docs/bootstrap/packages/mas.md @@ -0,0 +1,71 @@ +# mas + +Mac App Store apps via the [`mas`](https://github.com/mas-cli/mas) CLI. + +```toml +[bootstrap.packages] +"brew:mas" = "latest" +"mas:497799835" = "latest" # Xcode +``` + +`mas` apps are part of `[bootstrap.packages]`, just like apt packages, +Homebrew formulae, and casks. The package name is the App Store app ID: +a numeric ADAM ID accepted by `mas install` and `mas upgrade`. + +mise does not install `mas` implicitly. Install it yourself first, for +example with the built-in brew manager: + +```toml +[bootstrap.packages] +"brew:mas" = "latest" +"mas:497799835" = "latest" +``` + +or with a normal mise tool if you have one configured globally: + +```sh +mise use -g mas +``` + +## Commands + +```sh +mise bootstrap packages use mas:497799835 +mise bootstrap packages status +mise bootstrap packages install --manager mas +mise bootstrap packages upgrade --manager mas +``` + +`mise bootstrap packages install` runs `mas install ` for missing apps. +`mise bootstrap packages upgrade` runs `mas upgrade ` for installed apps. +Both commands require numeric ADAM IDs; bundle identifiers such as +`com.apple.dt.Xcode` are not valid package names. + +## Caveats + +`mas` is macOS-only and must be on `PATH`. On other platforms, or when the +`mas` command is missing, shared configs list the entries as skipped instead +of failing. Explicit commands such as `mise bootstrap packages install +--manager mas` still fail when `mas` is unavailable, matching the other +package managers. + +Mac App Store operations may require an Apple Account signed in to the App +Store, macOS authentication, prior purchase/claiming for paid apps, and valid +Spotlight indexing. mise surfaces errors from `mas` rather than trying to +purchase or claim apps itself. + +## Finding IDs + +Use `mas search` or copy an App Store URL and extract the numeric ID: + +```sh +mas search xcode +``` + +For example, Xcode's App Store URL contains `id497799835`, so the package +entry is: + +```toml +[bootstrap.packages] +"mas:497799835" = "latest" +``` diff --git a/docs/cli/bootstrap/packages/install.md b/docs/cli/bootstrap/packages/install.md index 46dbafc25b..4bcd920e67 100644 --- a/docs/cli/bootstrap/packages/install.md +++ b/docs/cli/bootstrap/packages/install.md @@ -26,7 +26,7 @@ Packages in `manager:package` form; defaults to everything configured in [bootst ### `-m --manager ` -Only install packages for this manager, e.g. `apt`, `brew`, or `brew-cask` +Only install packages for this manager, e.g. `apt`, `brew`, `brew-cask`, or `mas` **Choices:** @@ -34,6 +34,7 @@ Only install packages for this manager, e.g. `apt`, `brew`, or `brew-cask` - `brew` - `brew-cask` - `dnf` +- `mas` - `pacman` ### `-n --dry-run` @@ -52,7 +53,7 @@ Examples: ``` mise bootstrap packages install -mise bootstrap packages install apt:curl brew:jq brew-cask:firefox +mise bootstrap packages install apt:curl brew:jq brew-cask:firefox mas:497799835 mise bootstrap packages install --dry-run mise bootstrap packages install --manager apt --yes ``` diff --git a/docs/cli/bootstrap/packages/upgrade.md b/docs/cli/bootstrap/packages/upgrade.md index 0a9e463868..d0a4106698 100644 --- a/docs/cli/bootstrap/packages/upgrade.md +++ b/docs/cli/bootstrap/packages/upgrade.md @@ -10,9 +10,10 @@ Upgrade installed bootstrap packages from `[bootstrap.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, and brew-cask installs -the current cask artifact. Packages that are not installed yet are skipped -— use `mise bootstrap packages install` for those. +formula's current bottle and replaces the old keg, brew-cask installs +the current cask artifact, and mas upgrades App Store apps. Packages that +are not installed yet are skipped — use `mise bootstrap packages install` +for those. Packages can also be given explicitly in `manager:package` form. @@ -26,7 +27,7 @@ Packages in `manager:package` form; defaults to everything configured in [bootst ### `-m --manager ` -Only upgrade packages for this manager, e.g. `apt`, `brew`, or `brew-cask` +Only upgrade packages for this manager, e.g. `apt`, `brew`, `brew-cask`, or `mas` **Choices:** @@ -34,6 +35,7 @@ Only upgrade packages for this manager, e.g. `apt`, `brew`, or `brew-cask` - `brew` - `brew-cask` - `dnf` +- `mas` - `pacman` ### `-n --dry-run` @@ -50,6 +52,7 @@ Examples: mise bootstrap packages upgrade mise bootstrap packages upgrade brew:postgresql@17 mise bootstrap packages upgrade --manager brew-cask +mise bootstrap packages upgrade --manager mas mise bootstrap packages upgrade --manager apt --yes mise bootstrap packages upgrade --dry-run ``` diff --git a/docs/cli/bootstrap/packages/use.md b/docs/cli/bootstrap/packages/use.md index 990df9a5c1..caa4518f06 100644 --- a/docs/cli/bootstrap/packages/use.md +++ b/docs/cli/bootstrap/packages/use.md @@ -15,7 +15,7 @@ Versions are pinned with `@`: `mise bootstrap packages use apt:curl@8.5.0-2`. Wi `@` (or with `@latest`) no pin is written. brew formulae and casks version through their names instead (for example `brew:postgresql@17`, `brew-cask:temurin@17`), where `@` is part of the Homebrew name rather than -a mise version selector. +a mise version selector. mas uses numeric ADAM IDs and does not support pins. ## Arguments @@ -48,7 +48,7 @@ Skip the confirmation prompt Examples: ``` -mise bootstrap packages use apt:curl brew:jq brew-cask:firefox +mise bootstrap packages use apt:curl brew:jq brew-cask:firefox mas:497799835 mise bootstrap packages use -g brew:postgresql@17 mise bootstrap packages use apt:curl@8.5.0-2 ``` diff --git a/e2e/cli/test_system_status b/e2e/cli/test_system_status index f814c480ae..2d97ccb94d 100644 --- a/e2e/cli/test_system_status +++ b/e2e/cli/test_system_status @@ -5,13 +5,21 @@ cat <mise.toml "apt:bc" = "latest" "brew:jq" = "latest" "dnf:bc" = "latest" +"mas:497799835" = "latest" "pacman:bc" = "latest" EOF # status renders on any platform; unavailable managers are skipped, not errors assert_succeed "mise bootstrap packages status" assert_contains "mise bootstrap packages status" "bc" +assert_contains "mise bootstrap packages status" "497799835" assert_contains "mise bootstrap packages status --json" '"apt"' +assert_contains "mise bootstrap packages status --json" '"mas"' +if [[ "$(uname)" == "Darwin" ]] && command -v mas >/dev/null; then + assert_succeed "mise bootstrap packages install --manager mas --dry-run --yes" +else + assert_fail "mise bootstrap packages install --manager mas --dry-run --yes" +fi # unknown managers warn but don't fail cat <mise.toml diff --git a/e2e/cli/test_system_use b/e2e/cli/test_system_use index 2cd64e719b..c43b17e30e 100644 --- a/e2e/cli/test_system_use +++ b/e2e/cli/test_system_use @@ -12,6 +12,12 @@ assert_fail "cat mise.toml" # version pins use @, like mise use assert_contains "mise bootstrap packages use --dry-run apt:curl@8.5.0-2" '"apt:curl" = "8.5.0-2"' +# mas uses numeric ADAM IDs +assert_contains "mise bootstrap packages use --dry-run mas:497799835" '"mas:497799835" = "latest"' +assert_contains "mise bootstrap packages use --dry-run mas:497799835@latest" '"mas:497799835" = "latest"' +assert_fail "mise bootstrap packages use --dry-run mas:com.apple.dt.Xcode" +assert_fail "mise bootstrap packages use --dry-run mas:497799835@1" + # bad specs and unknown managers fail before anything is written assert_fail "mise bootstrap packages use noprefix" assert_fail "mise bootstrap packages use not-a-real-manager:pkg" diff --git a/man/man1/mise.1 b/man/man1/mise.1 index e6ce1f0b97..751233f103 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -937,7 +937,7 @@ only. .PP .TP \fB\-m, \-\-manager\fR \fI\fR -Only install packages for this manager, e.g. `apt`, `brew`, or `brew\-cask` +Only install packages for this manager, e.g. `apt`, `brew`, `brew\-cask`, or `mas` .TP \fB\-n, \-\-dry\-run\fR Print the commands that would run without running them @@ -971,9 +971,10 @@ Upgrade installed bootstrap packages from `[bootstrap.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, and brew\-cask installs -the current cask artifact. Packages that are not installed yet are skipped -— use `mise bootstrap packages install` for those. +formula's current bottle and replaces the old keg, brew\-cask installs +the current cask artifact, and mas upgrades App Store apps. Packages that +are not installed yet are skipped — use `mise bootstrap packages install` +for those. Packages can also be given explicitly in `manager:package` form. .PP @@ -983,7 +984,7 @@ Packages can also be given explicitly in `manager:package` form. .PP .TP \fB\-m, \-\-manager\fR \fI\fR -Only upgrade packages for this manager, e.g. `apt`, `brew`, or `brew\-cask` +Only upgrade packages for this manager, e.g. `apt`, `brew`, `brew\-cask`, or `mas` .TP \fB\-n, \-\-dry\-run\fR Print the commands that would run without running them @@ -1006,7 +1007,7 @@ Versions are pinned with `@`: `mise bootstrap packages use apt:curl@8.5.0\-2`. W `@` (or with `@latest`) no pin is written. brew formulae and casks version through their names instead (for example `brew:postgresql@17`, `brew\-cask:temurin@17`), where `@` is part of the Homebrew name rather than -a mise version selector. +a mise version selector. mas uses numeric ADAM IDs and does not support pins. .PP \fBUsage:\fR mise bootstrap packages use [OPTIONS] ... .PP diff --git a/mise.usage.kdl b/mise.usage.kdl index 5b74380e9b..bec8e408f5 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -401,14 +401,14 @@ only. Examples: $ mise bootstrap packages install - $ mise bootstrap packages install apt:curl brew:jq brew-cask:firefox + $ mise bootstrap packages install apt:curl brew:jq brew-cask:firefox mas:497799835 $ mise bootstrap packages install --dry-run $ mise bootstrap packages install --manager apt --yes """# - flag "-m --manager" help="Only install packages for this manager, e.g. `apt`, `brew`, or `brew-cask`" { + flag "-m --manager" help="Only install packages for this manager, e.g. `apt`, `brew`, `brew-cask`, or `mas`" { arg { - choices apt brew brew-cask dnf pacman + choices apt brew brew-cask dnf mas pacman } } flag "-n --dry-run" help="Print the commands that would run without running them" @@ -437,9 +437,10 @@ Upgrade installed bootstrap packages from `[bootstrap.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, and brew-cask installs -the current cask artifact. Packages that are not installed yet are skipped -— use `mise bootstrap packages install` for those. +formula's current bottle and replaces the old keg, brew-cask installs +the current cask artifact, and mas upgrades App Store apps. Packages that +are not installed yet are skipped — use `mise bootstrap packages install` +for those. Packages can also be given explicitly in `manager:package` form. """# @@ -449,13 +450,14 @@ Examples: $ mise bootstrap packages upgrade $ mise bootstrap packages upgrade brew:postgresql@17 $ mise bootstrap packages upgrade --manager brew-cask + $ mise bootstrap packages upgrade --manager mas $ mise bootstrap packages upgrade --manager apt --yes $ mise bootstrap packages upgrade --dry-run """# - flag "-m --manager" help="Only upgrade packages for this manager, e.g. `apt`, `brew`, or `brew-cask`" { + flag "-m --manager" help="Only upgrade packages for this manager, e.g. `apt`, `brew`, `brew-cask`, or `mas`" { arg { - choices apt brew brew-cask dnf pacman + choices apt brew brew-cask dnf mas pacman } } flag "-n --dry-run" help="Print the commands that would run without running them" @@ -475,12 +477,12 @@ Versions are pinned with `@`: `mise bootstrap packages use apt:curl@8.5.0-2`. Wi `@` (or with `@latest`) no pin is written. brew formulae and casks version through their names instead (for example `brew:postgresql@17`, `brew-cask:temurin@17`), where `@` is part of the Homebrew name rather than -a mise version selector. +a mise version selector. mas uses numeric ADAM IDs and does not support pins. """# after_long_help #""" Examples: - $ mise bootstrap packages use apt:curl brew:jq brew-cask:firefox + $ mise bootstrap packages use apt:curl brew:jq brew-cask:firefox mas:497799835 $ mise bootstrap packages use -g brew:postgresql@17 $ mise bootstrap packages use apt:curl@8.5.0-2 diff --git a/src/cli/system/install.rs b/src/cli/system/install.rs index 076da85155..28afc06289 100644 --- a/src/cli/system/install.rs +++ b/src/cli/system/install.rs @@ -22,8 +22,8 @@ pub struct SystemInstall { #[clap(value_name = "PACKAGE")] packages: Vec, - /// Only install packages for this manager, e.g. `apt`, `brew`, or `brew-cask` - #[clap(long, short, value_parser = ["apt", "brew", "brew-cask", "dnf", "pacman"])] + /// Only install packages for this manager, e.g. `apt`, `brew`, `brew-cask`, or `mas` + #[clap(long, short, value_parser = ["apt", "brew", "brew-cask", "dnf", "mas", "pacman"])] manager: Option, /// Print the commands that would run without running them @@ -195,7 +195,7 @@ static AFTER_LONG_HELP: &str = color_print::cstr!( r#"Examples: $ mise bootstrap packages install - $ mise bootstrap packages install apt:curl brew:jq brew-cask:firefox + $ mise bootstrap packages install apt:curl brew:jq brew-cask:firefox mas:497799835 $ mise bootstrap packages install --dry-run $ mise bootstrap packages install --manager apt --yes "# diff --git a/src/cli/system/upgrade.rs b/src/cli/system/upgrade.rs index 5140259c78..d648971493 100644 --- a/src/cli/system/upgrade.rs +++ b/src/cli/system/upgrade.rs @@ -9,9 +9,10 @@ use crate::system; /// 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, and brew-cask installs -/// the current cask artifact. Packages that are not installed yet are skipped -/// — use `mise bootstrap packages install` for those. +/// formula's current bottle and replaces the old keg, brew-cask installs +/// the current cask artifact, and mas upgrades App Store apps. Packages that +/// are not installed yet are skipped — use `mise bootstrap packages install` +/// for those. /// /// Packages can also be given explicitly in `manager:package` form. #[derive(Debug, clap::Args)] @@ -22,8 +23,8 @@ pub struct SystemUpgrade { #[clap(value_name = "PACKAGE")] packages: Vec, - /// Only upgrade packages for this manager, e.g. `apt`, `brew`, or `brew-cask` - #[clap(long, short, value_parser = ["apt", "brew", "brew-cask", "dnf", "pacman"])] + /// Only upgrade packages for this manager, e.g. `apt`, `brew`, `brew-cask`, or `mas` + #[clap(long, short, value_parser = ["apt", "brew", "brew-cask", "dnf", "mas", "pacman"])] manager: Option, /// Print the commands that would run without running them @@ -64,6 +65,7 @@ static AFTER_LONG_HELP: &str = color_print::cstr!( $ mise bootstrap packages upgrade $ mise bootstrap packages upgrade brew:postgresql@17 $ mise bootstrap packages upgrade --manager brew-cask + $ mise bootstrap packages upgrade --manager mas $ mise bootstrap packages upgrade --manager apt --yes $ mise bootstrap packages upgrade --dry-run "# diff --git a/src/cli/system/use.rs b/src/cli/system/use.rs index a823dc0cc7..c1ff21b6c3 100644 --- a/src/cli/system/use.rs +++ b/src/cli/system/use.rs @@ -21,7 +21,7 @@ use crate::system::packages::PackageRequest; /// `@` (or with `@latest`) no pin is written. brew formulae and casks /// version through their names instead (for example `brew:postgresql@17`, /// `brew-cask:temurin@17`), where `@` is part of the Homebrew name rather than -/// a mise version selector. +/// a mise version selector. mas uses numeric ADAM IDs and does not support pins. #[derive(Debug, clap::Args)] #[clap(visible_alias = "u", verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] pub struct SystemUse { @@ -141,7 +141,7 @@ impl SystemUse { static AFTER_LONG_HELP: &str = color_print::cstr!( r#"Examples: - $ mise bootstrap packages use apt:curl brew:jq brew-cask:firefox + $ mise bootstrap packages use apt:curl brew:jq brew-cask:firefox mas:497799835 $ mise bootstrap packages use -g brew:postgresql@17 $ mise bootstrap packages use apt:curl@8.5.0-2 "# diff --git a/src/system/mod.rs b/src/system/mod.rs index e7da79d53b..4b9328c327 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -126,14 +126,16 @@ pub fn parse_spec(spec: &str) -> eyre::Result<(String, String)> { /// pin. brew and brew-cask are exempt from `@` parsing: `@` is part of /// Homebrew names (`postgresql@17` — that name IS brew's versioning /// mechanism), and bottles/casks can't be installed at a pinned version -/// anyway. +/// anyway. mas uses numeric ADAM IDs only. pub fn parse_use_spec(spec: &str) -> eyre::Result<(String, PackageRequest)> { let (mgr, rest) = parse_spec(spec)?; - if is_brew_manager(&mgr) { + let rest = normalize_use_spec_package_name(&mgr, &rest)?; + validate_package_name(&mgr, rest)?; + if is_opaque_package_manager(&mgr) { return Ok(( mgr, PackageRequest { - name: rest, + name: rest.to_string(), version: None, tap_url: None, }, @@ -154,7 +156,7 @@ pub fn parse_use_spec(spec: &str) -> eyre::Result<(String, PackageRequest)> { None => Ok(( mgr, PackageRequest { - name: rest, + name: rest.to_string(), version: None, tap_url: None, }, @@ -218,6 +220,10 @@ fn packages_from_config_files_with_brew_taps( match parse_spec(&spec) { Ok((mgr, name)) => { let version = (version != "latest").then_some(version); + if let Err(err) = validate_package_name(&mgr, &name) { + warn!("[bootstrap.packages]: {err}"); + continue; + } let tap_url = if is_brew_manager(&mgr) { brew_tap_name(&name).and_then(|tap| brew_taps.get(tap).cloned()) } else { @@ -358,6 +364,7 @@ pub fn packages_from_specs_with_config( let mut by_mgr: IndexMap> = IndexMap::new(); for spec in specs { let (mgr, name) = parse_spec(spec)?; + validate_package_name(&mgr, &name)?; let tap_url = if is_brew_manager(&mgr) { brew_tap_name(&name).and_then(|tap| brew_taps.get(tap).cloned()) } else { @@ -395,6 +402,29 @@ fn is_brew_manager(mgr: &str) -> bool { matches!(mgr, "brew" | "brew-cask") } +fn is_opaque_package_manager(mgr: &str) -> bool { + is_brew_manager(mgr) || mgr == "mas" +} + +fn normalize_use_spec_package_name<'a>(mgr: &str, name: &'a str) -> eyre::Result<&'a str> { + if mgr == "mas" + && let Some(name) = name.strip_suffix("@latest") + { + if name.is_empty() { + bail!("invalid system package spec: expected ':[@version]'"); + } + return Ok(name); + } + Ok(name) +} + +fn validate_package_name(mgr: &str, name: &str) -> eyre::Result<()> { + if mgr == "mas" && !packages::mas::is_adam_id(name) { + bail!("mas app IDs must be numeric ADAM IDs (e.g. \"mas:497799835\")"); + } + Ok(()) +} + fn brew_taps_from_config(config: &Config) -> IndexMap { let mut brew_taps: IndexMap = IndexMap::new(); for cf in config.config_files.values().rev() { @@ -487,6 +517,19 @@ mod tests { assert_eq!(req.name, "temurin@17"); assert_eq!(req.version, None); + let (mgr, req) = parse_use_spec("mas:497799835").unwrap(); + assert_eq!(mgr, "mas"); + assert_eq!(req.name, "497799835"); + assert_eq!(req.version, None); + + let (mgr, req) = parse_use_spec("mas:497799835@latest").unwrap(); + assert_eq!(mgr, "mas"); + assert_eq!(req.name, "497799835"); + assert_eq!(req.version, None); + + assert!(parse_use_spec("mas:com.example.App").is_err()); + assert!(parse_use_spec("mas:497799835@1").is_err()); + assert!(parse_use_spec("apt:curl@").is_err()); assert!(parse_use_spec("noprefix").is_err()); } diff --git a/src/system/packages/mas.rs b/src/system/packages/mas.rs new file mode 100644 index 0000000000..6c88f44286 --- /dev/null +++ b/src/system/packages/mas.rs @@ -0,0 +1,374 @@ +use std::collections::HashMap; +use std::process::Stdio; + +use async_trait::async_trait; +use eyre::{Result as EyreResult, bail, eyre}; +use serde_json::Value; + +use super::{InstallOpts, PackageRequest, PackageState, PackageStatus, SystemPackageManager}; +use crate::result::Result; + +/// Mac App Store apps via the `mas` CLI. +pub struct MasManager {} + +impl MasManager { + pub fn new() -> Self { + Self {} + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InstalledApp { + adam_id: Option, + bundle_id: Option, + version: String, +} + +fn value_string(value: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + let value = value.get(*key)?; + match value { + Value::String(s) if !s.is_empty() => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + _ => None, + } + }) +} + +fn parse_mas_json_value(value: &Value) -> Option { + let adam_id = value_string( + value, + &[ + "adamID", "adamId", "adam_id", "appID", "appId", "app_id", "id", "trackID", "trackId", + ], + ); + let bundle_id = value_string( + value, + &[ + "bundleID", + "bundleId", + "bundle_id", + "bundleIdentifier", + "bundle_identifier", + ], + ); + let version = value_string( + value, + &[ + "version", + "currentVersion", + "current_version", + "versionString", + ], + )?; + Some(InstalledApp { + adam_id, + bundle_id, + version, + }) +} + +fn parse_mas_json(output: &str) -> EyreResult> { + let output = output.trim(); + if output.is_empty() { + return Ok(vec![]); + } + if let Ok(Value::Array(values)) = serde_json::from_str::(output) { + let apps: Vec<_> = values.iter().filter_map(parse_mas_json_value).collect(); + if apps.is_empty() && !values.is_empty() { + bail!("mas list --json returned no parseable app objects"); + } + return Ok(apps); + } + let mut apps = vec![]; + for line in output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + let value = serde_json::from_str::(line) + .map_err(|err| eyre!("mas list --json returned invalid JSON: {err}"))?; + match parse_mas_json_value(&value) { + Some(app) => apps.push(app), + None => bail!("mas list --json returned an app object without an ID and version"), + } + } + Ok(apps) +} + +fn parse_mas_text(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let (adam_id, rest) = line.trim().split_once(char::is_whitespace)?; + if !adam_id.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let version = rest + .rsplit_once('(') + .and_then(|(_, v)| v.strip_suffix(')')) + .unwrap_or("") + .trim(); + if version.is_empty() { + return None; + } + Some(InstalledApp { + adam_id: Some(adam_id.to_string()), + bundle_id: None, + version: version.to_string(), + }) + }) + .collect() +} + +fn statuses_from_apps(apps: &[InstalledApp], requests: &[PackageRequest]) -> Vec { + let mut installed: HashMap = HashMap::new(); + for app in apps { + if let Some(adam_id) = &app.adam_id { + installed.insert(adam_id.clone(), app.version.clone()); + } + if let Some(bundle_id) = &app.bundle_id { + installed.insert(bundle_id.clone(), app.version.clone()); + } + } + requests + .iter() + .map(|req| { + let state = match installed.get(&req.name) { + Some(version) => match &req.version { + Some(requested) if version != requested => PackageState::VersionMismatch { + installed: version.clone(), + }, + _ => PackageState::Installed { + version: version.clone(), + }, + }, + None => PackageState::Missing, + }; + PackageStatus { + request: req.clone(), + state, + } + }) + .collect() +} + +async fn mas_list() -> Result> { + debug!("$ mas list --json"); + let json_output = tokio::process::Command::new("mas") + .args(["list", "--json"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + let json_err = if json_output.status.success() { + let stdout = String::from_utf8_lossy(&json_output.stdout); + match parse_mas_json(&stdout) { + Ok(apps) => return Ok(apps), + Err(err) => { + debug!("mas list --json parse failed: {err}"); + Some(err.to_string()) + } + } + } else { + None + }; + + debug!("$ mas list"); + let text_output = tokio::process::Command::new("mas") + .arg("list") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + if !text_output.status.success() { + let json_stderr = String::from_utf8_lossy(&json_output.stderr); + let text_stderr = String::from_utf8_lossy(&text_output.stderr); + bail!( + "mas list failed: {}", + if text_stderr.trim().is_empty() { + json_err.as_deref().unwrap_or_else(|| json_stderr.trim()) + } else { + text_stderr.trim() + } + ); + } + let stdout = String::from_utf8_lossy(&text_output.stdout); + Ok(parse_mas_text(&stdout)) +} + +#[async_trait(?Send)] +impl SystemPackageManager for MasManager { + fn name(&self) -> &'static str { + "mas" + } + + fn is_available(&self) -> bool { + cfg!(target_os = "macos") && crate::file::which("mas").is_some() + } + + fn unavailable_reason(&self) -> String { + if cfg!(target_os = "macos") { + "mas not found".to_string() + } else { + "only available on macos".to_string() + } + } + + fn supports_version_pins(&self) -> bool { + false + } + + async fn installed(&self, pkgs: &[PackageRequest]) -> Result> { + let apps = mas_list().await?; + Ok(statuses_from_apps(&apps, pkgs)) + } + + async fn install(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> { + if let Some(p) = pkgs.iter().find(|p| p.version.is_some()) { + bail!("mas cannot install a pinned version ('{p}')"); + } + if let Some(p) = pkgs.iter().find(|p| !is_adam_id(&p.name)) { + bail!("mas install requires a numeric ADAM ID ('{p}'); use `mas search` to find it"); + } + let mut args = vec!["install".to_string()]; + args.extend(pkgs.iter().map(|p| p.name.clone())); + if opts.dry_run { + miseprintln!("mas {}", args.join(" ")); + return Ok(()); + } + debug!("$ mas {}", args.join(" ")); + let output = tokio::process::Command::new("mas") + .args(&args) + .stdin(Stdio::null()) + .output() + .await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("mas install failed: {}", stderr.trim()); + } + Ok(()) + } + + async fn upgrade(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> { + if let Some(p) = pkgs.iter().find(|p| !is_adam_id(&p.name)) { + bail!("mas upgrade requires a numeric ADAM ID ('{p}'); use `mas search` to find it"); + } + let mut args = vec!["upgrade".to_string()]; + args.extend(pkgs.iter().map(|p| p.name.clone())); + if opts.dry_run { + miseprintln!("mas {}", args.join(" ")); + return Ok(()); + } + debug!("$ mas {}", args.join(" ")); + let output = tokio::process::Command::new("mas") + .args(&args) + .stdin(Stdio::null()) + .output() + .await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("mas upgrade failed: {}", stderr.trim()); + } + Ok(()) + } +} + +pub(crate) fn is_adam_id(name: &str) -> bool { + !name.is_empty() && name.chars().all(|c| c.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn req(name: &str, version: Option<&str>) -> PackageRequest { + PackageRequest { + name: name.to_string(), + version: version.map(str::to_string), + tap_url: None, + } + } + + #[test] + fn test_parse_mas_json_lines() { + let apps = parse_mas_json( + r#"{"adamID":497799835,"bundleID":"com.apple.dt.Xcode","version":"16.2"} +{"adamID":"409203825","bundleID":"com.apple.Numbers","version":"14.4"}"#, + ) + .unwrap(); + assert_eq!(apps.len(), 2); + let statuses = statuses_from_apps( + &apps, + &[ + req("497799835", None), + req("com.apple.Numbers", None), + req("missing", None), + req("com.apple.dt.Xcode", Some("15.0")), + ], + ); + assert_eq!( + statuses[0].state, + PackageState::Installed { + version: "16.2".to_string() + } + ); + assert_eq!( + statuses[1].state, + PackageState::Installed { + version: "14.4".to_string() + } + ); + assert_eq!(statuses[2].state, PackageState::Missing); + assert_eq!( + statuses[3].state, + PackageState::VersionMismatch { + installed: "16.2".to_string() + } + ); + } + + #[test] + fn test_parse_mas_json_array() { + let apps = parse_mas_json( + r#"[{"id":497799835,"bundleIdentifier":"com.apple.dt.Xcode","version":"16.2"}]"#, + ) + .unwrap(); + assert_eq!( + apps, + vec![InstalledApp { + adam_id: Some("497799835".to_string()), + bundle_id: Some("com.apple.dt.Xcode".to_string()), + version: "16.2".to_string() + }] + ); + } + + #[test] + fn test_parse_mas_text() { + let apps = parse_mas_text("497799835 Xcode (16.2)\n409203825 Numbers (14.4)\n"); + assert_eq!( + apps[0], + InstalledApp { + adam_id: Some("497799835".to_string()), + bundle_id: None, + version: "16.2".to_string() + } + ); + } + + #[test] + fn test_parse_mas_json_rejects_invalid_json() { + assert!(parse_mas_json("not json").is_err()); + assert!(parse_mas_json(r#"[{"adamID":497799835}]"#).is_err()); + } + + #[test] + fn test_is_adam_id() { + assert!(is_adam_id("497799835")); + assert!(!is_adam_id("")); + assert!(!is_adam_id("com.apple.dt.Xcode")); + } +} diff --git a/src/system/packages/mod.rs b/src/system/packages/mod.rs index e9c445d80f..271c9b0946 100644 --- a/src/system/packages/mod.rs +++ b/src/system/packages/mod.rs @@ -1,4 +1,4 @@ -//! System package managers (apt, brew, brew-cask) for the `[bootstrap.packages]` config section. +//! System package managers (apt, brew, brew-cask, mas) for the `[bootstrap.packages]` config section. //! //! These are machine-global, unversioned packages — deliberately separate from //! the `Backend` system, which manages per-project, version-pinned dev tools. @@ -13,6 +13,7 @@ pub mod apt; #[cfg(unix)] pub mod brew; pub mod dnf; +pub mod mas; pub mod pacman; /// A single package entry from `[bootstrap.packages]` — the part after the @@ -117,6 +118,7 @@ pub fn all_managers() -> Vec> { #[cfg(unix)] Arc::new(brew::BrewCaskManager::new()), Arc::new(dnf::DnfManager::new()), + Arc::new(mas::MasManager::new()), Arc::new(pacman::PacmanManager::new()), ] } diff --git a/xtasks/fig/src/mise.ts b/xtasks/fig/src/mise.ts index 05748b9aa6..e14c35e987 100644 --- a/xtasks/fig/src/mise.ts +++ b/xtasks/fig/src/mise.ts @@ -867,11 +867,18 @@ const completionSpec: Fig.Spec = { { name: ["-m", "--manager"], description: - "Only install packages for this manager, e.g. `apt`, `brew`, or `brew-cask`", + "Only install packages for this manager, e.g. `apt`, `brew`, `brew-cask`, or `mas`", isRepeatable: false, args: { name: "manager", - suggestions: ["apt", "brew", "brew-cask", "dnf", "pacman"], + suggestions: [ + "apt", + "brew", + "brew-cask", + "dnf", + "mas", + "pacman", + ], }, }, { @@ -926,11 +933,18 @@ const completionSpec: Fig.Spec = { { name: ["-m", "--manager"], description: - "Only upgrade packages for this manager, e.g. `apt`, `brew`, or `brew-cask`", + "Only upgrade packages for this manager, e.g. `apt`, `brew`, `brew-cask`, or `mas`", isRepeatable: false, args: { name: "manager", - suggestions: ["apt", "brew", "brew-cask", "dnf", "pacman"], + suggestions: [ + "apt", + "brew", + "brew-cask", + "dnf", + "mas", + "pacman", + ], }, }, {