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",
+ ],
},
},
{