From 7e075eaabc4adc2cc4e711d5478f639d4da9fa68 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:38:08 +0000 Subject: [PATCH 1/2] feat(system): add [system.defaults] for declarative macOS defaults Extends the [system] bootstrapping section with declarative macOS user defaults, following the same semantics as [system.packages]: additive merge across the config hierarchy, OS-filtered (inert off-macOS), and only ever applied by an explicit `mise system install`. [system.defaults."com.apple.dock"] autohide = true tilesize = 48 Values map to typed `defaults write` flags (-bool/-int/-float/-string); drift is detected with `defaults read-type`/`defaults read` and shown by `mise system status` (set/differs/unset) and `mise doctor`. Types compare strictly: integer 1 does not satisfy a configured `true`. Unsupported plist shapes (arrays, dicts) parse fine for forward compatibility but warn and are skipped. Co-Authored-By: Claude Fable 5 --- docs/.vitepress/config.ts | 4 + docs/cli/system.md | 6 +- docs/cli/system/install.md | 9 +- docs/cli/system/status.md | 5 +- docs/system-packages/defaults.md | 102 ++++++++++ docs/system-packages/index.md | 4 + e2e/cli/test_system_defaults | 49 +++++ man/man1/mise.1 | 20 +- mise.usage.kdl | 32 +++- schema/mise.json | 19 +- src/cli/doctor/mod.rs | 85 +++++++++ src/cli/system/install.rs | 67 ++++++- src/cli/system/mod.rs | 6 +- src/cli/system/status.rs | 92 ++++++++- src/config/config_file/mise_toml.rs | 44 +++++ src/system/defaults.rs | 281 ++++++++++++++++++++++++++++ src/system/mod.rs | 54 +++++- xtasks/fig/src/mise.ts | 8 +- 18 files changed, 839 insertions(+), 48 deletions(-) create mode 100644 docs/system-packages/defaults.md create mode 100644 e2e/cli/test_system_defaults create mode 100644 src/system/defaults.rs diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 8fa33a4a30..c402705880 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -97,6 +97,10 @@ export default withMermaid( { text: "dnf", link: "/system-packages/dnf" }, { text: "pacman", link: "/system-packages/pacman" }, { text: "brew", link: "/system-packages/brew" }, + { + text: "macOS Defaults", + link: "/system-packages/defaults", + }, ], }, { diff --git a/docs/cli/system.md b/docs/cli/system.md index 582c43faf6..1461fa55de 100644 --- a/docs/cli/system.md +++ b/docs/cli/system.md @@ -4,12 +4,14 @@ - **Usage**: `mise system ` - **Source code**: [`src/cli/system/mod.rs`](https://github.com/jdx/mise/blob/main/src/cli/system/mod.rs) -[experimental] Manage system packages from `[system.packages]` +[experimental] Manage system packages from `[system.packages]` and macOS +defaults from `[system.defaults]` System packages are machine-global packages installed by the OS package manager (apt, dnf, pacman) or mise's Homebrew-bottle installer (brew). +macOS defaults are user preferences written with `defaults write`. Unlike `[tools]`, they are not version-pinned per-project and are only -ever installed when explicitly requested with `mise system install`. +ever applied when explicitly requested with `mise system install`. ## Subcommands diff --git a/docs/cli/system/install.md b/docs/cli/system/install.md index a71ec1a5d3..ebef963678 100644 --- a/docs/cli/system/install.md +++ b/docs/cli/system/install.md @@ -5,15 +5,18 @@ - **Aliases**: `i` - **Source code**: [`src/cli/system/install.rs`](https://github.com/jdx/mise/blob/main/src/cli/system/install.rs) -Install missing system packages from `[system.packages]` +Install missing system packages from `[system.packages]` and apply macOS +defaults from `[system.defaults]` Checks which configured packages are missing and installs them with the system package manager. This may elevate with sudo when not running as -root (see the `system_packages.sudo` setting). +root (see the `system_packages.sudo` setting). On macOS, also writes any +`[system.defaults]` entries that are unset or differ from the config. Packages can also be given explicitly in `manager:package` form (e.g. `apt:curl`, `brew:jq`); they are installed whether or not they appear in -the config. +the config. Explicit packages and `--manager` scope the run to packages +only. ## Arguments diff --git a/docs/cli/system/status.md b/docs/cli/system/status.md index 818ea8ae4c..a17a9b3d55 100644 --- a/docs/cli/system/status.md +++ b/docs/cli/system/status.md @@ -5,7 +5,8 @@ - **Aliases**: `ls` - **Source code**: [`src/cli/system/status.rs`](https://github.com/jdx/mise/blob/main/src/cli/system/status.rs) -Show the status of system packages from `[system.packages]` +Show the status of system packages from `[system.packages]` and macOS +defaults from `[system.defaults]` ## Flags @@ -15,7 +16,7 @@ Output in JSON format ### `--missing` -Exit with code 1 if any configured packages are missing +Exit with code 1 if any configured packages are missing or defaults are out of sync Examples: diff --git a/docs/system-packages/defaults.md b/docs/system-packages/defaults.md new file mode 100644 index 0000000000..8c46dcfe59 --- /dev/null +++ b/docs/system-packages/defaults.md @@ -0,0 +1,102 @@ +# macOS Defaults + +mise can declare macOS user defaults (preferences) in the +`[system.defaults]` section of `mise.toml` and apply them with +`mise system install`: + +```toml +[system.defaults.NSGlobalDomain] +KeyRepeat = 2 +InitialKeyRepeat = 15 +ApplePressAndHoldEnabled = false + +[system.defaults."com.apple.dock"] +autohide = true +tilesize = 48 +orientation = "left" + +[system.defaults."com.apple.finder"] +ShowPathbar = true +AppleShowAllFiles = true +``` + +Each `[system.defaults.""]` table holds the keys for one preferences +domain — quote domains containing dots. Values map to the matching +`defaults write` type: + +| TOML value | written as | example | +| ---------- | ------------------ | ---------------------- | +| boolean | `-bool true/false` | `autohide = true` | +| integer | `-int ` | `tilesize = 48` | +| float | `-float ` | `scale = 1.5` | +| string | `-string ` | `orientation = "left"` | + +Other plist shapes (arrays, dicts, dates, data) are not supported; entries +using them parse fine but are skipped with a warning, so configs written for +newer mise versions still work. + +## Semantics + +`[system.defaults]` follows the same rules as +[`[system.packages]`](/system-packages/): + +- **Declarative and additive** — (domain, key) pairs merge across the + [config hierarchy](/configuration.html) (global → project) as a union; a + more local config overrides the value of a pair the global config declared + but cannot remove it. mise never deletes a default. +- **OS-filtered** — on anything other than macOS the section is inert: + `mise system status` and `mise doctor` list the entries as skipped (so + nothing is silently invisible) and `mise system install` ignores them, so + a shared config authored for both Linux and macOS just works. +- **Manual application only** — mise never writes defaults implicitly; only + `mise system install` does, after the usual confirmation prompt. +- **Strictly typed** — an existing value only counts as in sync when both + the value and the plist type match: an integer `1` does not satisfy a + configured `true`. `mise system install` converges it to the typed value. + +User defaults are per-user, so unlike system packages no sudo is ever +involved. Host-scoped preferences (`defaults -currentHost`) and `sudo +defaults` system domains are not supported. + +## Commands + +```sh +mise system status # shows defaults drift next to package status +mise system status --missing # exit 1 if anything is unset or differs + +mise system install # writes unset/differing defaults (prompts first) +mise system install --dry-run # print the `defaults write` commands instead +mise system install --yes # skip the confirmation prompt +``` + +`mise system status` reports each entry as `set` (matches), `differs` (a +value exists but doesn't match — the current value is shown), or `unset`. +`mise doctor` summarizes the same drift. + +Note that explicit package arguments and `--manager` scope +`mise system install` to packages only — defaults are applied by the bare +converge-everything form. + +## App restarts + +Some applications only pick up changed defaults after a relaunch — mise +prints a reminder after writing. The usual suspects: + +```sh +killall Dock +killall Finder +killall SystemUIServer +``` + +mise deliberately does not kill applications itself. + +## Finding keys + +To discover a setting's domain and key, change it in System Settings and +diff the output of `defaults read` before and after, or read a domain +directly: + +```sh +defaults read com.apple.dock +defaults read-type com.apple.dock tilesize +``` diff --git a/docs/system-packages/index.md b/docs/system-packages/index.md index 4dcfa889d4..983675be0d 100644 --- a/docs/system-packages/index.md +++ b/docs/system-packages/index.md @@ -24,6 +24,10 @@ itself. Use them for shared libraries and build dependencies that dev tools need (`libssl-dev`, `postgresql`, `ffmpeg`), not for the dev tools themselves — those belong in `[tools]`. +The `[system]` section can also declare +[macOS defaults](/system-packages/defaults.html) (`[system.defaults]`), +applied by the same `mise system install` command. + ## Supported package managers | Manager | Platform | Page | diff --git a/e2e/cli/test_system_defaults b/e2e/cli/test_system_defaults new file mode 100644 index 0000000000..637927ff31 --- /dev/null +++ b/e2e/cli/test_system_defaults @@ -0,0 +1,49 @@ +#!/usr/bin/env bash + +cat <mise.toml +[system.defaults.NSGlobalDomain] +KeyRepeat = 2 + +[system.defaults."com.apple.dock"] +autohide = true +tilesize = 48 +orientation = "left" +EOF + +# status renders on any platform; on non-macOS entries are skipped, not errors +assert_succeed "mise system status" +assert_contains "mise system status" "com.apple.dock" +assert_contains "mise system status --json" '"defaults"' +if [[ $(uname) != "Darwin" ]]; then + assert_contains "mise system status" "skipped" + assert_contains "mise system status --json" '"available": false' + # unavailable entries don't count as missing (cross-platform configs) + assert_succeed "mise system status --missing" + # install skips defaults silently off-macOS + assert_succeed "mise system install --yes" +fi + +# dry-run never writes anything +assert_succeed "mise system install --dry-run --yes" + +# unsupported value types warn but don't fail +cat <mise.toml +[system.defaults."com.apple.dock"] +future-array = [1, 2] +EOF +assert_succeed "mise system status" +assert_contains "mise system status 2>&1" "unsupported value type" + +# a domain entry that isn't a table warns but doesn't fail +cat <mise.toml +[system.defaults] +autohide = true +EOF +assert_succeed "mise system status" +assert_contains "mise system status 2>&1" "expected a table" + +# empty [system.defaults] section +cat <mise.toml +[system.defaults] +EOF +assert_succeed "mise system status" diff --git a/man/man1/mise.1 b/man/man1/mise.1 index 752a9c9281..6a81d05121 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -458,16 +458,16 @@ Symlinks all tool versions from an external tool into mise Symlinks all ruby tool versions from an external tool into mise .TP \fBsystem\fR -[experimental] Manage system packages from `[system.packages]` +[experimental] Manage system packages from `[system.packages]` and macOS .TP \fBsystem install\fR -Install missing system packages from `[system.packages]` +Install missing system packages from `[system.packages]` and apply macOS .RS \fIAliases: \fRi .RE .TP \fBsystem status\fR -Show the status of system packages from `[system.packages]` +Show the status of system packages from `[system.packages]` and macOS .RS \fIAliases: \fRls .RE @@ -2745,15 +2745,18 @@ Symlinks all ruby tool versions from an external tool into mise \fB\-\-brew\fR Get tool versions from Homebrew .SH "MISE SYSTEM INSTALL" -Install missing system packages from `[system.packages]` +Install missing system packages from `[system.packages]` and apply macOS +defaults from `[system.defaults]` Checks which configured packages are missing and installs them with the system package manager. This may elevate with sudo when not running as -root (see the `system_packages.sudo` setting). +root (see the `system_packages.sudo` setting). On macOS, also writes any +`[system.defaults]` entries that are unset or differ from the config. Packages can also be given explicitly in `manager:package` form (e.g. `apt:curl`, `brew:jq`); they are installed whether or not they appear in -the config. +the config. Explicit packages and `\-\-manager` scope the run to packages +only. .PP \fBUsage:\fR mise system install [OPTIONS] [] ... .PP @@ -2777,7 +2780,8 @@ Refresh package manager metadata first (apt: `apt\-get update`) \fB\fR Packages in `manager:package` form; defaults to everything configured in [system.packages] .SH "MISE SYSTEM STATUS" -Show the status of system packages from `[system.packages]` +Show the status of system packages from `[system.packages]` and macOS +defaults from `[system.defaults]` .PP \fBUsage:\fR mise system status [OPTIONS] .PP @@ -2788,7 +2792,7 @@ Show the status of system packages from `[system.packages]` Output in JSON format .TP \fB\-\-missing\fR -Exit with code 1 if any configured packages are missing +Exit with code 1 if any configured packages are missing or defaults are out of sync .SH "MISE SYSTEM UPGRADE" Upgrade installed system packages from `[system.packages]` diff --git a/mise.usage.kdl b/mise.usage.kdl index a35456a67c..a350a4d8f1 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -2849,27 +2849,38 @@ Examples: flag --brew help="Get tool versions from Homebrew" } } -cmd system subcommand_required=#true help="[experimental] Manage system packages from `[system.packages]`" { +cmd system subcommand_required=#true help=#""" +[experimental] Manage system packages from `[system.packages]` and macOS +defaults from `[system.defaults]` +"""# { long_help #""" -[experimental] Manage system packages from `[system.packages]` +[experimental] Manage system packages from `[system.packages]` and macOS +defaults from `[system.defaults]` System packages are machine-global packages installed by the OS package manager (apt, dnf, pacman) or mise's Homebrew-bottle installer (brew). +macOS defaults are user preferences written with `defaults write`. Unlike `[tools]`, they are not version-pinned per-project and are only -ever installed when explicitly requested with `mise system install`. +ever applied when explicitly requested with `mise system install`. """# - cmd install help="Install missing system packages from `[system.packages]`" { + cmd install help=#""" +Install missing system packages from `[system.packages]` and apply macOS +defaults from `[system.defaults]` +"""# { alias i long_help #""" -Install missing system packages from `[system.packages]` +Install missing system packages from `[system.packages]` and apply macOS +defaults from `[system.defaults]` Checks which configured packages are missing and installs them with the system package manager. This may elevate with sudo when not running as -root (see the `system_packages.sudo` setting). +root (see the `system_packages.sudo` setting). On macOS, also writes any +`[system.defaults]` entries that are unset or differ from the config. Packages can also be given explicitly in `manager:package` form (e.g. `apt:curl`, `brew:jq`); they are installed whether or not they appear in -the config. +the config. Explicit packages and `--manager` scope the run to packages +only. """# after_long_help #""" Examples: @@ -2890,7 +2901,10 @@ Examples: flag --update help="Refresh package manager metadata first (apt: `apt-get update`)" arg "[PACKAGE]…" help="Packages in `manager:package` form; defaults to everything configured in [system.packages]" required=#false var=#true } - cmd status help="Show the status of system packages from `[system.packages]`" { + cmd status help=#""" +Show the status of system packages from `[system.packages]` and macOS +defaults from `[system.defaults]` +"""# { alias ls after_long_help #""" Examples: @@ -2901,7 +2915,7 @@ Examples: """# flag "-J --json" help="Output in JSON format" - flag --missing help="Exit with code 1 if any configured packages are missing" + flag --missing help="Exit with code 1 if any configured packages are missing or defaults are out of sync" } cmd upgrade help="Upgrade installed system packages from `[system.packages]`" { alias up diff --git a/schema/mise.json b/schema/mise.json index b1fd484283..c32f34faab 100644 --- a/schema/mise.json +++ b/schema/mise.json @@ -2959,7 +2959,7 @@ }, "system": { "type": "object", - "description": "[experimental] machine-global bootstrapping (system packages)", + "description": "[experimental] machine-global bootstrapping (system packages, macOS defaults)", "properties": { "packages": { "type": "object", @@ -2971,6 +2971,23 @@ "type": "string", "description": "version pin in the manager's native format, or \"latest\"" } + }, + "defaults": { + "type": "object", + "description": "macOS user defaults to apply with `mise system install`, keyed by preferences domain (e.g. \"com.apple.dock\", \"NSGlobalDomain\")", + "additionalProperties": { + "type": "object", + "description": "defaults keys and their desired values for this domain", + "additionalProperties": { + "anyOf": [ + { "type": "boolean" }, + { "type": "integer" }, + { "type": "number" }, + { "type": "string" } + ], + "description": "desired value, written with the matching `defaults write` type (-bool, -int, -float, -string)" + } + } } } }, diff --git a/src/cli/doctor/mod.rs b/src/cli/doctor/mod.rs index 4c23f5961d..2822c73a32 100644 --- a/src/cli/doctor/mod.rs +++ b/src/cli/doctor/mod.rs @@ -195,6 +195,10 @@ impl Doctor { data.insert("system_packages".into(), system_packages); } + if let Some(system_defaults) = self.system_defaults_json(&config).await { + data.insert("system_defaults".into(), system_defaults); + } + if !self.errors.is_empty() { data.insert("errors".into(), self.errors.clone().into_iter().collect()); } @@ -365,6 +369,7 @@ impl Doctor { } self.analyze_system_packages(config).await?; + self.analyze_system_defaults(config).await?; Ok(()) } @@ -432,6 +437,86 @@ impl Doctor { Some(map.into()) } + /// same diagnostics as [`Self::analyze_system_defaults`] for `doctor -J` + async fn system_defaults_json(&mut self, config: &Arc) -> Option { + if !Settings::get().experimental { + return None; + } + let defaults = crate::system::defaults_from_config(config); + if defaults.is_empty() { + return None; + } + if !crate::system::defaults::is_available() { + return Some(serde_json::json!({ + "available": false, + "reason": crate::system::defaults::unavailable_reason(), + "requested": defaults.len(), + })); + } + match crate::system::defaults::status(&defaults).await { + Ok(statuses) => { + let out_of_sync = statuses + .iter() + .filter(|s| s.state != crate::system::defaults::DefaultsState::Set) + .count(); + if out_of_sync > 0 { + self.warnings.push(format!( + "{out_of_sync} macOS default(s) are out of sync, apply them with `mise system install`" + )); + } + Some(serde_json::json!({ + "available": true, + "requested": statuses.len(), + "out_of_sync": out_of_sync, + })) + } + Err(err) => { + self.warnings + .push(format!("failed to check macOS defaults: {err}")); + None + } + } + } + + async fn analyze_system_defaults(&mut self, config: &Arc) -> eyre::Result<()> { + if !Settings::get().experimental { + return Ok(()); + } + let defaults = crate::system::defaults_from_config(config); + if defaults.is_empty() { + return Ok(()); + } + let line = if !crate::system::defaults::is_available() { + format!( + "unavailable ({}), {} entry(ies) skipped", + crate::system::defaults::unavailable_reason(), + defaults.len() + ) + } else { + match crate::system::defaults::status(&defaults).await { + Ok(statuses) => { + let out_of_sync = statuses + .iter() + .filter(|s| s.state != crate::system::defaults::DefaultsState::Set) + .count(); + if out_of_sync > 0 { + self.warnings.push(format!( + "{out_of_sync} macOS default(s) are out of sync, apply them with `mise system install`" + )); + } + format!("{} requested, {out_of_sync} out of sync", statuses.len()) + } + Err(err) => { + self.warnings + .push(format!("failed to check macOS defaults: {err}")); + return Ok(()); + } + } + }; + info::section("system_defaults", line)?; + Ok(()) + } + async fn analyze_system_packages(&mut self, config: &Arc) -> eyre::Result<()> { if !Settings::get().experimental { return Ok(()); diff --git a/src/cli/system/install.rs b/src/cli/system/install.rs index 9e12c3e6e5..39378f59d9 100644 --- a/src/cli/system/install.rs +++ b/src/cli/system/install.rs @@ -4,15 +4,18 @@ use super::driver::{self, Action, DriverOpts}; use crate::config::{Config, Settings}; use crate::system; -/// Install missing system packages from `[system.packages]` +/// Install missing system packages from `[system.packages]` and apply macOS +/// defaults from `[system.defaults]` /// /// Checks which configured packages are missing and installs them with the /// system package manager. This may elevate with sudo when not running as -/// root (see the `system_packages.sudo` setting). +/// root (see the `system_packages.sudo` setting). On macOS, also writes any +/// `[system.defaults]` entries that are unset or differ from the config. /// /// Packages can also be given explicitly in `manager:package` form (e.g. /// `apt:curl`, `brew:jq`); they are installed whether or not they appear in -/// the config. +/// the config. Explicit packages and `--manager` scope the run to packages +/// only. #[derive(Debug, clap::Args)] #[clap(visible_alias = "i", verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] pub struct SystemInstall { @@ -41,20 +44,74 @@ pub struct SystemInstall { impl SystemInstall { pub async fn run(self) -> Result<()> { Settings::get().ensure_experimental("mise system")?; + // defaults only participate in the full converge-everything form — + // explicit package specs and --manager filters scope the run to + // packages + let mut defaults = vec![]; let mgrs = if self.packages.is_empty() { let config = Config::get().await?; + if self.manager.is_none() { + defaults = system::defaults_from_config(&config); + } system::packages_from_config(&config) } else { system::packages_from_specs(&self.packages)? }; let opts = DriverOpts { - manager: self.manager, + manager: self.manager.clone(), explicit: !self.packages.is_empty(), dry_run: self.dry_run, update: self.update, yes: self.yes, }; - driver::run(mgrs, Action::Install, &opts).await + // when only defaults are configured, skip the driver so it doesn't + // print "no system packages configured" + if !mgrs.is_empty() || defaults.is_empty() { + driver::run(mgrs, Action::Install, &opts).await?; + } + self.apply_defaults(defaults).await + } + + async fn apply_defaults(&self, defaults: Vec) -> Result<()> { + use crate::system::defaults::{self, DefaultsState}; + if defaults.is_empty() { + return Ok(()); + } + if !defaults::is_available() { + // cross-platform config: [system.defaults] is simply inert off-macOS + debug!("defaults: skipping, {}", defaults::unavailable_reason()); + return Ok(()); + } + let statuses = defaults::status(&defaults).await?; + let targets: Vec<_> = statuses + .iter() + .filter(|s| s.state != DefaultsState::Set) + .map(|s| s.request.clone()) + .collect(); + let set = statuses.len() - targets.len(); + if set > 0 { + info!("defaults: {set} value(s) already set"); + } + if targets.is_empty() { + return Ok(()); + } + let list = targets.iter().map(|r| r.to_string()).collect::>(); + if !self.dry_run && !self.yes && console::user_attended_stderr() { + let msg = format!("defaults: write {}?", list.join(", ")); + if !crate::ui::prompt::confirm(msg)? { + info!("defaults: skipped"); + return Ok(()); + } + } + defaults::apply(&targets, self.dry_run).await?; + if !self.dry_run { + info!( + "defaults: wrote {} — some apps only pick up changes after a relaunch \ + (e.g. `killall Dock`)", + list.join(", ") + ); + } + Ok(()) } } diff --git a/src/cli/system/mod.rs b/src/cli/system/mod.rs index 3d02836a28..d0d3b53705 100644 --- a/src/cli/system/mod.rs +++ b/src/cli/system/mod.rs @@ -8,12 +8,14 @@ mod upgrade; #[path = "use.rs"] mod r#use; -/// [experimental] Manage system packages from `[system.packages]` +/// [experimental] Manage system packages from `[system.packages]` and macOS +/// defaults from `[system.defaults]` /// /// System packages are machine-global packages installed by the OS package /// manager (apt, dnf, pacman) or mise's Homebrew-bottle installer (brew). +/// macOS defaults are user preferences written with `defaults write`. /// Unlike `[tools]`, they are not version-pinned per-project and are only -/// ever installed when explicitly requested with `mise system install`. +/// ever applied when explicitly requested with `mise system install`. #[derive(Debug, clap::Args)] #[clap(verbatim_doc_comment)] pub struct System { diff --git a/src/cli/system/status.rs b/src/cli/system/status.rs index 65fdd9c4a1..802727450e 100644 --- a/src/cli/system/status.rs +++ b/src/cli/system/status.rs @@ -3,10 +3,12 @@ use serde_json::json; use crate::config::{Config, Settings}; use crate::system; +use crate::system::defaults::DefaultsState; use crate::system::packages::PackageState; use crate::ui::table::MiseTable; -/// Show the status of system packages from `[system.packages]` +/// Show the status of system packages from `[system.packages]` and macOS +/// defaults from `[system.defaults]` #[derive(Debug, clap::Args)] #[clap(visible_alias = "ls", verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] pub struct SystemStatus { @@ -14,7 +16,8 @@ pub struct SystemStatus { #[clap(long, short = 'J')] json: bool, - /// Exit with code 1 if any configured packages are missing + /// Exit with code 1 if any configured packages are missing or defaults + /// are out of sync #[clap(long)] missing: bool, } @@ -89,16 +92,89 @@ impl SystemStatus { ); } } + let defaults = system::defaults_from_config(&config); + let mut defaults_rows: Vec> = vec![]; + if !defaults.is_empty() { + if !system::defaults::is_available() { + let reason = system::defaults::unavailable_reason(); + if self.json { + json_out.insert( + "defaults".to_string(), + json!({ "available": false, "reason": reason }), + ); + } else { + for req in &defaults { + defaults_rows.push(vec![ + req.domain.clone(), + req.key.clone(), + req.value.to_string(), + "".to_string(), + format!("skipped ({reason})"), + ]); + } + } + } else { + let statuses = system::defaults::status(&defaults).await?; + let mut json_entries = vec![]; + for s in statuses { + let (current, state) = match &s.state { + DefaultsState::Set => (s.request.value.to_string(), "set"), + DefaultsState::Differs { current } => { + any_missing = true; + (current.clone(), "differs") + } + DefaultsState::Unset => { + any_missing = true; + ("".to_string(), "unset") + } + }; + if self.json { + json_entries.push(json!({ + "domain": s.request.domain, + "key": s.request.key, + "value": s.request.value.to_json(), + "current": current, + "state": state, + })); + } else { + defaults_rows.push(vec![ + s.request.domain.clone(), + s.request.key.clone(), + s.request.value.to_string(), + current, + state.to_string(), + ]); + } + } + if self.json { + json_out.insert( + "defaults".to_string(), + json!({ "available": true, "entries": json_entries }), + ); + } + } + } if self.json { miseprintln!("{}", serde_json::to_string_pretty(&json_out)?); - } else if rows.is_empty() { - info!("no system packages configured in [system.packages]"); + } else if rows.is_empty() && defaults_rows.is_empty() { + info!("nothing configured in [system.packages] or [system.defaults]"); } else { - let mut table = MiseTable::new(false, &["Manager", "Package", "Installed", "State"]); - for row in rows { - table.add_row(row); + if !rows.is_empty() { + let mut table = + MiseTable::new(false, &["Manager", "Package", "Installed", "State"]); + for row in rows { + table.add_row(row); + } + table.print()?; + } + if !defaults_rows.is_empty() { + let mut table = + MiseTable::new(false, &["Domain", "Key", "Value", "Current", "State"]); + for row in defaults_rows { + table.add_row(row); + } + table.print()?; } - table.print()?; } if self.missing && any_missing { crate::exit(1); diff --git a/src/config/config_file/mise_toml.rs b/src/config/config_file/mise_toml.rs index c9dfa7b94f..3e18b9b4d6 100644 --- a/src/config/config_file/mise_toml.rs +++ b/src/config/config_file/mise_toml.rs @@ -2078,6 +2078,50 @@ mod tests { file::remove_file(&p).unwrap(); } + #[tokio::test] + async fn test_system_defaults() { + let _config = Config::get().await.unwrap(); + let p = CWD.as_ref().unwrap().join(".test.mise.toml"); + file::write( + &p, + r#" + [system.defaults.NSGlobalDomain] + KeyRepeat = 2 + ApplePressAndHoldEnabled = false + + [system.defaults."com.apple.dock"] + autohide = true + tilesize = 48 + magnification-scale = 1.5 + orientation = "left" + # unsupported shapes still parse (forward compatibility) + future-array = [1, 2] + "#, + ) + .unwrap(); + let cf = MiseToml::from_file(&p).unwrap(); + let system = cf.system_config().unwrap(); + let global = system.defaults.get("NSGlobalDomain").unwrap(); + assert_eq!(global.get("KeyRepeat").unwrap(), &toml::Value::Integer(2)); + assert_eq!( + global.get("ApplePressAndHoldEnabled").unwrap(), + &toml::Value::Boolean(false) + ); + let dock = system.defaults.get("com.apple.dock").unwrap(); + assert_eq!(dock.get("autohide").unwrap(), &toml::Value::Boolean(true)); + assert_eq!(dock.get("tilesize").unwrap(), &toml::Value::Integer(48)); + assert_eq!( + dock.get("magnification-scale").unwrap(), + &toml::Value::Float(1.5) + ); + assert_eq!( + dock.get("orientation").unwrap(), + &toml::Value::String("left".into()) + ); + assert!(dock.get("future-array").unwrap().is_array()); + file::remove_file(&p).unwrap(); + } + #[tokio::test] async fn test_update_system_package() { let _config = Config::get().await.unwrap(); diff --git a/src/system/defaults.rs b/src/system/defaults.rs new file mode 100644 index 0000000000..274df7a4d4 --- /dev/null +++ b/src/system/defaults.rs @@ -0,0 +1,281 @@ +//! macOS user defaults (preferences) for the `[system.defaults]` config section. +//! +//! Entries are written with `defaults write <-type> ` +//! and checked with `defaults read-type`/`defaults read`. Like +//! `[system.packages]` they are machine-global, declarative, and only ever +//! applied when explicitly requested with `mise system install`. + +use std::process::Stdio; + +use crate::result::Result; + +/// A single `[system.defaults.]` entry: `key = value` +#[derive(Debug, Clone, PartialEq)] +pub struct DefaultsRequest { + /// preferences domain, e.g. "com.apple.dock" or "NSGlobalDomain" + pub domain: String, + pub key: String, + pub value: DefaultsValue, +} + +impl std::fmt::Display for DefaultsRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {} = {}", self.domain, self.key, self.value) + } +} + +/// The value types `defaults write` can set and mise can verify. Other plist +/// types (arrays, dicts, dates, data) are not supported — config entries with +/// those TOML types warn and are skipped. +#[derive(Debug, Clone, PartialEq)] +pub enum DefaultsValue { + Bool(bool), + Int(i64), + Float(f64), + Str(String), +} + +impl DefaultsValue { + pub fn from_toml(value: &toml::Value) -> Option { + match value { + toml::Value::Boolean(b) => Some(Self::Bool(*b)), + toml::Value::Integer(i) => Some(Self::Int(*i)), + toml::Value::Float(f) => Some(Self::Float(*f)), + toml::Value::String(s) => Some(Self::Str(s.clone())), + _ => None, + } + } + + /// type+value arguments for `defaults write ...` + pub fn write_args(&self) -> Vec { + match self { + Self::Bool(b) => vec!["-bool".into(), b.to_string()], + Self::Int(i) => vec!["-int".into(), i.to_string()], + Self::Float(f) => vec!["-float".into(), f.to_string()], + Self::Str(s) => vec!["-string".into(), s.clone()], + } + } + + pub fn to_json(&self) -> serde_json::Value { + match self { + Self::Bool(b) => (*b).into(), + Self::Int(i) => (*i).into(), + Self::Float(f) => (*f).into(), + Self::Str(s) => s.clone().into(), + } + } + + /// Does the pair from `defaults read-type` ("boolean", "integer", ...) + /// and `defaults read` (raw value; booleans print as 1/0) match this + /// value? Types are compared strictly: an integer 1 does not satisfy a + /// configured `true` — `mise system install` converges it to the typed + /// value. + fn matches(&self, read_type: &str, raw: &str) -> bool { + match self { + Self::Bool(b) => read_type == "boolean" && raw == if *b { "1" } else { "0" }, + Self::Int(i) => read_type == "integer" && raw.parse::() == Ok(*i), + Self::Float(f) => { + read_type == "float" && raw.parse::().is_ok_and(|v| (v - f).abs() < 1e-9) + } + Self::Str(s) => read_type == "string" && raw == s, + } + } +} + +impl std::fmt::Display for DefaultsValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Bool(b) => write!(f, "{b}"), + Self::Int(i) => write!(f, "{i}"), + Self::Float(v) => write!(f, "{v}"), + Self::Str(s) => write!(f, "{s}"), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DefaultsState { + /// current value matches the config + Set, + /// a value exists but differs from the config (in value or type) + Differs { current: String }, + /// the key is not set in this domain + Unset, +} + +#[derive(Debug, Clone)] +pub struct DefaultsStatus { + pub request: DefaultsRequest, + pub state: DefaultsState, +} + +pub fn is_available() -> bool { + cfg!(target_os = "macos") && crate::file::which("defaults").is_some() +} + +pub fn unavailable_reason() -> String { + if cfg!(target_os = "macos") { + "`defaults` not found".to_string() + } else { + "only available on macos".to_string() + } +} + +/// Query the current state of each entry. Side-effect free. +pub async fn status(requests: &[DefaultsRequest]) -> Result> { + let mut out = vec![]; + for req in requests { + let state = match read(&req.domain, &req.key).await? { + Some((read_type, raw)) => { + if req.value.matches(&read_type, &raw) { + DefaultsState::Set + } else { + // call out a type mismatch when the raw value alone + // would look identical to the configured one + let current = if raw == req.value.to_string() { + format!("{raw} ({read_type})") + } else { + raw + }; + DefaultsState::Differs { current } + } + } + None => DefaultsState::Unset, + }; + out.push(DefaultsStatus { + request: req.clone(), + state, + }); + } + Ok(out) +} + +/// Write the given entries (already filtered to unset/differing ones) +pub async fn apply(requests: &[DefaultsRequest], dry_run: bool) -> Result<()> { + for req in requests { + let mut args = vec!["write".to_string(), req.domain.clone(), req.key.clone()]; + args.extend(req.value.write_args()); + if dry_run { + miseprintln!("defaults {}", args.join(" ")); + continue; + } + debug!("$ defaults {}", args.join(" ")); + let output = tokio::process::Command::new("defaults") + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + if !output.status.success() { + eyre::bail!( + "`defaults {}` failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + } + Ok(()) +} + +/// `defaults read-type` + `defaults read` for one key. Returns +/// `(type, raw value)`, or None when the key (or domain) does not exist — +/// both commands exit non-zero for that, which is not an error here. +async fn read(domain: &str, key: &str) -> Result> { + let Some(read_type) = defaults_cmd(&["read-type", domain, key]).await? else { + return Ok(None); + }; + // "Type is boolean" -> "boolean" + let read_type = read_type + .strip_prefix("Type is ") + .unwrap_or(&read_type) + .to_string(); + let Some(raw) = defaults_cmd(&["read", domain, key]).await? else { + return Ok(None); + }; + Ok(Some((read_type, raw))) +} + +async fn defaults_cmd(args: &[&str]) -> Result> { + debug!("$ defaults {}", args.join(" ")); + let output = tokio::process::Command::new("defaults") + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + if !output.status.success() { + return Ok(None); + } + Ok(Some( + String::from_utf8_lossy(&output.stdout).trim().to_string(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn val(s: &str) -> toml::Value { + s.parse().unwrap() + } + + #[test] + fn test_from_toml() { + assert_eq!( + DefaultsValue::from_toml(&val("true")), + Some(DefaultsValue::Bool(true)) + ); + assert_eq!( + DefaultsValue::from_toml(&val("48")), + Some(DefaultsValue::Int(48)) + ); + assert_eq!( + DefaultsValue::from_toml(&val("1.5")), + Some(DefaultsValue::Float(1.5)) + ); + assert_eq!( + DefaultsValue::from_toml(&val(r#""right""#)), + Some(DefaultsValue::Str("right".into())) + ); + + // unsupported plist shapes are None -> warned + skipped by the caller + assert_eq!(DefaultsValue::from_toml(&val("[1, 2]")), None); + assert_eq!(DefaultsValue::from_toml(&val("{ a = 1 }")), None); + } + + #[test] + fn test_write_args() { + assert_eq!(DefaultsValue::Bool(true).write_args(), ["-bool", "true"]); + assert_eq!(DefaultsValue::Bool(false).write_args(), ["-bool", "false"]); + assert_eq!(DefaultsValue::Int(2).write_args(), ["-int", "2"]); + assert_eq!(DefaultsValue::Float(0.5).write_args(), ["-float", "0.5"]); + assert_eq!( + DefaultsValue::Str("left".into()).write_args(), + ["-string", "left"] + ); + } + + #[test] + fn test_matches() { + // booleans read back as 1/0 + assert!(DefaultsValue::Bool(true).matches("boolean", "1")); + assert!(DefaultsValue::Bool(false).matches("boolean", "0")); + assert!(!DefaultsValue::Bool(true).matches("boolean", "0")); + // strict typing: integer 1 does not satisfy `true` + assert!(!DefaultsValue::Bool(true).matches("integer", "1")); + + assert!(DefaultsValue::Int(2).matches("integer", "2")); + assert!(!DefaultsValue::Int(2).matches("integer", "3")); + assert!(!DefaultsValue::Int(2).matches("float", "2")); + + // `defaults read` may print floats without a fraction + assert!(DefaultsValue::Float(48.0).matches("float", "48")); + assert!(DefaultsValue::Float(0.5).matches("float", "0.5")); + assert!(!DefaultsValue::Float(0.5).matches("float", "0.6")); + + assert!(DefaultsValue::Str("left".into()).matches("string", "left")); + assert!(!DefaultsValue::Str("left".into()).matches("string", "right")); + } +} diff --git a/src/system/mod.rs b/src/system/mod.rs index 65563ef41c..bd541d2efa 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -1,10 +1,11 @@ //! `[system]` config section: machine-global bootstrapping. //! //! Currently this is `[system.packages]` — declarative system packages -//! installed by `mise system install`. These are intentionally not part of -//! `[tools]`: they're unversioned, machine-global, and managed by the OS -//! package manager (or mise's own Homebrew-bottle installer), not mise's -//! per-project toolset. +//! installed by `mise system install` — and `[system.defaults]` — declarative +//! macOS user defaults applied by the same command. These are intentionally +//! not part of `[tools]`: they're unversioned, machine-global, and managed by +//! the OS package manager (or mise's own Homebrew-bottle installer) or macOS +//! `defaults`, not mise's per-project toolset. use std::sync::Arc; @@ -13,8 +14,10 @@ use indexmap::IndexMap; use serde::Deserialize; use crate::config::Config; +use crate::system::defaults::{DefaultsRequest, DefaultsValue}; use crate::system::packages::{PackageRequest, SystemPackageManager}; +pub mod defaults; pub mod packages; pub(crate) mod sudo; @@ -26,6 +29,12 @@ pub struct SystemTomlConfig { /// pacman, winget, ...) parse fine on older ones. #[serde(default)] pub packages: IndexMap, + /// `[system.defaults.]` -> key -> value. Values stay raw TOML so + /// shapes from newer mise versions (arrays, dicts) parse fine on older + /// ones; the domain level is also raw so a malformed section warns + /// instead of failing the whole config. + #[serde(default)] + pub defaults: IndexMap, } /// Packages for one manager, aggregated across the config hierarchy @@ -132,6 +141,43 @@ pub fn packages_from_config(config: &Config) -> Vec { resolve_managers(by_mgr, false).expect("non-strict resolve is infallible") } +/// Aggregate `[system.defaults]` across all loaded config files. +/// +/// (domain, key) pairs union global -> local; a more local config overrides +/// the value a global config declared. Unsupported value shapes warn +/// (forward compatibility) and are skipped. +pub fn defaults_from_config(config: &Config) -> Vec { + let mut merged: IndexMap<(String, String), toml::Value> = IndexMap::new(); + // config_files is ordered local -> global; reverse for global -> local + for cf in config.config_files.values().rev() { + if let Some(sys) = cf.system_config() { + for (domain, entries) in sys.defaults { + match entries { + toml::Value::Table(entries) => { + for (key, value) in entries { + merged.insert((domain.clone(), key), value); + } + } + _ => warn!( + "[system.defaults]: expected a table of key/value pairs for domain '{domain}'" + ), + } + } + } + } + let mut out = vec![]; + for ((domain, key), value) in merged { + match DefaultsValue::from_toml(&value) { + Some(value) => out.push(DefaultsRequest { domain, key, value }), + None => warn!( + "[system.defaults]: unsupported value type for {domain} {key} \ + (expected bool, integer, float, or string)" + ), + } + } + out +} + /// Build [`ManagerPackages`] from explicit CLI specs like `apt:curl`. /// Unlike the config path, malformed specs and unknown managers are hard /// errors. CLI specs carry no version pin — pins live in the config value. diff --git a/xtasks/fig/src/mise.ts b/xtasks/fig/src/mise.ts index acf8a8ebf3..5b1f8b7244 100644 --- a/xtasks/fig/src/mise.ts +++ b/xtasks/fig/src/mise.ts @@ -3213,12 +3213,12 @@ const completionSpec: Fig.Spec = { { name: "system", description: - "[experimental] Manage system packages from `[system.packages]`", + "[experimental] Manage system packages from `[system.packages]` and macOS\ndefaults from `[system.defaults]`", subcommands: [ { name: ["install", "i"], description: - "Install missing system packages from `[system.packages]`", + "Install missing system packages from `[system.packages]` and apply macOS\ndefaults from `[system.defaults]`", options: [ { name: ["-m", "--manager"], @@ -3259,7 +3259,7 @@ const completionSpec: Fig.Spec = { { name: ["status", "ls"], description: - "Show the status of system packages from `[system.packages]`", + "Show the status of system packages from `[system.packages]` and macOS\ndefaults from `[system.defaults]`", options: [ { name: ["-J", "--json"], @@ -3269,7 +3269,7 @@ const completionSpec: Fig.Spec = { { name: "--missing", description: - "Exit with code 1 if any configured packages are missing", + "Exit with code 1 if any configured packages are missing or defaults are out of sync", isRepeatable: false, }, ], From 63cfcb8bea896b4e6b3ce23be383bf89547397ff Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:05:37 +0000 Subject: [PATCH 2/2] fix(system): address review feedback for [system.defaults] - shell-quote dry-run/debug command output so printed `defaults write` commands are copy-pasteable when string values contain spaces - distinguish missing keys ("does not exist") from real `defaults` failures (cfprefsd unavailable, managed domains) instead of treating every non-zero exit as Unset - preserve leading/trailing spaces in string values read back from `defaults read` (strip only the trailing newline) - doctor: factor the defaults check into one helper shared by the text and JSON paths, and keep the system_defaults section visible (with the error) when the status check fails instead of dropping it - normalize schema/mise.json formatting from `mise run render` (CI lint) Co-Authored-By: Claude Fable 5 --- schema/mise.json | 16 ++++-- src/cli/doctor/mod.rs | 116 +++++++++++++++++++++++++---------------- src/system/defaults.rs | 32 ++++++++---- 3 files changed, 107 insertions(+), 57 deletions(-) diff --git a/schema/mise.json b/schema/mise.json index c32f34faab..aac78cb8b9 100644 --- a/schema/mise.json +++ b/schema/mise.json @@ -2980,10 +2980,18 @@ "description": "defaults keys and their desired values for this domain", "additionalProperties": { "anyOf": [ - { "type": "boolean" }, - { "type": "integer" }, - { "type": "number" }, - { "type": "string" } + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + } ], "description": "desired value, written with the matching `defaults write` type (-bool, -int, -float, -string)" } diff --git a/src/cli/doctor/mod.rs b/src/cli/doctor/mod.rs index 2822c73a32..8016964e15 100644 --- a/src/cli/doctor/mod.rs +++ b/src/cli/doctor/mod.rs @@ -48,6 +48,22 @@ pub enum Commands { Path(path::Path), } +/// outcome of the `[system.defaults]` doctor check +enum SystemDefaultsDiagnosis { + Unavailable { + requested: usize, + reason: String, + }, + Checked { + requested: usize, + out_of_sync: usize, + }, + CheckFailed { + requested: usize, + error: String, + }, +} + impl Doctor { pub async fn run(self) -> eyre::Result<()> { if let Some(cmd) = self.subcommand { @@ -437,8 +453,12 @@ impl Doctor { Some(map.into()) } - /// same diagnostics as [`Self::analyze_system_defaults`] for `doctor -J` - async fn system_defaults_json(&mut self, config: &Arc) -> Option { + /// Shared `[system.defaults]` check for the text and JSON doctor paths. + /// Pushes the relevant warnings; returns None when nothing is configured. + async fn check_system_defaults( + &mut self, + config: &Arc, + ) -> Option { if !Settings::get().experimental { return None; } @@ -446,12 +466,12 @@ impl Doctor { if defaults.is_empty() { return None; } + let requested = defaults.len(); if !crate::system::defaults::is_available() { - return Some(serde_json::json!({ - "available": false, - "reason": crate::system::defaults::unavailable_reason(), - "requested": defaults.len(), - })); + return Some(SystemDefaultsDiagnosis::Unavailable { + requested, + reason: crate::system::defaults::unavailable_reason(), + }); } match crate::system::defaults::status(&defaults).await { Ok(statuses) => { @@ -464,53 +484,61 @@ impl Doctor { "{out_of_sync} macOS default(s) are out of sync, apply them with `mise system install`" )); } - Some(serde_json::json!({ - "available": true, - "requested": statuses.len(), - "out_of_sync": out_of_sync, - })) + Some(SystemDefaultsDiagnosis::Checked { + requested, + out_of_sync, + }) } Err(err) => { self.warnings .push(format!("failed to check macOS defaults: {err}")); - None + Some(SystemDefaultsDiagnosis::CheckFailed { + requested, + error: err.to_string(), + }) } } } + /// same diagnostics as [`Self::analyze_system_defaults`] for `doctor -J` + async fn system_defaults_json(&mut self, config: &Arc) -> Option { + let json = match self.check_system_defaults(config).await? { + SystemDefaultsDiagnosis::Unavailable { requested, reason } => serde_json::json!({ + "available": false, + "reason": reason, + "requested": requested, + }), + SystemDefaultsDiagnosis::Checked { + requested, + out_of_sync, + } => serde_json::json!({ + "available": true, + "requested": requested, + "out_of_sync": out_of_sync, + }), + SystemDefaultsDiagnosis::CheckFailed { requested, error } => serde_json::json!({ + "available": true, + "requested": requested, + "error": error, + }), + }; + Some(json) + } + async fn analyze_system_defaults(&mut self, config: &Arc) -> eyre::Result<()> { - if !Settings::get().experimental { + let Some(diagnosis) = self.check_system_defaults(config).await else { return Ok(()); - } - let defaults = crate::system::defaults_from_config(config); - if defaults.is_empty() { - return Ok(()); - } - let line = if !crate::system::defaults::is_available() { - format!( - "unavailable ({}), {} entry(ies) skipped", - crate::system::defaults::unavailable_reason(), - defaults.len() - ) - } else { - match crate::system::defaults::status(&defaults).await { - Ok(statuses) => { - let out_of_sync = statuses - .iter() - .filter(|s| s.state != crate::system::defaults::DefaultsState::Set) - .count(); - if out_of_sync > 0 { - self.warnings.push(format!( - "{out_of_sync} macOS default(s) are out of sync, apply them with `mise system install`" - )); - } - format!("{} requested, {out_of_sync} out of sync", statuses.len()) - } - Err(err) => { - self.warnings - .push(format!("failed to check macOS defaults: {err}")); - return Ok(()); - } + }; + let line = match diagnosis { + SystemDefaultsDiagnosis::Unavailable { requested, reason } => { + format!("unavailable ({reason}), {requested} entry(ies) skipped") + } + SystemDefaultsDiagnosis::Checked { + requested, + out_of_sync, + } => format!("{requested} requested, {out_of_sync} out of sync"), + SystemDefaultsDiagnosis::CheckFailed { requested, error } => { + format!("{requested} requested, check failed: {error}") } }; info::section("system_defaults", line)?; diff --git a/src/system/defaults.rs b/src/system/defaults.rs index 274df7a4d4..62e890dc81 100644 --- a/src/system/defaults.rs +++ b/src/system/defaults.rs @@ -155,11 +155,14 @@ pub async fn apply(requests: &[DefaultsRequest], dry_run: bool) -> Result<()> { for req in requests { let mut args = vec!["write".to_string(), req.domain.clone(), req.key.clone()]; args.extend(req.value.write_args()); + // shell-quoted so the printed command is copy-pasteable even when a + // string value contains spaces + let display = shell_words::join(&args); if dry_run { - miseprintln!("defaults {}", args.join(" ")); + miseprintln!("defaults {display}"); continue; } - debug!("$ defaults {}", args.join(" ")); + debug!("$ defaults {display}"); let output = tokio::process::Command::new("defaults") .args(&args) .stdin(Stdio::null()) @@ -169,8 +172,7 @@ pub async fn apply(requests: &[DefaultsRequest], dry_run: bool) -> Result<()> { .await?; if !output.status.success() { eyre::bail!( - "`defaults {}` failed: {}", - args.join(" "), + "`defaults {display}` failed: {}", String::from_utf8_lossy(&output.stderr).trim() ); } @@ -197,7 +199,7 @@ async fn read(domain: &str, key: &str) -> Result> { } async fn defaults_cmd(args: &[&str]) -> Result> { - debug!("$ defaults {}", args.join(" ")); + debug!("$ defaults {}", shell_words::join(args)); let output = tokio::process::Command::new("defaults") .args(args) .stdin(Stdio::null()) @@ -206,11 +208,23 @@ async fn defaults_cmd(args: &[&str]) -> Result> { .output() .await?; if !output.status.success() { - return Ok(None); + // "does not exist" is the expected missing-key/-domain answer; any + // other failure (cfprefsd unavailable, managed domain, ...) must not + // masquerade as Unset + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("does not exist") { + return Ok(None); + } + eyre::bail!( + "`defaults {}` failed: {}", + shell_words::join(args), + stderr.trim() + ); } - Ok(Some( - String::from_utf8_lossy(&output.stdout).trim().to_string(), - )) + // strip only the trailing newline — leading/trailing spaces can be + // significant in string values + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(Some(stdout.trim_end_matches(['\r', '\n']).to_string())) } #[cfg(test)]