diff --git a/docs/.vitepress/cli_commands.ts b/docs/.vitepress/cli_commands.ts
index b569f6ce09..fae9ff2604 100644
--- a/docs/.vitepress/cli_commands.ts
+++ b/docs/.vitepress/cli_commands.ts
@@ -27,6 +27,17 @@ export const commands: { [key: string]: Command } = {
},
bootstrap: {
hide: false,
+ subcommands: {
+ "macos-defaults": {
+ hide: false,
+ },
+ packages: {
+ hide: false,
+ },
+ user: {
+ hide: false,
+ },
+ },
},
cache: {
hide: false,
@@ -101,6 +112,23 @@ export const commands: { [key: string]: Command } = {
},
},
},
+ dotfiles: {
+ hide: false,
+ subcommands: {
+ add: {
+ hide: false,
+ },
+ apply: {
+ hide: false,
+ },
+ edit: {
+ hide: false,
+ },
+ status: {
+ hide: false,
+ },
+ },
+ },
edit: {
hide: false,
},
@@ -316,26 +344,6 @@ export const commands: { [key: string]: Command } = {
},
},
},
- system: {
- hide: false,
- subcommands: {
- brew: {
- hide: false,
- },
- install: {
- hide: false,
- },
- status: {
- hide: false,
- },
- upgrade: {
- hide: false,
- },
- use: {
- hide: false,
- },
- },
- },
tasks: {
hide: false,
subcommands: {
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index a95907af42..ecc6aaaeec 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -137,34 +137,31 @@ export default withMermaid(
],
},
{
- text: "System (experimental)",
+ text: "Bootstrap (experimental)",
items: [
+ { text: "Overview", link: "/bootstrap" },
{
- text: "System Packages",
- link: "/system-packages/",
+ text: "Bootstrap Packages",
+ link: "/bootstrap/packages/",
collapsed: true,
items: [
- { text: "apt", link: "/system-packages/apt" },
- { text: "dnf", link: "/system-packages/dnf" },
- { text: "pacman", link: "/system-packages/pacman" },
- { text: "brew", link: "/system-packages/brew" },
+ { text: "apt", link: "/bootstrap/packages/apt" },
+ { text: "dnf", link: "/bootstrap/packages/dnf" },
+ { text: "pacman", link: "/bootstrap/packages/pacman" },
+ { text: "brew", link: "/bootstrap/packages/brew" },
],
},
{
- text: "System Files (dotfiles)",
- link: "/system-files",
- },
- {
- text: "System Edits",
- link: "/system-edits",
+ text: "Dotfiles",
+ link: "/dotfiles",
},
{
text: "macOS Defaults",
- link: "/system-packages/defaults",
+ link: "/bootstrap/macos-defaults",
},
{
- text: "mise bootstrap",
- link: "/cli/bootstrap",
+ text: "User Login Shell",
+ link: "/bootstrap/user",
},
],
},
diff --git a/docs/bootstrap.md b/docs/bootstrap.md
new file mode 100644
index 0000000000..8d75f10a29
--- /dev/null
+++ b/docs/bootstrap.md
@@ -0,0 +1,151 @@
+# Bootstrap
+
+`mise bootstrap` sets up the machine-level pieces around a mise config: OS
+packages, dotfiles, macOS defaults, the user's login shell, tools, and any
+final project-specific task.
+
+Use bootstrap for things that are needed before a project or workstation is
+ready, but that do not belong in `[tools]`: native libraries, Homebrew
+formulae, shell rc files, editor config, macOS preferences, and one-time
+machine setup.
+
+## How it runs
+
+`mise bootstrap` runs these steps in order:
+
+1. `mise bootstrap packages install` installs missing `[bootstrap.packages]`.
+2. `mise dotfiles apply` applies `[dotfiles]`.
+3. `mise bootstrap macos-defaults apply` writes `[bootstrap.macos.defaults]`.
+4. `mise bootstrap user apply` applies `[bootstrap.user]`.
+5. `mise install` installs missing `[tools]`.
+6. `mise run bootstrap` runs a task named `bootstrap`, if one exists.
+
+The declarative steps converge: if a package is already installed, a dotfile
+already matches, or a default is already set, mise skips it. The `bootstrap`
+task runs every time, so keep it idempotent.
+
+## Example
+
+```toml
+[bootstrap.packages]
+"apt:build-essential" = "latest"
+"brew:postgresql@17" = "latest"
+
+[dotfiles]
+"~/.gitconfig" = { mode = "symlink" }
+"~/.config/nvim" = { mode = "symlink" }
+"~/.zshrc/activate" = { block = 'eval "$(mise activate zsh)"' }
+
+[bootstrap.macos.defaults]
+"com.apple.dock" = { autohide = true }
+
+[bootstrap.user]
+login_shell = "/bin/zsh"
+
+[tools]
+node = "lts"
+python = "3.12"
+
+[tasks.bootstrap]
+run = "gh auth status || gh auth login"
+```
+
+Then run:
+
+```sh
+mise bootstrap --yes
+```
+
+For a dry run:
+
+```sh
+mise bootstrap --dry-run
+```
+
+## Inspecting State
+
+Use the narrower commands when you want to inspect one part of the bootstrap
+state:
+
+```sh
+mise bootstrap packages status
+mise dotfiles status
+mise dotfiles apply --dry-run
+mise dotfiles apply --dry-run --verbose
+mise bootstrap macos-defaults status
+mise bootstrap user status
+```
+
+`mise bootstrap packages status --missing` and `mise dotfiles status
+--missing` are useful CI checks when a repo expects machine setup to be in
+place but should not install anything during that check.
+
+## What Goes Where
+
+| Config | Use for |
+| ---------------------------- | ------------------------------------------------------------- |
+| `[bootstrap.packages]` | OS packages from apt, dnf, pacman, or brew |
+| `[dotfiles]` | Whole-file dotfiles and small managed edits to existing files |
+| `[bootstrap.macos.defaults]` | macOS user preferences written through `defaults write` |
+| `[bootstrap.user]` | Current-user settings such as `login_shell` |
+| `[tools]` | Versioned dev tools managed by mise |
+| `[tasks.bootstrap]` | Anything custom that should run after tools are installed |
+
+Use declarative sections when mise can inspect and converge the state. Use
+`[tasks.bootstrap]` for imperative setup that does not fit those sections,
+such as cloning a private repository, running an auth flow, or seeding local
+data.
+
+## Common Workflows
+
+### New Machine
+
+```sh
+mise trust
+mise bootstrap --yes
+```
+
+### Add A Package
+
+```sh
+mise bootstrap packages use apt:libssl-dev
+```
+
+This writes `[bootstrap.packages]` and installs what is missing.
+
+### Capture An Edited Dotfile
+
+```sh
+$EDITOR ~/.zshrc
+mise dotfiles add ~/.zshrc
+```
+
+`mise dotfiles add` stores the live file under `dotfiles.root` and writes an
+explicit `[dotfiles]` entry with `mode`.
+
+### Edit A Managed Dotfile
+
+```sh
+mise dotfiles edit ~/.zshrc
+mise dotfiles apply ~/.zshrc
+```
+
+For symlinked dotfiles, `edit` opens the managed source, so it works with the
+default `symlink` mode.
+
+## Advanced: Self-Managing Config
+
+You can manage the dotfiles repository and the mise global config as
+dotfiles:
+
+```toml
+[settings]
+dotfiles.root = "~/.dotfiles"
+
+[dotfiles]
+"~/.dotfiles" = "~/src/dotfiles"
+"~/.config/mise/config.toml" = "~/.dotfiles/mise/config.toml"
+```
+
+The repo/source must exist before the first apply. Replacing the active
+global config affects future mise invocations, so use this pattern carefully.
diff --git a/docs/system-packages/defaults.md b/docs/bootstrap/macos-defaults.md
similarity index 54%
rename from docs/system-packages/defaults.md
rename to docs/bootstrap/macos-defaults.md
index 8c46dcfe59..c9698be1a1 100644
--- a/docs/system-packages/defaults.md
+++ b/docs/bootstrap/macos-defaults.md
@@ -1,28 +1,18 @@
# macOS Defaults
mise can declare macOS user defaults (preferences) in the
-`[system.defaults]` section of `mise.toml` and apply them with
-`mise system install`:
+`[bootstrap.macos.defaults]` section of `mise.toml` and apply them with
+`mise bootstrap macos-defaults apply`:
```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
+[bootstrap.macos.defaults]
+NSGlobalDomain = { KeyRepeat = 2, InitialKeyRepeat = 15, ApplePressAndHoldEnabled = false }
+"com.apple.dock" = { autohide = true, tilesize = 48, orientation = "left" }
+"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:
+Each key under `[bootstrap.macos.defaults]` is a preferences domain. Quote
+domains containing dots. Values map to the matching `defaults write` type:
| TOML value | written as | example |
| ---------- | ------------------ | ---------------------- |
@@ -37,22 +27,25 @@ newer mise versions still work.
## Semantics
-`[system.defaults]` follows the same rules as
-[`[system.packages]`](/system-packages/):
+`[bootstrap.macos.defaults]` follows the same rules as
+[`[bootstrap.packages]`](/bootstrap/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.
+ `mise bootstrap macos-defaults status` and `mise doctor` list the entries
+ as skipped (so nothing is silently invisible) and
+ `mise bootstrap macos-defaults apply` 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.
+ `mise bootstrap macos-defaults apply` 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.
+ configured `true`. `mise bootstrap macos-defaults apply` 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
@@ -61,21 +54,17 @@ 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 bootstrap macos-defaults status # shows defaults drift
+mise bootstrap macos-defaults 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 bootstrap macos-defaults apply # writes unset/differing defaults
+mise bootstrap macos-defaults apply --dry-run # print the `defaults write` commands
+mise bootstrap macos-defaults apply --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.
+`mise bootstrap macos-defaults 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.
## App restarts
diff --git a/docs/system-packages/apt.md b/docs/bootstrap/packages/apt.md
similarity index 72%
rename from docs/system-packages/apt.md
rename to docs/bootstrap/packages/apt.md
index 6cdb3af806..d2ff23f328 100644
--- a/docs/system-packages/apt.md
+++ b/docs/bootstrap/packages/apt.md
@@ -3,7 +3,7 @@
System packages for Debian-family Linux (Debian, Ubuntu, Mint, ...).
```toml
-[system.packages]
+[bootstrap.packages]
"apt:libssl-dev" = "latest"
"apt:curl" = "8.5.0-2ubuntu10" # version pin
"apt:gcc:arm64" = "latest" # architecture qualifier
@@ -13,12 +13,12 @@ System packages for Debian-family Linux (Debian, Ubuntu, Mint, ...).
- Package state is checked with `dpkg-query` (read-only, never elevates).
- Missing packages are installed with `apt-get install -y`, elevated with
- sudo when necessary (see [sudo](/system-packages/index.html#sudo)).
+ sudo when necessary (see [sudo](/bootstrap/packages/#sudo)).
- Version pins are passed to apt as its native `name=version` syntax;
`name:arch` qualifiers pass through in the package name.
- `DEBIAN_FRONTEND=noninteractive` is set so installs never block on
configuration prompts.
-- `mise system upgrade` runs `apt-get update` and then
+- `mise bootstrap packages upgrade` runs `apt-get update` and then
`apt-get install --only-upgrade` for the configured packages, so nothing
not already installed gets pulled in.
@@ -30,13 +30,13 @@ touch apt metadata — if an install fails with "Unable to locate package",
refresh explicitly:
```sh
-mise system install --update
+mise bootstrap packages install --update
```
## Version pins
A pinned entry (`"apt:curl" = "8.5.0-2ubuntu10"`) shows as `version mismatch`
-in `mise system status` when a different version is installed, and
-`mise system install` passes the pin to apt to correct it. `"latest"` entries
-are satisfied by any installed version — use `mise system upgrade` to move
+in `mise bootstrap packages status` when a different version is installed, and
+`mise bootstrap packages install` passes the pin to apt to correct it. `"latest"` entries
+are satisfied by any installed version — use `mise bootstrap packages upgrade` to move
them to the newest available version.
diff --git a/docs/system-packages/brew.md b/docs/bootstrap/packages/brew.md
similarity index 94%
rename from docs/system-packages/brew.md
rename to docs/bootstrap/packages/brew.md
index d3646ec0ab..c2bfaa961e 100644
--- a/docs/system-packages/brew.md
+++ b/docs/bootstrap/packages/brew.md
@@ -4,7 +4,7 @@ Homebrew formulae from `homebrew/core` — **without requiring Homebrew to be
installed**.
```toml
-[system.packages]
+[bootstrap.packages]
"brew:postgresql@17" = "latest"
"brew:ffmpeg" = "latest"
"brew:imagemagick" = "latest"
@@ -25,7 +25,7 @@ formulae are delegated to a real `brew` command; use the same
fully-qualified formula name you would pass to `brew install`:
```toml
-[system.packages]
+[bootstrap.packages]
"brew:railwaycat/emacsmacport/emacs-mac" = "latest"
```
@@ -34,10 +34,10 @@ tap source. This mirrors `[plugins]`: the key is the tap name and the value
is the git URL.
```toml
-[system.brew.taps]
+[bootstrap.brew.taps]
"acme/tools" = "https://git.example.com/acme/homebrew-tools.git"
-[system.packages]
+[bootstrap.packages]
"brew:acme/tools/widget" = "latest"
```
@@ -49,9 +49,9 @@ You can also manage taps imperatively, matching `mise plugins install` /
modify `mise.toml`.
```sh
-mise system brew tap railwaycat/emacsmacport
-mise system brew tap acme/tools https://git.example.com/acme/homebrew-tools.git
-mise system brew untap acme/tools
+mise bootstrap packages brew tap railwaycat/emacsmacport
+mise bootstrap packages brew tap acme/tools https://git.example.com/acme/homebrew-tools.git
+mise bootstrap packages brew untap acme/tools
```
This exists because shared-library packages — postgres, ffmpeg, imagemagick,
@@ -155,7 +155,7 @@ gcc/make on Linux), exactly as they would under plain Homebrew.
## Upgrades
-`mise system upgrade` re-resolves the configured formulae against the
+`mise bootstrap packages upgrade` re-resolves the configured formulae against the
formulae.brew.sh API and pours any whose current version differs from the
linked keg — the new keg replaces the old one and the links are repointed,
the same dance `brew upgrade` does. Since bottles only exist for a formula's
@@ -175,7 +175,7 @@ operation.
with a clear error naming the unsupported feature.
- **Use canonical formula names.** `postgresql@17` is a formula name, not a
mise version pin — the API's current stable version decides what gets
- installed. Aliases (`postgres`) install correctly but `mise system status`
+ installed. Aliases (`postgres`) install correctly but `mise bootstrap packages status`
can't track them; mise warns and tells you the canonical name.
- `PATH` is up to you: `/bin` must be on `PATH` to use linked
binaries, just like with Homebrew itself.
diff --git a/docs/system-packages/dnf.md b/docs/bootstrap/packages/dnf.md
similarity index 77%
rename from docs/system-packages/dnf.md
rename to docs/bootstrap/packages/dnf.md
index 33147c1bd0..a054627fc5 100644
--- a/docs/system-packages/dnf.md
+++ b/docs/bootstrap/packages/dnf.md
@@ -4,7 +4,7 @@ System packages for RedHat-family Linux (Fedora, RHEL, CentOS Stream, Rocky,
Alma, ...).
```toml
-[system.packages]
+[bootstrap.packages]
"dnf:openssl-devel" = "latest"
"dnf:postgresql-server" = "latest"
"dnf:bash" = "5.2.26-3.fc40" # version or version-release pin
@@ -14,13 +14,13 @@ Alma, ...).
- Package state is checked with `rpm -q` (read-only, never elevates).
- Missing packages are installed with `dnf install -y`, elevated with sudo
- when necessary (see [sudo](/system-packages/index.html#sudo)).
+ when necessary (see [sudo](/bootstrap/packages/#sudo)).
- Version pins are passed to dnf as its native `name-version` /
`name-version-release` syntax; a version-only pin is satisfied by any
release of that version.
-- `mise system install --update` adds `--refresh` to force a metadata
+- `mise bootstrap packages install --update` adds `--refresh` to force a metadata
refresh; otherwise dnf manages its own metadata expiry.
-- `mise system upgrade` runs `dnf upgrade -y --refresh` for the configured
+- `mise bootstrap packages upgrade` runs `dnf upgrade -y --refresh` for the configured
packages — only already-installed packages are touched.
::: info
diff --git a/docs/system-packages/index.md b/docs/bootstrap/packages/index.md
similarity index 61%
rename from docs/system-packages/index.md
rename to docs/bootstrap/packages/index.md
index 901e6723ad..4d12c80d62 100644
--- a/docs/system-packages/index.md
+++ b/docs/bootstrap/packages/index.md
@@ -1,10 +1,10 @@
-# System Packages
+# Bootstrap Packages
mise can ensure machine-global system packages are installed via the
-`[system.packages]` section of `mise.toml`:
+`[bootstrap.packages]` section of `mise.toml`:
```toml
-[system.packages]
+[bootstrap.packages]
"apt:libssl-dev" = "latest"
"apt:build-essential" = "latest"
"brew:postgresql@17" = "latest"
@@ -16,9 +16,8 @@ and the value is a version: `"latest"` for whatever the manager installs, or
a pin in the manager's native format where supported (see the per-manager
pages).
-mise can also place machine-global config files (dotfiles) — see
-[System Files](/system-files.html), which follows the same rules and shares
-the same commands.
+mise can also place config files (dotfiles) — see
+[Dotfiles](/dotfiles.html), which uses `mise dotfiles` commands.
System packages are intentionally separate from [`[tools]`](/configuration.html):
they are not version-pinned per-project, do not get shims, and are installed
@@ -28,19 +27,20 @@ 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]`),
-[login shells](/system-login-shell.html) (`[system].login_shell`), applied
-by the same `mise system install` command.
+The `[bootstrap]` section can also declare
+[macOS defaults](/bootstrap/macos-defaults.html) (`[bootstrap.macos.defaults]`),
+applied by `mise bootstrap macos-defaults apply`. Current-user
+[login shells](/bootstrap/user.html) (`[bootstrap.user].login_shell`) are
+applied by `mise bootstrap user apply` or [`mise bootstrap`](/cli/bootstrap.html).
## Supported package managers
-| Manager | Platform | Page |
-| -------- | -------------------------------------------------------------- | -------------------------------------- |
-| `apt` | Debian, Ubuntu | [apt](/system-packages/apt.html) |
-| `dnf` | Fedora, RHEL, CentOS, Rocky, Alma | [dnf](/system-packages/dnf.html) |
-| `pacman` | Arch, Manjaro | [pacman](/system-packages/pacman.html) |
-| `brew` | macOS (arm64), Linux (x86_64/arm64) — **no Homebrew required** | [brew](/system-packages/brew.html) |
+| Manager | Platform | Page |
+| -------- | -------------------------------------------------------------- | ----------------------------------------- |
+| `apt` | Debian, Ubuntu | [apt](/bootstrap/packages/apt.html) |
+| `dnf` | Fedora, RHEL, CentOS, Rocky, Alma | [dnf](/bootstrap/packages/dnf.html) |
+| `pacman` | Arch, Manjaro | [pacman](/bootstrap/packages/pacman.html) |
+| `brew` | macOS (arm64), Linux (x86_64/arm64) — **no Homebrew required** | [brew](/bootstrap/packages/brew.html) |
## Semantics
@@ -51,59 +51,59 @@ by the same `mise system install` command.
- **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). `mise system status` and `mise doctor`
+ works on both macOS and Linux). `mise bootstrap packages status` and `mise doctor`
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 system install` ever installs anything.
+ missing, but only `mise bootstrap packages install` ever installs anything.
- **Unknown managers are ignored with a warning** so configs using managers
from newer mise versions still parse.
-For current-user login shell setup, use `[system].login_shell`:
+For current-user login shell setup, use `[bootstrap.user].login_shell`:
```toml
-[system]
+[bootstrap.user]
login_shell = "/bin/zsh"
```
-See [System Login Shell](/system-login-shell.html) for details.
+See [User Login Shell](/bootstrap/user.html) for details.
## Commands
```sh
-mise system status # table of requested vs installed packages
-mise system status --json # machine-readable
-mise system status --missing # exit 1 if anything is out of sync (CI check)
-
-mise system install # install whatever is missing (prompts first)
-mise system install apt:curl # install specific packages (configured or not)
-mise system install --dry-run # print the commands without running them
-mise system install --yes # skip the confirmation prompt
-mise system install --manager apt
-mise system install --update # refresh package manager metadata first
-
-mise system use apt:curl brew:jq # add to [system.packages] and install
-mise system use -g brew:ffmpeg # write to the global config instead
-mise system use apt:curl@8.5.0-2 # pin a version (brew pins via the
+mise bootstrap packages status # table of requested vs installed packages
+mise bootstrap packages status --json # machine-readable
+mise bootstrap packages status --missing # exit 1 if anything is out of sync (CI check)
+
+mise bootstrap packages install # install whatever is missing (prompts first)
+mise bootstrap packages install apt:curl # install specific packages (configured or not)
+mise bootstrap packages install --dry-run # print the commands without running them
+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 # add to [bootstrap.packages] and install
+mise bootstrap packages use -g brew:ffmpeg # write to the global config instead
+mise bootstrap packages use apt:curl@8.5.0-2 # pin a version (brew pins via the
# formula name: brew:postgresql@17)
-mise system upgrade # upgrade installed packages to current versions
-mise system upgrade --manager brew
+mise bootstrap packages upgrade # upgrade installed packages to current versions
+mise bootstrap packages upgrade --manager brew
```
-`mise system use` is `mise use` for system packages: it writes
+`mise bootstrap packages use` is `mise use` for system packages: it writes
`"manager:package" = "version"` entries to mise.toml (the local file by
default, the global one with `-g`) and installs whatever is missing. Entries
for managers that aren't available on the current machine are written without
installing — that's how a shared config picks up `apt:` lines authored on a
Mac.
-`mise system upgrade` refreshes package manager metadata and upgrades the
+`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 and brew
-[can't install pins](/system-packages/pacman.html), so pinned entries are
+[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 system install`'s job. For brew this pours the formula's current
+that's `mise bootstrap packages install`'s job. For brew this pours the formula's current
bottle and replaces the old keg.
`mise doctor` also reports configured system packages and warns when any are
@@ -134,8 +134,8 @@ OS.
The Linux package managers require root. When not running as root, mise
elevates with `sudo`, which prompts for your password as usual. The same
-sudo path is used when `[system].login_shell` needs to add a shell to
-`/etc/shells`, and it only happens during an explicit `mise system install`:
+sudo path is used when `[bootstrap.user].login_shell` needs to add a shell to
+`/etc/shells`, and it only happens during an explicit `mise bootstrap`:
- already root (containers, CI): no sudo, commands run directly
- interactive terminal: e.g. `sudo apt-get install ...` with a normal sudo
@@ -147,14 +147,14 @@ sudo path is used when `[system].login_shell` needs to add a shell to
Set [`system_packages.sudo = false`](/configuration/settings.html) to forbid
elevation entirely; mise will print the command for you to run yourself
instead. The `brew` manager never needs sudo except once to create
-`/opt/homebrew` (see [brew](/system-packages/brew.html)).
+`/opt/homebrew` (see [brew](/bootstrap/packages/brew.html)).
## CI usage
In containers you're typically already root, so no prompts occur:
```sh
-mise system install --yes
+mise bootstrap packages install --yes
mise install
```
@@ -162,5 +162,5 @@ mise install
named `bootstrap` afterwards, if one is defined) — one command to set up a
fresh machine or container.
-`mise system status --missing` exits 1 when packages are missing, which makes
+`mise bootstrap packages status --missing` exits 1 when packages are missing, which makes
a convenient CI check without installing anything.
diff --git a/docs/system-packages/pacman.md b/docs/bootstrap/packages/pacman.md
similarity index 74%
rename from docs/system-packages/pacman.md
rename to docs/bootstrap/packages/pacman.md
index 341799be38..cdfe5f4016 100644
--- a/docs/system-packages/pacman.md
+++ b/docs/bootstrap/packages/pacman.md
@@ -3,7 +3,7 @@
System packages for Arch-family Linux (Arch, Manjaro, EndeavourOS, ...).
```toml
-[system.packages]
+[bootstrap.packages]
"pacman:openssl" = "latest"
"pacman:base-devel" = "latest"
```
@@ -13,12 +13,12 @@ System packages for Arch-family Linux (Arch, Manjaro, EndeavourOS, ...).
- Package state is checked with `pacman -Q` (read-only, never elevates).
- Missing packages are installed with `pacman -S --noconfirm --needed`,
elevated with sudo when necessary (see
- [sudo](/system-packages/index.html#sudo)). `--needed` makes installs
+ [sudo](/bootstrap/packages/#sudo)). `--needed` makes installs
idempotent.
- If `/var/lib/pacman/sync` contains no databases (fresh containers), mise
runs `pacman -Sy` automatically before installing. Force a refresh with
- `mise system install --update`.
-- `mise system upgrade` runs `pacman -Sy` and then upgrades only the
+ `mise bootstrap packages install --update`.
+- `mise bootstrap packages upgrade` runs `pacman -Sy` and then upgrades only the
configured packages. Note that Arch officially supports only full-system
upgrades (`pacman -Syu`) — upgrading individual packages is a
[partial upgrade](https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported),
@@ -26,8 +26,8 @@ System packages for Arch-family Linux (Arch, Manjaro, EndeavourOS, ...).
::: warning
Arch repositories only carry the latest version of each package, so pacman
-entries cannot be installed at a pinned version — `mise system install`
-skips pinned entries with a warning, though `mise system status` still
+entries cannot be installed at a pinned version — `mise bootstrap packages install`
+skips pinned entries with a warning, though `mise bootstrap packages status` still
reports a `version mismatch` for them. AUR packages are not supported (they
require an AUR helper and building from source).
:::
diff --git a/docs/system-login-shell.md b/docs/bootstrap/user.md
similarity index 57%
rename from docs/system-login-shell.md
rename to docs/bootstrap/user.md
index 52de482d64..fb213d8588 100644
--- a/docs/system-login-shell.md
+++ b/docs/bootstrap/user.md
@@ -1,10 +1,11 @@
-# System Login Shell
+# User Login Shell
-mise can declare the current user's login shell in the `[system]` section of
-`mise.toml` and apply it with `mise system install`:
+mise can declare the current user's login shell in `[bootstrap.user]` and
+apply it with `mise bootstrap user apply` or
+[`mise bootstrap`](/cli/bootstrap.html):
```toml
-[system]
+[bootstrap.user]
login_shell = "/bin/zsh"
```
@@ -18,19 +19,19 @@ chsh -s /bin/zsh
## Semantics
-`[system].login_shell` follows the same manual, idempotent model as
-[system packages](/system-packages/):
+`[bootstrap.user].login_shell` follows the same manual, idempotent model as
+[bootstrap packages](/bootstrap/packages/):
- **Most local wins** - a project config can override a global
`login_shell`; unlike package/file lists, there is only one desired value.
- **Manual application only** - mise never changes your login shell
- implicitly. Only `mise system install` or [`mise bootstrap`](/cli/bootstrap.html)
- applies it.
+ implicitly. Only [`mise bootstrap`](/cli/bootstrap.html) applies it.
- **Listed shell** - the shell must appear in `/etc/shells` before `chsh`
accepts it on many platforms. mise adds the configured path to that file
when it is missing.
- **Unix-only** - on non-Unix platforms, or when `chsh` is not available,
- `mise system status` reports the entry as skipped and install ignores it.
+ `mise bootstrap user status` reports the entry as skipped and bootstrap
+ ignores it.
- **Absolute path required** - relative shell names are skipped with a
warning. Use the full path, such as `/bin/zsh` or `/opt/homebrew/bin/fish`.
@@ -46,14 +47,10 @@ still target root.
## Commands
```sh
-mise system status # shows login shell state
-mise system status --missing # exit 1 if the shell differs or is not listed
+mise bootstrap user status # shows login shell state
+mise bootstrap user status --missing # exit 1 if the shell differs or is not listed
-mise system install # updates /etc/shells and runs chsh -s when needed
-mise system install --dry-run # print the commands instead
-mise system install --yes # skip the confirmation prompt
+mise bootstrap user apply # updates /etc/shells and runs chsh -s
+mise bootstrap user apply --dry-run # print the commands instead
+mise bootstrap user apply --yes # skip the confirmation prompt
```
-
-Explicit package arguments and `--manager` scope `mise system install` to
-packages only, so `login_shell` is applied by the bare converge-everything
-form.
diff --git a/docs/cli/bootstrap.md b/docs/cli/bootstrap.md
index db85923ccd..8b0268d861 100644
--- a/docs/cli/bootstrap.md
+++ b/docs/cli/bootstrap.md
@@ -1,18 +1,22 @@
# `mise bootstrap`
-- **Usage**: `mise bootstrap [FLAGS]`
-- **Source code**: [`src/cli/bootstrap.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap.rs)
+- **Usage**: `mise bootstrap [FLAGS] `
+- **Source code**: [`src/cli/bootstrap/mod.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap/mod.rs)
[experimental] Set up a machine for the current config in one command
Runs the bootstrap steps for the current config in order:
-1. `mise system install` — install missing `[system.packages]`, apply
- `[system.files]` and `[system.edits]`, write `[system.defaults]`
- (macOS), and set `[system].login_shell` (Unix)
-2. `mise install` — install missing tools from `[tools]`
-3. `mise run bootstrap` — if a task named `bootstrap` is defined
+1. `mise bootstrap packages install` — install missing
+ `[bootstrap.packages]`
+2. `mise dotfiles apply` — apply dotfiles from `[dotfiles]`
+3. `mise bootstrap macos-defaults apply` — write
+ `[bootstrap.macos.defaults]` entries (macOS)
+4. `mise bootstrap user apply` — set `[bootstrap.user].login_shell`
+ (Unix)
+5. `mise install` — install missing tools from `[tools]`
+6. `mise run bootstrap` — if a task named `bootstrap` is defined
The declarative steps converge — anything already in its desired state
is skipped, so re-running is safe. The `bootstrap` task runs on every
@@ -34,10 +38,17 @@ Skip confirmation prompts
Refresh system package manager metadata first (apt: `apt-get update`)
+## Subcommands
+
+- [`mise bootstrap macos-defaults `](/cli/bootstrap/macos-defaults.md)
+- [`mise bootstrap packages `](/cli/bootstrap/packages.md)
+- [`mise bootstrap user `](/cli/bootstrap/user.md)
+
Examples:
```
-mise bootstrap # system packages + tools + bootstrap task
-mise bootstrap --yes # don't prompt before installing system packages
-mise bootstrap --dry-run # show what would happen
+mise bootstrap # packages + dotfiles + tools + bootstrap task
+mise bootstrap packages install --yes
+mise bootstrap macos-defaults status
+mise bootstrap user apply --dry-run
```
diff --git a/docs/cli/bootstrap/macos-defaults.md b/docs/cli/bootstrap/macos-defaults.md
new file mode 100644
index 0000000000..704bb39fdb
--- /dev/null
+++ b/docs/cli/bootstrap/macos-defaults.md
@@ -0,0 +1,12 @@
+
+# `mise bootstrap macos-defaults`
+
+- **Usage**: `mise bootstrap macos-defaults `
+- **Source code**: [`src/cli/bootstrap/mod.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap/mod.rs)
+
+Manage macOS defaults from `[bootstrap.macos.defaults]`
+
+## Subcommands
+
+- [`mise bootstrap macos-defaults apply [-n --dry-run] [-y --yes]`](/cli/bootstrap/macos-defaults/apply.md)
+- [`mise bootstrap macos-defaults status [-J --json] [--missing]`](/cli/bootstrap/macos-defaults/status.md)
diff --git a/docs/cli/bootstrap/macos-defaults/apply.md b/docs/cli/bootstrap/macos-defaults/apply.md
new file mode 100644
index 0000000000..cf9698cad3
--- /dev/null
+++ b/docs/cli/bootstrap/macos-defaults/apply.md
@@ -0,0 +1,15 @@
+
+# `mise bootstrap macos-defaults apply`
+
+- **Usage**: `mise bootstrap macos-defaults apply [-n --dry-run] [-y --yes]`
+- **Source code**: [`src/cli/bootstrap/macos_defaults/apply.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap/macos_defaults/apply.rs)
+
+## Flags
+
+### `-n --dry-run`
+
+Print the commands that would run without running them
+
+### `-y --yes`
+
+Skip the confirmation prompt
diff --git a/docs/cli/bootstrap/macos-defaults/status.md b/docs/cli/bootstrap/macos-defaults/status.md
new file mode 100644
index 0000000000..1379df7eb1
--- /dev/null
+++ b/docs/cli/bootstrap/macos-defaults/status.md
@@ -0,0 +1,15 @@
+
+# `mise bootstrap macos-defaults status`
+
+- **Usage**: `mise bootstrap macos-defaults status [-J --json] [--missing]`
+- **Source code**: [`src/cli/bootstrap/macos_defaults/status.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap/macos_defaults/status.rs)
+
+## Flags
+
+### `-J --json`
+
+Output in JSON format
+
+### `--missing`
+
+Exit with code 1 if any configured defaults are not in their desired state
diff --git a/docs/cli/bootstrap/packages.md b/docs/cli/bootstrap/packages.md
new file mode 100644
index 0000000000..1667c73236
--- /dev/null
+++ b/docs/cli/bootstrap/packages.md
@@ -0,0 +1,15 @@
+
+# `mise bootstrap packages`
+
+- **Usage**: `mise bootstrap packages `
+- **Source code**: [`src/cli/bootstrap/mod.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap/mod.rs)
+
+Manage bootstrap system packages from `[bootstrap.packages]`
+
+## Subcommands
+
+- [`mise bootstrap packages brew `](/cli/bootstrap/packages/brew.md)
+- [`mise bootstrap packages install [FLAGS] [PACKAGE]…`](/cli/bootstrap/packages/install.md)
+- [`mise bootstrap packages status [-J --json] [--missing]`](/cli/bootstrap/packages/status.md)
+- [`mise bootstrap packages upgrade [FLAGS] [PACKAGE]…`](/cli/bootstrap/packages/upgrade.md)
+- [`mise bootstrap packages use [FLAGS] …`](/cli/bootstrap/packages/use.md)
diff --git a/docs/cli/bootstrap/packages/brew.md b/docs/cli/bootstrap/packages/brew.md
new file mode 100644
index 0000000000..e3a0c26ed8
--- /dev/null
+++ b/docs/cli/bootstrap/packages/brew.md
@@ -0,0 +1,15 @@
+
+# `mise bootstrap packages brew`
+
+- **Usage**: `mise bootstrap packages brew `
+- **Source code**: [`src/cli/bootstrap/mod.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap/mod.rs)
+
+Manage Homebrew taps used by bootstrap packages
+
+These commands shell out to Homebrew and do not modify `mise.toml`. Use
+`[bootstrap.brew.taps]` when you want tap sources shared in config.
+
+## Subcommands
+
+- [`mise bootstrap packages brew tap [-n --dry-run] [URL]`](/cli/bootstrap/packages/brew/tap.md)
+- [`mise bootstrap packages brew untap [-n --dry-run] …`](/cli/bootstrap/packages/brew/untap.md)
diff --git a/docs/cli/bootstrap/packages/brew/tap.md b/docs/cli/bootstrap/packages/brew/tap.md
new file mode 100644
index 0000000000..bd65c42e27
--- /dev/null
+++ b/docs/cli/bootstrap/packages/brew/tap.md
@@ -0,0 +1,30 @@
+
+# `mise bootstrap packages brew tap`
+
+- **Usage**: `mise bootstrap packages brew tap [-n --dry-run] [URL]`
+- **Source code**: [`src/cli/bootstrap/packages/brew/tap.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap/packages/brew/tap.rs)
+
+Tap a Homebrew formula repository
+
+## Arguments
+
+### ``
+
+Tap name, e.g. `owner/repo`
+
+### `[URL]`
+
+Git URL for non-GitHub or otherwise custom taps
+
+## Flags
+
+### `-n --dry-run`
+
+Print the command that would run without running it
+
+Examples:
+
+```
+mise bootstrap packages brew tap railwaycat/emacsmacport
+mise bootstrap packages brew tap acme/tools https://git.example.com/acme/homebrew-tools.git
+```
diff --git a/docs/cli/bootstrap/packages/brew/untap.md b/docs/cli/bootstrap/packages/brew/untap.md
new file mode 100644
index 0000000000..941e16f167
--- /dev/null
+++ b/docs/cli/bootstrap/packages/brew/untap.md
@@ -0,0 +1,26 @@
+
+# `mise bootstrap packages brew untap`
+
+- **Usage**: `mise bootstrap packages brew untap [-n --dry-run] …`
+- **Aliases**: `remove`, `rm`
+- **Source code**: [`src/cli/bootstrap/packages/brew/untap.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap/packages/brew/untap.rs)
+
+Untap Homebrew formula repositories
+
+## Arguments
+
+### `…`
+
+Tap name(s), e.g. `owner/repo`
+
+## Flags
+
+### `-n --dry-run`
+
+Print the command that would run without running it
+
+Examples:
+
+```
+mise bootstrap packages brew untap railwaycat/emacsmacport
+```
diff --git a/docs/cli/bootstrap/packages/install.md b/docs/cli/bootstrap/packages/install.md
new file mode 100644
index 0000000000..54687e458e
--- /dev/null
+++ b/docs/cli/bootstrap/packages/install.md
@@ -0,0 +1,57 @@
+
+# `mise bootstrap packages install`
+
+- **Usage**: `mise bootstrap packages install [FLAGS] [PACKAGE]…`
+- **Aliases**: `i`
+- **Source code**: [`src/cli/bootstrap/packages/install.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap/packages/install.rs)
+
+Install missing system packages from `[bootstrap.packages]`
+
+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).
+
+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. Explicit packages and `--manager` scope the run to packages
+only.
+
+## Arguments
+
+### `[PACKAGE]…`
+
+Packages in `manager:package` form; defaults to everything configured in [bootstrap.packages]
+
+## Flags
+
+### `-m --manager `
+
+Only install packages for this manager, e.g. `apt` or `brew`
+
+**Choices:**
+
+- `apt`
+- `brew`
+- `dnf`
+- `pacman`
+
+### `-n --dry-run`
+
+Print the commands that would run without running them
+
+### `-y --yes`
+
+Skip the confirmation prompt
+
+### `--update`
+
+Refresh package manager metadata first (apt: `apt-get update`)
+
+Examples:
+
+```
+mise bootstrap packages install
+mise bootstrap packages install apt:curl brew:jq
+mise bootstrap packages install --dry-run
+mise bootstrap packages install --manager apt --yes
+```
diff --git a/docs/cli/bootstrap/packages/status.md b/docs/cli/bootstrap/packages/status.md
new file mode 100644
index 0000000000..fa0104a81a
--- /dev/null
+++ b/docs/cli/bootstrap/packages/status.md
@@ -0,0 +1,26 @@
+
+# `mise bootstrap packages status`
+
+- **Usage**: `mise bootstrap packages status [-J --json] [--missing]`
+- **Aliases**: `ls`
+- **Source code**: [`src/cli/bootstrap/packages/status.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap/packages/status.rs)
+
+Show the status of system packages from `[bootstrap.packages]`
+
+## Flags
+
+### `-J --json`
+
+Output in JSON format
+
+### `--missing`
+
+Exit with code 1 if any configured packages are not in their desired state
+
+Examples:
+
+```
+mise bootstrap packages status
+mise bootstrap packages status --json
+mise bootstrap packages status --missing # exit 1 if anything is out of sync
+```
diff --git a/docs/cli/system/upgrade.md b/docs/cli/bootstrap/packages/upgrade.md
similarity index 58%
rename from docs/cli/system/upgrade.md
rename to docs/cli/bootstrap/packages/upgrade.md
index 3e9dffbe61..c2b24221f0 100644
--- a/docs/cli/system/upgrade.md
+++ b/docs/cli/bootstrap/packages/upgrade.md
@@ -1,17 +1,17 @@
-# `mise system upgrade`
+# `mise bootstrap packages upgrade`
-- **Usage**: `mise system upgrade [FLAGS] [PACKAGE]…`
+- **Usage**: `mise bootstrap packages upgrade [FLAGS] [PACKAGE]…`
- **Aliases**: `up`
-- **Source code**: [`src/cli/system/upgrade.rs`](https://github.com/jdx/mise/blob/main/src/cli/system/upgrade.rs)
+- **Source code**: [`src/cli/bootstrap/packages/upgrade.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap/packages/upgrade.rs)
-Upgrade installed system packages from `[system.packages]`
+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. Packages that are not
-installed yet are skipped — use `mise system install` for those.
+installed yet are skipped — use `mise bootstrap packages install` for those.
Packages can also be given explicitly in `manager:package` form.
@@ -19,7 +19,7 @@ Packages can also be given explicitly in `manager:package` form.
### `[PACKAGE]…`
-Packages in `manager:package` form; defaults to everything configured in [system.packages]
+Packages in `manager:package` form; defaults to everything configured in [bootstrap.packages]
## Flags
@@ -45,8 +45,8 @@ Skip the confirmation prompt
Examples:
```
-mise system upgrade
-mise system upgrade brew:postgresql@17
-mise system upgrade --manager apt --yes
-mise system upgrade --dry-run
+mise bootstrap packages upgrade
+mise bootstrap packages upgrade brew:postgresql@17
+mise bootstrap packages upgrade --manager apt --yes
+mise bootstrap packages upgrade --dry-run
```
diff --git a/docs/cli/system/use.md b/docs/cli/bootstrap/packages/use.md
similarity index 64%
rename from docs/cli/system/use.md
rename to docs/cli/bootstrap/packages/use.md
index e9f511b336..3ed0c1190e 100644
--- a/docs/cli/system/use.md
+++ b/docs/cli/bootstrap/packages/use.md
@@ -1,17 +1,17 @@
-# `mise system use`
+# `mise bootstrap packages use`
-- **Usage**: `mise system use [FLAGS] …`
+- **Usage**: `mise bootstrap packages use [FLAGS] …`
- **Aliases**: `u`
-- **Source code**: [`src/cli/system/use.rs`](https://github.com/jdx/mise/blob/main/src/cli/system/use.rs)
+- **Source code**: [`src/cli/bootstrap/packages/use.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap/packages/use.rs)
-Add system packages to [system.packages] and install them
+Add bootstrap packages to [bootstrap.packages] and install them
Like `mise use` for tools: writes `"manager:package" = "version"` entries
to mise.toml (the local config by default, the global one with `-g`) and
then installs whatever is missing.
-Versions are pinned with `@`: `mise system use apt:curl@8.5.0-2`. Without
+Versions are pinned with `@`: `mise bootstrap packages use apt:curl@8.5.0-2`. Without
`@` (or with `@latest`) no pin is written. brew formulae version through
their names instead (`brew:postgresql@17`), so `@` is always part of the
formula name there.
@@ -47,7 +47,7 @@ Skip the confirmation prompt
Examples:
```
-mise system use apt:curl brew:jq
-mise system use -g brew:postgresql@17
-mise system use apt:curl@8.5.0-2
+mise bootstrap packages use apt:curl brew:jq
+mise bootstrap packages use -g brew:postgresql@17
+mise bootstrap packages use apt:curl@8.5.0-2
```
diff --git a/docs/cli/bootstrap/user.md b/docs/cli/bootstrap/user.md
new file mode 100644
index 0000000000..fac048d52f
--- /dev/null
+++ b/docs/cli/bootstrap/user.md
@@ -0,0 +1,12 @@
+
+# `mise bootstrap user`
+
+- **Usage**: `mise bootstrap user `
+- **Source code**: [`src/cli/bootstrap/mod.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap/mod.rs)
+
+Manage current-user bootstrap settings from `[bootstrap.user]`
+
+## Subcommands
+
+- [`mise bootstrap user apply [-n --dry-run] [-y --yes]`](/cli/bootstrap/user/apply.md)
+- [`mise bootstrap user status [-J --json] [--missing]`](/cli/bootstrap/user/status.md)
diff --git a/docs/cli/bootstrap/user/apply.md b/docs/cli/bootstrap/user/apply.md
new file mode 100644
index 0000000000..ed86ff95ae
--- /dev/null
+++ b/docs/cli/bootstrap/user/apply.md
@@ -0,0 +1,15 @@
+
+# `mise bootstrap user apply`
+
+- **Usage**: `mise bootstrap user apply [-n --dry-run] [-y --yes]`
+- **Source code**: [`src/cli/bootstrap/user/apply.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap/user/apply.rs)
+
+## Flags
+
+### `-n --dry-run`
+
+Print the commands that would run without running them
+
+### `-y --yes`
+
+Skip the confirmation prompt
diff --git a/docs/cli/bootstrap/user/status.md b/docs/cli/bootstrap/user/status.md
new file mode 100644
index 0000000000..fca39e6611
--- /dev/null
+++ b/docs/cli/bootstrap/user/status.md
@@ -0,0 +1,15 @@
+
+# `mise bootstrap user status`
+
+- **Usage**: `mise bootstrap user status [-J --json] [--missing]`
+- **Source code**: [`src/cli/bootstrap/user/status.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap/user/status.rs)
+
+## Flags
+
+### `-J --json`
+
+Output in JSON format
+
+### `--missing`
+
+Exit with code 1 if any configured user setting is not in its desired state
diff --git a/docs/cli/dotfiles.md b/docs/cli/dotfiles.md
new file mode 100644
index 0000000000..fcf9469dc0
--- /dev/null
+++ b/docs/cli/dotfiles.md
@@ -0,0 +1,19 @@
+
+# `mise dotfiles`
+
+- **Usage**: `mise dotfiles `
+- **Source code**: [`src/cli/dotfiles/mod.rs`](https://github.com/jdx/mise/blob/main/src/cli/dotfiles/mod.rs)
+
+[experimental] Manage dotfiles from `[dotfiles]`
+
+Dotfiles are config files symlinked, copied, or rendered to target paths,
+plus marker-delimited blocks or single lines in files mise doesn't own.
+Unlike `[tools]`, dotfiles are only acted on when explicitly requested with
+`mise dotfiles apply` or `mise bootstrap`.
+
+## Subcommands
+
+- [`mise dotfiles add [FLAGS] …`](/cli/dotfiles/add.md)
+- [`mise dotfiles apply [FLAGS] [TARGET]…`](/cli/dotfiles/apply.md)
+- [`mise dotfiles edit [FLAGS] `](/cli/dotfiles/edit.md)
+- [`mise dotfiles status [-J --json] [--missing] [TARGET]…`](/cli/dotfiles/status.md)
diff --git a/docs/cli/dotfiles/add.md b/docs/cli/dotfiles/add.md
new file mode 100644
index 0000000000..52446fd31d
--- /dev/null
+++ b/docs/cli/dotfiles/add.md
@@ -0,0 +1,59 @@
+
+# `mise dotfiles add`
+
+- **Usage**: `mise dotfiles add [FLAGS] …`
+- **Source code**: [`src/cli/dotfiles/add.rs`](https://github.com/jdx/mise/blob/main/src/cli/dotfiles/add.rs)
+
+Add or update dotfiles in `[dotfiles]`
+
+If the target is already managed, this updates its source from the live
+target. Otherwise it creates a `[dotfiles]` entry and seeds the source
+under `dotfiles.root` unless `--source` is provided.
+
+## Arguments
+
+### `…`
+
+Targets to add or update
+
+## Flags
+
+### `-f --force`
+
+Overwrite existing sources without prompting
+
+### `-g --global`
+
+Write to the global config
+
+### `-l --local`
+
+Write to the local config instead of the global config
+
+### `-m --mode `
+
+Dotfile mode to write
+
+### `-n --dry-run`
+
+Print the config/source updates without writing anything
+
+### `-p --path `
+
+Write to this config file or directory
+
+### `-s --source `
+
+Source path to use for a single target
+
+### `-y --yes`
+
+Skip the confirmation prompt
+
+Examples:
+
+```
+mise dotfiles add ~/.zshrc
+mise dotfiles add --mode copy ~/.config/starship.toml
+mise dotfiles add --source dotfiles/gitconfig ~/.gitconfig
+```
diff --git a/docs/cli/dotfiles/apply.md b/docs/cli/dotfiles/apply.md
new file mode 100644
index 0000000000..a908b61ed3
--- /dev/null
+++ b/docs/cli/dotfiles/apply.md
@@ -0,0 +1,41 @@
+
+# `mise dotfiles apply`
+
+- **Usage**: `mise dotfiles apply [FLAGS] [TARGET]…`
+- **Source code**: [`src/cli/dotfiles/apply.rs`](https://github.com/jdx/mise/blob/main/src/cli/dotfiles/apply.rs)
+
+Apply dotfiles from `[dotfiles]`
+
+Applies configured whole-file entries and edits that aren't in their
+desired state. Whole-file entries may symlink, copy, or render templates.
+Edit entries manage a marker-delimited block or a single line in a file
+mise doesn't otherwise own.
+
+## Arguments
+
+### `[TARGET]…`
+
+Only apply these targets
+
+## Flags
+
+### `-f --force`
+
+Overwrite existing files that conflict with whole-file dotfile entries
+
+### `-n --dry-run`
+
+Print the actions that would run without writing anything
+
+### `-y --yes`
+
+Skip the confirmation prompt
+
+Examples:
+
+```
+mise dotfiles apply
+mise dotfiles apply --dry-run
+mise dotfiles apply --dry-run --verbose
+mise dotfiles apply --force --yes
+```
diff --git a/docs/cli/dotfiles/edit.md b/docs/cli/dotfiles/edit.md
new file mode 100644
index 0000000000..c41567b47f
--- /dev/null
+++ b/docs/cli/dotfiles/edit.md
@@ -0,0 +1,38 @@
+
+# `mise dotfiles edit`
+
+- **Usage**: `mise dotfiles edit [FLAGS] `
+- **Source code**: [`src/cli/dotfiles/edit.rs`](https://github.com/jdx/mise/blob/main/src/cli/dotfiles/edit.rs)
+
+Edit a managed dotfile source
+
+## Arguments
+
+### ``
+
+Target to edit
+
+## Flags
+
+### `--apply`
+
+Apply this target after the editor exits
+
+### `-m --mode `
+
+Dotfile mode to use if the target is not yet managed
+
+### `-s --source `
+
+Source path to use if the target is not yet managed
+
+### `-y --yes`
+
+Skip the confirmation prompt when adding an unmanaged target
+
+Examples:
+
+```
+mise dotfiles edit ~/.zshrc
+mise dotfiles edit --apply ~/.config/starship.toml
+```
diff --git a/docs/cli/dotfiles/status.md b/docs/cli/dotfiles/status.md
new file mode 100644
index 0000000000..91381b4ba0
--- /dev/null
+++ b/docs/cli/dotfiles/status.md
@@ -0,0 +1,34 @@
+
+# `mise dotfiles status`
+
+- **Usage**: `mise dotfiles status [-J --json] [--missing] [TARGET]…`
+- **Aliases**: `ls`
+- **Source code**: [`src/cli/dotfiles/status.rs`](https://github.com/jdx/mise/blob/main/src/cli/dotfiles/status.rs)
+
+Show the status of dotfiles from `[dotfiles]`
+
+## Arguments
+
+### `[TARGET]…`
+
+Only show these targets
+
+## Flags
+
+### `-J --json`
+
+Output in JSON format
+
+### `--missing`
+
+Exit with code 1 if any configured dotfiles are not in their desired
+state (missing, source missing, differs)
+
+Examples:
+
+```
+mise dotfiles status
+mise dotfiles status ~/.zshrc
+mise dotfiles status --json
+mise dotfiles status --missing # exit 1 if anything is out of sync
+```
diff --git a/docs/cli/index.md b/docs/cli/index.md
index 296e857d5b..f1463ae112 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -88,7 +88,21 @@ Can also use `MISE_NO_HOOKS=1`
- [`mise backends `](/cli/backends.md)
- [`mise backends ls`](/cli/backends/ls.md)
- [`mise bin-paths [--bin-names] [-J --json] [TOOL@VERSION]…`](/cli/bin-paths.md)
-- [`mise bootstrap [FLAGS]`](/cli/bootstrap.md)
+- [`mise bootstrap [FLAGS] `](/cli/bootstrap.md)
+- [`mise bootstrap macos-defaults `](/cli/bootstrap/macos-defaults.md)
+- [`mise bootstrap macos-defaults apply [-n --dry-run] [-y --yes]`](/cli/bootstrap/macos-defaults/apply.md)
+- [`mise bootstrap macos-defaults status [-J --json] [--missing]`](/cli/bootstrap/macos-defaults/status.md)
+- [`mise bootstrap packages `](/cli/bootstrap/packages.md)
+- [`mise bootstrap packages brew `](/cli/bootstrap/packages/brew.md)
+- [`mise bootstrap packages brew tap [-n --dry-run] [URL]`](/cli/bootstrap/packages/brew/tap.md)
+- [`mise bootstrap packages brew untap [-n --dry-run] …`](/cli/bootstrap/packages/brew/untap.md)
+- [`mise bootstrap packages install [FLAGS] [PACKAGE]…`](/cli/bootstrap/packages/install.md)
+- [`mise bootstrap packages status [-J --json] [--missing]`](/cli/bootstrap/packages/status.md)
+- [`mise bootstrap packages upgrade [FLAGS] [PACKAGE]…`](/cli/bootstrap/packages/upgrade.md)
+- [`mise bootstrap packages use [FLAGS] …`](/cli/bootstrap/packages/use.md)
+- [`mise bootstrap user `](/cli/bootstrap/user.md)
+- [`mise bootstrap user apply [-n --dry-run] [-y --yes]`](/cli/bootstrap/user/apply.md)
+- [`mise bootstrap user status [-J --json] [--missing]`](/cli/bootstrap/user/status.md)
- [`mise cache `](/cli/cache.md)
- [`mise cache clear [TOOL]…`](/cli/cache/clear.md)
- [`mise cache path`](/cli/cache/path.md)
@@ -99,6 +113,11 @@ Can also use `MISE_NO_HOOKS=1`
- [`mise config ls [FLAGS]`](/cli/config/ls.md)
- [`mise config set [-f --file ] [-t --type ] [VALUE]`](/cli/config/set.md)
- [`mise deactivate`](/cli/deactivate.md)
+- [`mise dotfiles `](/cli/dotfiles.md)
+- [`mise dotfiles add [FLAGS] …`](/cli/dotfiles/add.md)
+- [`mise dotfiles apply [FLAGS] [TARGET]…`](/cli/dotfiles/apply.md)
+- [`mise dotfiles edit [FLAGS] `](/cli/dotfiles/edit.md)
+- [`mise dotfiles status [-J --json] [--missing] [TARGET]…`](/cli/dotfiles/status.md)
- [`mise doctor [-J --json] `](/cli/doctor.md)
- [`mise doctor path [-f --full]`](/cli/doctor/path.md)
- [`mise en [-s --shell ] [DIR]`](/cli/en.md)
@@ -165,14 +184,6 @@ Can also use `MISE_NO_HOOKS=1`
- [`mise sync node [FLAGS]`](/cli/sync/node.md)
- [`mise sync python [--pyenv] [--uv]`](/cli/sync/python.md)
- [`mise sync ruby [--brew]`](/cli/sync/ruby.md)
-- [`mise system `](/cli/system.md)
-- [`mise system brew `](/cli/system/brew.md)
-- [`mise system brew tap [-n --dry-run] [URL]`](/cli/system/brew/tap.md)
-- [`mise system brew untap [-n --dry-run] …`](/cli/system/brew/untap.md)
-- [`mise system install [FLAGS] [PACKAGE]…`](/cli/system/install.md)
-- [`mise system status [-J --json] [--missing]`](/cli/system/status.md)
-- [`mise system upgrade [FLAGS] [PACKAGE]…`](/cli/system/upgrade.md)
-- [`mise system use [FLAGS] …`](/cli/system/use.md)
- [`mise tasks [FLAGS] [TASK] `](/cli/tasks.md)
- [`mise tasks add [FLAGS] [-- RUN]…`](/cli/tasks/add.md)
- [`mise tasks deps [--dot] [--hidden] [TASKS]…`](/cli/tasks/deps.md)
diff --git a/docs/cli/system.md b/docs/cli/system.md
deleted file mode 100644
index da88c0b3b3..0000000000
--- a/docs/cli/system.md
+++ /dev/null
@@ -1,28 +0,0 @@
-
-# `mise system`
-
-- **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]`, files
-from `[system.files]`, edits from `[system.edits]`, macOS defaults
-from `[system.defaults]`, and Unix login shell from `[system].login_shell`
-
-System packages are machine-global packages installed by the OS package
-manager (apt, dnf, pacman) or mise's Homebrew-bottle installer (brew).
-System files are config files (dotfiles) symlinked, copied, or rendered
-to machine-global paths. System edits manage one piece of a file
-something else owns — a marker-delimited block or a single line. macOS
-defaults are user preferences written with `defaults write`. Unlike
-`[tools]`, none of these are version-pinned per-project and they are only
-ever acted on when explicitly requested with `mise system install` (or
-`mise bootstrap`). Login shell changes are current-user settings applied
-with `chsh -s`.
-
-## Subcommands
-
-- [`mise system brew `](/cli/system/brew.md)
-- [`mise system install [FLAGS] [PACKAGE]…`](/cli/system/install.md)
-- [`mise system status [-J --json] [--missing]`](/cli/system/status.md)
-- [`mise system upgrade [FLAGS] [PACKAGE]…`](/cli/system/upgrade.md)
-- [`mise system use [FLAGS] …`](/cli/system/use.md)
diff --git a/docs/cli/system/brew.md b/docs/cli/system/brew.md
deleted file mode 100644
index d1a4c292d0..0000000000
--- a/docs/cli/system/brew.md
+++ /dev/null
@@ -1,15 +0,0 @@
-
-# `mise system brew`
-
-- **Usage**: `mise system brew `
-- **Source code**: [`src/cli/system/mod.rs`](https://github.com/jdx/mise/blob/main/src/cli/system/mod.rs)
-
-Manage Homebrew taps used by system packages
-
-These commands shell out to Homebrew and do not modify `mise.toml`. Use
-`[system.brew.taps]` when you want tap sources shared in config.
-
-## Subcommands
-
-- [`mise system brew tap [-n --dry-run] [URL]`](/cli/system/brew/tap.md)
-- [`mise system brew untap [-n --dry-run] …`](/cli/system/brew/untap.md)
diff --git a/docs/cli/system/brew/tap.md b/docs/cli/system/brew/tap.md
deleted file mode 100644
index 698b2e271d..0000000000
--- a/docs/cli/system/brew/tap.md
+++ /dev/null
@@ -1,30 +0,0 @@
-
-# `mise system brew tap`
-
-- **Usage**: `mise system brew tap [-n --dry-run] [URL]`
-- **Source code**: [`src/cli/system/brew/tap.rs`](https://github.com/jdx/mise/blob/main/src/cli/system/brew/tap.rs)
-
-Tap a Homebrew formula repository
-
-## Arguments
-
-### ``
-
-Tap name, e.g. `owner/repo`
-
-### `[URL]`
-
-Git URL for non-GitHub or otherwise custom taps
-
-## Flags
-
-### `-n --dry-run`
-
-Print the command that would run without running it
-
-Examples:
-
-```
-mise system brew tap railwaycat/emacsmacport
-mise system brew tap acme/tools https://git.example.com/acme/homebrew-tools.git
-```
diff --git a/docs/cli/system/brew/untap.md b/docs/cli/system/brew/untap.md
deleted file mode 100644
index 742dba0e35..0000000000
--- a/docs/cli/system/brew/untap.md
+++ /dev/null
@@ -1,26 +0,0 @@
-
-# `mise system brew untap`
-
-- **Usage**: `mise system brew untap [-n --dry-run] …`
-- **Aliases**: `remove`, `rm`
-- **Source code**: [`src/cli/system/brew/untap.rs`](https://github.com/jdx/mise/blob/main/src/cli/system/brew/untap.rs)
-
-Untap Homebrew formula repositories
-
-## Arguments
-
-### `…`
-
-Tap name(s), e.g. `owner/repo`
-
-## Flags
-
-### `-n --dry-run`
-
-Print the command that would run without running it
-
-Examples:
-
-```
-mise system brew untap railwaycat/emacsmacport
-```
diff --git a/docs/cli/system/install.md b/docs/cli/system/install.md
deleted file mode 100644
index c9de33f75a..0000000000
--- a/docs/cli/system/install.md
+++ /dev/null
@@ -1,68 +0,0 @@
-
-# `mise system install`
-
-- **Usage**: `mise system install [FLAGS] [PACKAGE]…`
-- **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]`, apply files
-from `[system.files]` and edits from `[system.edits]`, write macOS
-defaults from `[system.defaults]`, and set Unix login shell from
-`[system].login_shell`
-
-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). Afterwards, `[system.files]`
-and `[system.edits]` entries that aren't in their desired state are
-applied, on macOS any `[system.defaults]` entries that are unset or
-differ are written, and on Unix `[system].login_shell` is added to
-`/etc/shells` if needed before `chsh -s` applies it.
-
-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. Explicit packages and `--manager` scope the run to packages
-only.
-
-## Arguments
-
-### `[PACKAGE]…`
-
-Packages in `manager:package` form; defaults to everything configured in [system.packages]
-
-## Flags
-
-### `-f --force`
-
-Overwrite existing files that conflict with `[system.files]` entries
-
-### `-m --manager `
-
-Only install packages for this manager, e.g. `apt` or `brew`
-
-**Choices:**
-
-- `apt`
-- `brew`
-- `dnf`
-- `pacman`
-
-### `-n --dry-run`
-
-Print the commands that would run without running them
-
-### `-y --yes`
-
-Skip the confirmation prompt
-
-### `--update`
-
-Refresh package manager metadata first (apt: `apt-get update`)
-
-Examples:
-
-```
-mise system install
-mise system install apt:curl brew:jq
-mise system install --dry-run
-mise system install --manager apt --yes
-```
diff --git a/docs/cli/system/status.md b/docs/cli/system/status.md
deleted file mode 100644
index ea5c4a936c..0000000000
--- a/docs/cli/system/status.md
+++ /dev/null
@@ -1,29 +0,0 @@
-
-# `mise system status`
-
-- **Usage**: `mise system status [-J --json] [--missing]`
-- **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]`, files from
-`[system.files]`, edits from `[system.edits]`, and macOS defaults from
-`[system.defaults]`, and Unix login shell from `[system].login_shell`
-
-## Flags
-
-### `-J --json`
-
-Output in JSON format
-
-### `--missing`
-
-Exit with code 1 if any configured packages, files, edits, defaults, or
-login shell are not in their desired state
-
-Examples:
-
-```
-mise system status
-mise system status --json
-mise system status --missing # exit 1 if anything is out of sync
-```
diff --git a/docs/dev-tools/mise-oci.md b/docs/dev-tools/mise-oci.md
index d5c28c5f1e..00f5bc22cb 100644
--- a/docs/dev-tools/mise-oci.md
+++ b/docs/dev-tools/mise-oci.md
@@ -70,9 +70,9 @@ jq = "1.8.1"
3. **One layer per tool**, each rooted at
`/mise/installs///`. Annotated with
`dev.mise.tool.short` and `dev.mise.tool.version`.
-4. **Configured apt `[system.packages]`**, if any, installed into the base
+4. **Configured apt `[bootstrap.packages]`**, if any, installed into the base
rootfs and emitted as one package layer.
-5. **Configured `[system.files]`**, if any, baked as image files.
+5. **Configured `[dotfiles]`**, if any, baked as image files.
6. **Synthesized `/etc/mise/config.toml`** referencing `/mise` as the data
directory.
@@ -207,19 +207,19 @@ CLI flags override the `[oci]` section. The `[oci]` section overrides the
When `mise.toml` files are layered (global + project), sections are merged
field-by-field with the more specific file winning per field.
-### `[system]` in OCI images
+### `[bootstrap]` and `[dotfiles]` in OCI images
-`mise oci build` applies project-scoped `[system.packages]` and
-`[system.files]` entries to the image. This is the OCI equivalent of the
-declarative parts of `mise bootstrap` / `mise system install`.
-Pass `--include-global` to also include `[system.packages]` and
-`[system.files]` from global configs.
+`mise oci build` applies project-scoped `[bootstrap.packages]` and
+`[dotfiles]` entries to the image. This is the OCI equivalent of the
+declarative package and dotfile parts of `mise bootstrap`.
+Pass `--include-global` to also include `[bootstrap.packages]` and
+`[dotfiles]` from global configs.
```toml
-[system.packages]
+[bootstrap.packages]
"apt:curl" = "latest"
-[system.files]
+[dotfiles]
"/etc/profile.d/project.sh" = { source = "profile.sh", mode = "copy" }
"~/.config/app/config.toml" = { source = "config.toml", mode = "template" }
```
@@ -235,7 +235,7 @@ content. Host symlinks would usually point back to the checkout path and be
broken inside the container, so the image receives the resolved contents
instead. Targets beginning with `~/` are written under `/root/`.
-`[system.defaults]` and the imperative `bootstrap` task are not run by
+`[bootstrap.macos.defaults]` and the imperative `bootstrap` task are not run by
`mise oci build`. macOS defaults do not apply to Linux OCI images, and
container-specific startup work belongs in the image entrypoint or command.
diff --git a/docs/dotfiles.md b/docs/dotfiles.md
new file mode 100644
index 0000000000..662bfd6cb7
--- /dev/null
+++ b/docs/dotfiles.md
@@ -0,0 +1,222 @@
+# Dotfiles
+
+mise can manage dotfiles from the `[dotfiles]` section of `mise.toml`.
+Entries can either own a whole file or directory, or manage one small piece
+of a file something else owns.
+
+```toml
+[settings]
+dotfiles.root = "~/.dotfiles"
+dotfiles.default_mode = "symlink"
+
+[dotfiles]
+"~/.zshrc" = {} # ~/.dotfiles/.zshrc
+"~/.gitconfig" = "dotfiles/gitconfig" # explicit source
+"~/.config/alacritty.toml" = { mode = "copy" } # ~/.dotfiles/.config/alacritty.toml
+"~/.config/starship.toml" = { source = "dotfiles/starship.toml", mode = "copy" }
+"~/.ssh/config" = { source = "dotfiles/ssh_config.tmpl", mode = "template" }
+"~/.config/nvim" = "dotfiles/nvim" # symlink the directory itself
+"~/.local/bin" = { source = "dotfiles/bin", mode = "symlink-each" } # symlink each file within
+"~/hosts/dev" = { line = "127.0.0.1 dev.local" } # edit one line in ~/hosts
+```
+
+Dotfiles are only applied when explicitly requested with
+`mise dotfiles apply` or [`mise bootstrap`](/cli/bootstrap.html). They are
+never applied implicitly by `mise install` or `mise bootstrap packages`.
+
+## Whole-file entries
+
+Whole-file entries are keyed by the target path — absolute or starting with
+`~/` — and may point at a source file or directory. If `source` is omitted,
+mise mirrors the home-relative target path under `dotfiles.root`: `~/.zshrc`
+uses `~/.dotfiles/.zshrc`, and `~/.config/foo.toml` uses
+`~/.dotfiles/.config/foo.toml`. Targets outside `$HOME` must specify
+`source`.
+
+String entries are shorthand for an explicit source with
+`dotfiles.default_mode`. Commands that write `[dotfiles]` always write table
+form with `mode`, even when it is the default:
+
+```toml
+[dotfiles]
+"~/.zshrc" = { mode = "symlink" }
+"~/.ssh/config" = { source = "ssh/config", mode = "copy" }
+```
+
+Relative explicit sources resolve against the directory of the config file
+that declares the entry, so a global `~/.config/mise/config.toml` can manage
+dotfiles kept next to it, and a project config can ship machine setup from
+the repo.
+
+Source paths may contain glob wildcards like `*`, `**`, `?`, or `[ab]`.
+When a wildcard source matches multiple paths, the target path must contain
+matching wildcards so each source expands to a unique target:
+
+```toml
+[dotfiles]
+"~/.config/*.toml" = "dotfiles/config/*.toml"
+"~/.local/share/app/**/*.json" = { source = "dotfiles/app/**/*.json", mode = "copy" }
+"~/.config/app?.toml" = "dotfiles/config/app?.toml"
+"~/.config/theme-[ab].toml" = "dotfiles/config/theme-[ab].toml"
+```
+
+## Modes
+
+| Mode | Behavior |
+| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `symlink` | Symlink the target to the source. Works for files and directories — a directory source gets one link for the whole directory. This is the default. |
+| `symlink-each` | Source must be a directory: recreate its directory structure under the target and symlink each file individually, so the target directory (say, `~/.config`) can also hold files mise doesn't manage. |
+| `copy` | Copy the source file (or directory, recursively). Use when the target must be a real file — e.g. tools that rewrite their config in place. Directory copies are additive: matching files are overwritten, files mise doesn't manage are left in place. |
+| `template` | Render the source through the [mise template engine](/templates.html) and write the result. Permissions are taken from the source file (and repaired if they drift). |
+
+Templates get the same context as other mise templates (`env`, `vars`,
+`exec()`, etc.), which is the main reason to use them: one source file,
+per-machine output.
+
+Detecting whether a template's output has drifted requires rendering it, so
+`mise dotfiles status` and a real apply evaluate templates — including any
+`exec()` calls — from your trusted config, just like `[env]` templates.
+`--dry-run` is the exception: it promises to execute nothing, so it skips
+template rendering and lists those entries as `(if changed)`.
+
+## Edit entries
+
+Edit entries manage one piece of a file: the `mise activate` block in your
+shell rc, an entry in `/etc/hosts`, or a small snippet in a config file.
+They are keyed by target path plus an id naming each edit within the file:
+
+```toml
+[dotfiles]
+"~/.zshrc/activate" = { block = 'eval "$(mise activate zsh)"' }
+"~/.zshrc/aliases" = { block = '''
+alias ll='ls -l'
+alias la='ls -la'
+''' }
+"/etc/hosts/dev" = { line = "127.0.0.1 dev.local" }
+"~/.gitconfig/identity" = { source = "snippets/git-identity.tmpl", template = "tera" }
+```
+
+For edit entries, `source` is paired with `template = "tera"` to make the
+entry unambiguously an edit. A table with only `source` is a whole-file
+entry using `dotfiles.default_mode`.
+
+A `block` is delimited by marker comments in the target file, named by the
+entry's id:
+
+```sh
+# >>> mise:activate >>> managed by mise - do not edit between markers
+eval "$(mise activate zsh)"
+# <<< mise:activate <<<
+```
+
+The markers are the ownership record, stored in the file itself, so the
+design stays stateless: applying replaces only what's between them or
+appends the block if absent, and everything else in the file is untouched.
+
+Ids may contain letters, digits, `_`, `-`, and `.`. The marker comment
+prefix is inferred from the file extension (`#` for shell/config files,
+`--` for Lua, `//` for C-like languages, `;` for INI, `"` for vim) and can
+be overridden with `comment = "..."`. Files that can't hold line comments
+at all (strict JSON, XML) aren't a fit for blocks — use a whole-file entry
+instead.
+
+A `line` ensures an exact line exists somewhere in the file, appending it at
+the end if absent. It never modifies or removes other lines, which is what
+makes it safely idempotent. The value must be a single line; use a block for
+multi-line content.
+
+## Semantics
+
+- **Declarative and additive** — entries merge across the
+ [config hierarchy](/configuration.html) (global → project). Whole-file
+ entries merge by target path; edit entries merge by `(path, id)`.
+- **Manual application only** — nothing is written implicitly. Only
+ `mise dotfiles apply` or [`mise bootstrap`](/cli/bootstrap.html) applies
+ dotfiles.
+- **Idempotent** — entries already in their desired state are skipped;
+ re-running is always safe.
+- **Unknown modes and operations are ignored with a warning** so configs
+ using features from newer mise versions still parse.
+
+## Conflicts
+
+mise refuses to _replace_ existing files it doesn't manage: a real file or
+directory where a symlink should go, or a directory where a file should go,
+is an error listing the conflicting paths. Pass
+`mise dotfiles apply --force` to replace them.
+
+Content updates are not conflicts: a `copy` or `template` entry overwrites
+the target file's content without `--force` — that is the declared intent of
+those modes. Symlinks are re-pointed freely, since a symlink is never data.
+
+Edit entries never need `--force`: a block owns only what's between its
+markers, and a line only ever appends. Two cases are refused with an error
+instead of guessed at: corrupted markers and targets that are symlinks. An
+edit through a symlink would modify whatever the link points at, often a
+`[dotfiles]` source, so point the edit at the real file instead.
+
+Removing an entry from config leaves its file, block, or line in place
+because mise keeps no state database. Delete unmanaged leftovers by hand.
+
+## Commands
+
+```sh
+mise dotfiles status # shows applied/missing/differs/source missing
+mise dotfiles status --missing # exit 1 if anything is out of sync
+
+mise dotfiles apply # apply files and edits
+mise dotfiles apply --dry-run # print what would be done
+mise dotfiles apply --dry-run --verbose # include diff-like details
+mise dotfiles apply --yes # skip the confirmation prompt
+mise dotfiles apply --force # also replace conflicting files
+
+mise dotfiles add ~/.zshrc # capture a live file into dotfiles.root
+mise dotfiles edit ~/.zshrc # edit the managed source or owning config
+mise dotfiles edit --apply ~/.zshrc
+```
+
+`mise dotfiles status` reports each entry as `applied`, `missing`,
+`differs` with a reason, or `source missing`.
+
+## Capturing changes
+
+If you edit a copied dotfile in place and want to store those changes back
+in your dotfiles, run `mise dotfiles add` again:
+
+```sh
+$EDITOR ~/.config/starship.toml
+mise dotfiles add ~/.config/starship.toml
+```
+
+For an unmanaged target, `add` creates a `[dotfiles]` entry and seeds the
+source under `dotfiles.root`. For an already-managed target, it updates the
+existing source from the live target.
+
+## Self-managing mise config
+
+You can manage the mise config and the dotfiles root as dotfiles too:
+
+```toml
+[settings]
+dotfiles.root = "~/.dotfiles"
+
+[dotfiles]
+"~/.dotfiles" = "~/src/dotfiles"
+"~/.config/mise/config.toml" = "~/.dotfiles/mise/config.toml"
+```
+
+This is a bootstrap pattern: clone the real repo (for example
+`~/src/dotfiles`) before the first `mise dotfiles apply` or `mise bootstrap`.
+Replacing `~/.config/mise/config.toml` affects future mise invocations, so
+make sure the source contains a valid config before applying it.
+
+## Root-owned files
+
+Dotfiles write as the current user — there is no sudo here. Managing
+`/etc/hosts` works when running as root (containers, CI); otherwise mise
+fails with an ordinary permission error.
+
+## Windows
+
+File symlinks require elevation on Windows, so `symlink` and `symlink-each`
+fall back to copying for files there; directory symlinks use junctions.
diff --git a/docs/system-edits.md b/docs/system-edits.md
deleted file mode 100644
index e47d0dc732..0000000000
--- a/docs/system-edits.md
+++ /dev/null
@@ -1,129 +0,0 @@
-# System Edits
-
-Where [System Files](/system-files.html) manages whole files, `[system.edits]`
-manages one small piece of a file something else owns — the `mise activate`
-line in your shell rc, an entry in `/etc/hosts`. Entries are keyed by target
-path, then by an id naming each edit within the file:
-
-```toml
-[system.edits]
-"~/.zshrc" = {
- activate = 'eval "$(mise activate zsh)"',
- aliases = '''
-alias ll='ls -l'
-alias la='ls -la'
-''',
-}
-"/etc/hosts" = { dev = { line = "127.0.0.1 dev.local" } }
-```
-
-A string value is inline block content (TOML multiline strings keep larger
-blocks readable); a table value spells out the operation. Dotted keys work
-too when you prefer one entry per line:
-
-```toml
-[system.edits]
-"~/.zshrc".activate = 'eval "$(mise activate zsh)"'
-"~/.gitconfig".identity = { source = "snippets/git-identity.tmpl", template = "tera" }
-```
-
-## Blocks
-
-A `block` is delimited by marker comments in the target file, named by the
-entry's id:
-
-```sh
-# >>> mise:activate >>> managed by mise — do not edit between markers
-eval "$(mise activate zsh)"
-# <<< mise:activate <<<
-```
-
-The markers are the ownership record, stored in the file itself, so the
-design stays stateless: applying replaces only what's between them (or
-appends the block if absent), and everything else in the file is untouched.
-Content can come from three places:
-
-```toml
-[system.edits]
-"~/.zshrc" = {
- activate = "...", # inline (string shorthand)
- aliases = { source = "snippets/aliases.sh" }, # from a file, relative to this config
- prompt = { source = "snippets/prompt.tmpl", template = "tera" }, # rendered with the template engine
-}
-```
-
-Ids may contain letters, digits, `_`, `-`, and `.`. The marker comment
-prefix is inferred from the file extension (`#` for shell/config files,
-`--` for Lua, `//` for C-like languages, `;` for INI, `"` for vim) and can
-be overridden with `comment = "..."`. Files that can't hold line comments
-at all (strict JSON, XML) aren't a fit for blocks — use
-[System Files](/system-files.html) to own the whole file instead.
-
-`template = "tera"` names the engine rather than being a boolean so other
-engines can be added later; unknown engines from newer mise versions warn
-and are skipped, like unrecognized operations.
-
-Detecting whether a template block has drifted requires rendering it, so
-`mise system status` (and a real install) evaluates templates — including
-any `exec()` calls — from your trusted config, just like `[env]` templates.
-`--dry-run` is the exception: it promises to execute nothing, so it skips
-template rendering and lists those entries as `(if changed)`.
-
-## Lines
-
-A `line` ensures an exact line exists somewhere in the file, appending it at
-the end if absent. It never modifies or removes other lines, which is what
-makes it safely idempotent — use it for files where a three-line marker
-block is overkill or comments aren't tolerated. The value must be a single
-line (no embedded newline); use a block for multi-line content. The id is
-only a label (and the merge identity); it isn't written to the file.
-
-## Semantics
-
-Edits follow the same rules as the rest of [`[system]`](/system-packages/):
-
-- **Declarative and additive** — entries merge across the
- [config hierarchy](/configuration.html) (global → project) as a union,
- keyed by `(path, id)`; a more local config overrides an edit with the
- same id, exactly like [System Files](/system-files.html) overrides by
- target.
-- **Manual application only** — nothing is written implicitly. Only
- `mise system install` (or [`mise bootstrap`](/cli/bootstrap.html)) applies
- edits.
-- **Idempotent** — entries already in their desired state are skipped;
- re-running is always safe.
-- **Surgical** — edits never create conflicts with existing content and
- never need `--force`: a block owns only what's between its markers, a
- line only ever appends. Two cases are refused with an error instead of
- guessed at: corrupted markers (a begin without an end, or duplicates) and
- targets that are symlinks — an edit through a symlink would modify
- whatever the link points at (often a `[system.files]` source), so point
- the edit at the real file instead.
-
-Removing an entry from config leaves its block or line in the file (mise
-keeps no state database); delete it by hand. Blocks at least carry their
-provenance — the markers name mise and the id — while a stray line looks
-like any other, which is a reason to prefer blocks for anything
-non-obvious.
-
-## Commands
-
-```sh
-mise system status # shows edit state: applied/missing/differs
-mise system status --missing # exit 1 if anything is missing (CI check)
-
-mise system install # packages, then files, then edits (prompts first)
-mise system install --dry-run # print what would be done
-mise system install --yes # skip the confirmation prompt
-```
-
-`mise system status` reports each edit as `applied`, `missing` (no markers
-or line yet), `differs` (block content changed, corrupted markers, or a
-symlink target), or `source missing` (a block whose `source` file doesn't
-exist).
-
-## Root-owned files
-
-Edits write as the current user — there is no sudo here. Editing
-`/etc/hosts` works when running as root (containers, CI); otherwise mise
-fails with an ordinary permission error.
diff --git a/docs/system-files.md b/docs/system-files.md
deleted file mode 100644
index 4e1dbc7e95..0000000000
--- a/docs/system-files.md
+++ /dev/null
@@ -1,102 +0,0 @@
-# System Files
-
-mise can place config files (dotfiles) at machine-global paths via the
-`[system.files]` section of `mise.toml`:
-
-```toml
-[system.files]
-"~/.gitconfig" = "dotfiles/gitconfig" # symlink (default)
-"~/.config/starship.toml" = { source = "dotfiles/starship.toml", mode = "copy" }
-"~/.ssh/config" = { source = "dotfiles/ssh_config.tmpl", mode = "template" }
-"~/.config/nvim" = "dotfiles/nvim" # symlink the directory itself
-"~/.local/bin" = { source = "dotfiles/bin", mode = "symlink-each" } # symlink each file within
-"~/.config/*.toml" = { source = "dotfiles/config/*.toml", mode = "copy" }
-```
-
-Each entry is keyed by the target path — absolute or starting with `~/` —
-and points at a source file or directory. Relative sources resolve against
-the directory of the config file that declares the entry, so a global
-`~/.config/mise/config.toml` can manage dotfiles kept next to it, and a
-project config can ship machine setup from the repo.
-
-To manage one piece of a file something else owns (a line in `.zshrc`, an
-entry in `/etc/hosts`) rather than the whole file, see
-[System Edits](/system-edits.html).
-
-Source paths may contain glob wildcards like `*`, `**`, `?`, or `[ab]`.
-When a wildcard source matches multiple paths, the target path must contain
-matching wildcards so each source expands to a unique target:
-
-```toml
-[system.files]
-"~/.config/*.toml" = "dotfiles/config/*.toml"
-"~/.local/share/app/**/*.json" = { source = "dotfiles/app/**/*.json", mode = "copy" }
-"~/.config/app?.toml" = "dotfiles/config/app?.toml"
-"~/.config/theme-[ab].toml" = "dotfiles/config/theme-[ab].toml"
-```
-
-## Modes
-
-| Mode | Behavior |
-| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `symlink` | Symlink the target to the source. Works for files and directories — a directory source gets one link for the whole directory. This is the default. |
-| `symlink-each` | Source must be a directory: recreate its directory structure under the target and symlink each file individually, so the target directory (say, `~/.config`) can also hold files mise doesn't manage. |
-| `copy` | Copy the source file (or directory, recursively). Use when the target must be a real file — e.g. tools that rewrite their config in place. Directory copies are additive: matching files are overwritten, files mise doesn't manage are left in place. |
-| `template` | Render the source through the [mise template engine](/templates.html) and write the result. Permissions are taken from the source file (and repaired if they drift). |
-
-Templates get the same context as other mise templates (`env`, `vars`,
-`exec()`, etc.), which is the main reason to use them: one source file,
-per-machine output.
-
-Detecting whether a template's output has drifted requires rendering it, so
-`mise system status` (and a real install) evaluates templates — including
-any `exec()` calls — from your trusted config, just like `[env]` templates.
-`--dry-run` is the exception: it promises to execute nothing, so it skips
-template rendering and lists those entries as `(if changed)`.
-
-## Semantics
-
-Files follow the same rules as [system packages](/system-packages/):
-
-- **Declarative and additive** — entries merge across the
- [config hierarchy](/configuration.html) (global → project) as a union of
- target keys; a more local config overrides an entry for the same target.
-- **Manual application only** — nothing is written implicitly. Only
- `mise system install` (or [`mise bootstrap`](/cli/bootstrap.html)) applies
- files.
-- **Idempotent** — entries already in their desired state are skipped;
- re-running is always safe.
-- **Unknown modes are ignored with a warning** so configs using modes from
- newer mise versions still parse.
-
-## Conflicts
-
-mise refuses to _replace_ existing files it doesn't manage: a real file or
-directory where a symlink should go, or a directory where a file should go,
-is an error listing the conflicting paths. Pass
-`mise system install --force` to replace them.
-
-Content updates are not conflicts: a `copy` or `template` entry overwrites
-the target file's content without `--force` — that is the declared intent of
-those modes. Symlinks are re-pointed freely, since a symlink is never data.
-
-## Commands
-
-```sh
-mise system status # shows file state: applied/missing/differs
-mise system status --missing # exit 1 if anything is out of sync (CI check)
-
-mise system install # install packages, then apply files (prompts first)
-mise system install --dry-run # print what would be done
-mise system install --yes # skip the confirmation prompt
-mise system install --force # also replace conflicting files
-```
-
-`mise system status` reports each entry as `applied`, `missing`, `differs`
-(with a reason: re-pointed symlink, changed content, type conflict), or
-`source missing`.
-
-## Windows
-
-File symlinks require elevation on Windows, so `symlink` and `symlink-each`
-fall back to copying for files there; directory symlinks use junctions.
diff --git a/docs/tips-and-tricks.md b/docs/tips-and-tricks.md
index 4d1eeb4b2f..a1dbeeb9c8 100644
--- a/docs/tips-and-tricks.md
+++ b/docs/tips-and-tricks.md
@@ -74,29 +74,28 @@ mise generate task-stubs --mise-bin ./bin/mise
The generated task stubs behave like small project commands, while `bin/mise`
downloads and runs the pinned mise binary for the project.
-## Machine bootstrapping with `[system]`
+## Machine bootstrapping
-Beyond `[tools]`, the `[system]` config section can declare everything else a
-machine needs, and [`mise bootstrap`](/cli/bootstrap.html) converges all of
-it in one command — system packages, then files and edits, then tools, then
-a `bootstrap` task if you define one:
+Beyond `[tools]`, mise can declare the rest of the machine setup needed for
+a project or workstation, and [`mise bootstrap`](/cli/bootstrap.html)
+converges it in one command — system packages, then dotfiles, then macOS
+defaults, then login shell, then tools, then a
+`bootstrap` task if you define one:
```toml
-[system.packages] # OS packages (apt/dnf/pacman/brew)
+[bootstrap.packages] # OS packages (apt/dnf/pacman/brew)
"apt:build-essential" = "latest"
"brew:postgresql@17" = "latest"
-[system.files] # dotfiles: symlink/copy/template
-"~/.gitconfig" = "dotfiles/gitconfig"
-"~/.config/nvim" = "dotfiles/nvim"
+[dotfiles] # dotfiles: symlink/copy/template
+"~/.gitconfig" = { mode = "symlink" }
+"~/.config/nvim" = { mode = "symlink" }
+"~/.zshrc/activate" = { block = 'eval "$(mise activate zsh)"' }
-[system.edits] # one piece of a file you don't own
-"~/.zshrc".activate = 'eval "$(mise activate zsh)"'
+[bootstrap.macos.defaults] # macOS defaults write
+"com.apple.dock" = { autohide = true }
-[system.defaults] # macOS defaults write
-"com.apple.dock.autohide" = true
-
-[system] # current user's login shell
+[bootstrap.user] # current user's login shell
login_shell = "/bin/zsh"
[tasks.bootstrap] # anything else, with tools on PATH
@@ -108,12 +107,12 @@ mise bootstrap --yes # new laptop or container -> ready to work
```
Everything is declarative and idempotent: re-running skips whatever is
-already in its desired state, `mise system status --missing` makes a CI
-check, and nothing is ever applied implicitly. See
-[System Packages](/system-packages/), [System Files](/system-files.html),
-[System Edits](/system-edits.html), and
-[macOS Defaults](/system-packages/defaults.html), and
-[System Login Shell](/system-login-shell.html).
+already in its desired state, `mise bootstrap packages status --missing` and
+`mise dotfiles status --missing` make CI checks, and nothing is ever applied
+implicitly. See
+[Bootstrap](/bootstrap.html), [Bootstrap Packages](/bootstrap/packages/),
+[Dotfiles](/dotfiles.html), [macOS Defaults](/bootstrap/macos-defaults.html),
+and [User Login Shell](/bootstrap/user.html).
## Installation via zsh zinit
diff --git a/e2e/cli/test_bootstrap b/e2e/cli/test_bootstrap
index 21fd2d8714..529668e230 100644
--- a/e2e/cli/test_bootstrap
+++ b/e2e/cli/test_bootstrap
@@ -6,18 +6,16 @@ cat <mise.toml
EOF
assert_succeed "mise bootstrap --yes"
-# bootstrap applies system files and edits, installs tools, and runs the
+# bootstrap applies dotfiles, installs tools, and runs the
# `bootstrap` task afterwards, with the installed tools on PATH
echo "gitconfig content" >gitconfig
cat <<'EOF' >mise.toml
[tools]
tiny = "1.0.0"
-[system.files]
+[dotfiles]
"~/.gitconfig" = "gitconfig"
-
-[system.edits]
-"~/.zshrc".activate = 'eval "$(mise activate zsh)"'
+"~/.zshrc/activate" = { block = 'eval "$(mise activate zsh)"' }
# the task only succeeds if the installed tool is on PATH
[tasks.bootstrap]
@@ -47,7 +45,7 @@ assert_fail "cat bootstrap_ran"
# unavailable system package managers are skipped, not errors
cat <mise.toml
-[system.packages]
+[bootstrap.packages]
"apt:bc" = "latest"
"dnf:bc" = "latest"
"pacman:bc" = "latest"
diff --git a/e2e/cli/test_dotfiles_edits b/e2e/cli/test_dotfiles_edits
new file mode 100644
index 0000000000..3a678b54c2
--- /dev/null
+++ b/e2e/cli/test_dotfiles_edits
@@ -0,0 +1,204 @@
+#!/usr/bin/env bash
+# the single-quoted activate lines are intentionally literal
+# shellcheck disable=SC2016
+
+cat <<'EOF' >mise.toml
+[dotfiles]
+"~/.zshrc/activate" = { block = 'eval "$(mise activate zsh)"' }
+"~/.zshrc/aliases" = { block = '''
+alias ll='ls -l'
+alias la='ls -la'
+''' }
+"~/hosts/dev" = { line = "127.0.0.1 dev.local" }
+"~/init.lua/mise" = { block = "require('mise')" }
+"~/rendered.conf/sum" = { block = "sum = {{ 1 + 1 }}", template = "tera" }
+"~/execd.conf/execd" = { block = '{{ exec(command="touch $HOME/exec-ran") }}ok', template = "tera" }
+EOF
+
+# everything is missing before applying
+assert_contains "mise dotfiles status" "missing"
+assert_contains "mise dotfiles status" "block:activate"
+assert_contains "mise dotfiles status" "line:dev"
+assert_fail "mise dotfiles status --missing"
+
+# dry-run prints actions without writing anything; template blocks are not
+# rendered (exec() must not run) and are listed as "(if changed)".
+# status, by contrast, renders templates by design — clear its exec traces
+rm -f ~/exec-ran
+dry_out="$(mise dotfiles apply --dry-run 2>&1)"
+assert_contains_text "$dry_out" "block:activate"
+assert_contains_text "$dry_out" "(if changed)"
+assert_fail "cat ~/.zshrc"
+assert_fail "cat ~/exec-ran"
+
+# line edits append to existing files without touching other content
+echo "preexisting content" >~/hosts
+
+assert_succeed "mise dotfiles apply --yes"
+
+# two blocks coexist in one file, delimited by id'd markers; multiline
+# block content lands verbatim
+assert_contains "cat ~/.zshrc" ">>> mise:activate >>>"
+assert_contains "cat ~/.zshrc" 'eval "$(mise activate zsh)"'
+assert_contains "cat ~/.zshrc" ">>> mise:aliases >>>"
+assert_contains "cat ~/.zshrc" "alias ll='ls -l'"
+assert_contains "cat ~/.zshrc" "alias la='ls -la'"
+# line was appended, existing content kept
+assert "head -1 ~/hosts" "preexisting content"
+assert_contains "cat ~/hosts" "127.0.0.1 dev.local"
+# comment prefix inferred from extension
+assert_contains "cat ~/init.lua" "-- >>> mise:mise >>>"
+# template rendered
+assert_contains "cat ~/rendered.conf" "sum = 2"
+# a real install does render templates — exec() ran this time
+assert_contains "cat ~/execd.conf" "ok"
+assert_succeed "test -f ~/exec-ran"
+assert_contains "mise dotfiles status ~/hosts/dev" "line:dev"
+echo "preexisting content" >~/hosts
+assert_succeed "mise dotfiles apply ~/hosts/dev --yes"
+assert_contains "cat ~/hosts" "127.0.0.1 dev.local"
+assert "grep -c 'mise:activate >>>' ~/.zshrc" "1"
+assert_fail "mise dotfiles add ~/.zshrc --yes" "already managed by [dotfiles] edits"
+assert_fail "EDITOR=true mise dotfiles edit ~/.zshrc" "multiple [dotfiles] edit entries match"
+
+# idempotent: re-running changes nothing
+assert_succeed "mise dotfiles status --missing"
+assert_contains "mise dotfiles apply --yes 2>&1" "all edits are applied"
+assert "grep -c 'dev.local' ~/hosts" "1"
+assert "grep -c 'mise:activate >>>' ~/.zshrc" "1"
+
+# content changes are detected and replace only the block, preserving
+# everything around it
+echo "# user content at the end" >>~/.zshrc
+cat <<'EOF' >mise.toml
+[dotfiles]
+"~/.zshrc/activate" = { block = 'eval "$(mise activate zsh --shims)"' }
+"~/.zshrc/aliases" = { block = "alias ll='ls -l'" }
+EOF
+assert_contains "mise dotfiles status" "differs (block content differs)"
+assert_succeed "mise dotfiles apply --yes"
+assert_contains "cat ~/.zshrc" "--shims"
+assert_not_contains "cat ~/.zshrc" 'eval "$(mise activate zsh)"'
+assert_contains "cat ~/.zshrc" "alias ll='ls -l'"
+assert_contains "cat ~/.zshrc" "# user content at the end"
+
+# content that merely mentions a marker is not treated as one
+cat <<'EOF' >mise.toml
+[dotfiles]
+"~/.zshrc/activate" = { block = 'echo "keep the >>> mise:activate >>> line intact"' }
+"~/.zshrc/aliases" = { block = "alias ll='ls -l'" }
+EOF
+assert_succeed "mise dotfiles apply --yes"
+assert_succeed "mise dotfiles status --missing"
+cat <<'EOF' >mise.toml
+[dotfiles]
+"~/.zshrc/activate" = { block = 'eval "$(mise activate zsh --shims)"' }
+"~/.zshrc/aliases" = { block = "alias ll='ls -l'" }
+EOF
+assert_succeed "mise dotfiles apply --yes"
+
+# corrupted markers are an error, not a guess
+sed -i.bak '/<<< mise:activate <<real.conf
+ln -s "$PWD/real.conf" ~/linked.conf
+cat <<'EOF' >mise.toml
+[dotfiles]
+"~/linked.conf/added" = { line = "added" }
+EOF
+assert_contains "mise dotfiles status" "differs (target is a symlink"
+assert_fail "mise dotfiles apply --yes"
+assert "cat real.conf" "real file"
+
+# block content can come from a source file
+echo "source content" >snippet.txt
+cat <<'EOF' >mise.toml
+[dotfiles]
+"~/fromfile.conf/snippet" = { source = "snippet.txt", template = "tera" }
+EOF
+assert_succeed "mise dotfiles apply --yes"
+assert_contains "cat ~/fromfile.conf" "source content"
+# missing sources are visible and error on install
+rm snippet.txt
+assert_contains "mise dotfiles status" "source missing"
+assert_fail "mise dotfiles apply --yes"
+
+# two entries with different ids but identical line text don't write the
+# line twice in one batch
+cat <<'EOF' >mise.toml
+[dotfiles]
+"~/dup.conf/a" = { line = "same line" }
+"~/dup.conf/b" = { line = "same line" }
+EOF
+assert_succeed "mise dotfiles apply --yes"
+assert "grep -c 'same line' ~/dup.conf" "1"
+assert_succeed "mise dotfiles status --missing"
+
+# all problems are reported in one pass: a broken template doesn't hide a
+# missing source
+cat <<'EOF' >mise.toml
+[dotfiles]
+"~/broken.conf/tmpl" = { block = "{{ not_a_real_function() }}", template = "tera" }
+"~/missing.conf/snippet" = { source = "no-such-snippet.txt", template = "tera" }
+EOF
+if install_out="$(mise dotfiles apply --yes 2>&1)"; then
+ fail "[mise dotfiles apply --yes] expected failure"
+fi
+assert_contains_text "$install_out" "failed to render"
+assert_contains_text "$install_out" "source does not exist"
+
+# invalid entries warn but don't fail (forward compatibility)
+cat <<'EOF' >mise.toml
+[dotfiles]
+"~/x.conf/noop" = { something_else = true }
+EOF
+assert_succeed "mise dotfiles status"
+assert_contains "mise dotfiles status 2>&1" "no recognized operation"
+
+cat <<'EOF' >mise.toml
+[dotfiles]
+"~/x.conf/both" = { block = "a", line = "b" }
+EOF
+assert_succeed "mise dotfiles status"
+assert_contains "mise dotfiles status 2>&1" "mutually exclusive"
+
+# ids are restricted to marker-safe characters
+cat <<'EOF' >mise.toml
+[dotfiles]
+"~/x.conf/bad id!" = { block = "content" }
+EOF
+assert_succeed "mise dotfiles status"
+assert_contains "mise dotfiles status 2>&1" "ids may only contain"
+
+# unknown template engines warn but don't fail (forward compatibility)
+cat <<'EOF' >mise.toml
+[dotfiles]
+"~/x.conf/tmpl" = { block = "a", template = "jinja" }
+EOF
+assert_succeed "mise dotfiles status"
+assert_contains "mise dotfiles status 2>&1" "unknown template engine"
+
+# a multi-line line value can never converge — rejected, use a block instead
+cat <<'EOF' >mise.toml
+[dotfiles]
+"~/x.conf/multi" = { line = "first\nsecond" }
+EOF
+assert_succeed "mise dotfiles status"
+assert_contains "mise dotfiles status 2>&1" "line may not contain a newline"
+
+cat <<'EOF' >mise.toml
+[dotfiles]
+"~/x.conf" = true
+EOF
+assert_succeed "mise dotfiles status"
+assert_contains "mise dotfiles status 2>&1" "expected string or table entry"
+
+cat <<'EOF' >mise.toml
+[dotfiles]
+"~/x.conf/id" = { line = "x" }
+EOF
+assert_fail "mise dotfiles apply ~/missing/id --yes" "no dotfiles matched target filter"
diff --git a/e2e/cli/test_system_files b/e2e/cli/test_dotfiles_files
similarity index 50%
rename from e2e/cli/test_system_files
rename to e2e/cli/test_dotfiles_files
index e8ad23813a..2c961db7ee 100644
--- a/e2e/cli/test_system_files
+++ b/e2e/cli/test_dotfiles_files
@@ -9,7 +9,7 @@ echo "two" >dotfiles/bin/two
echo "sum = {{ 1 + 1 }}" >dotfiles/ssh_config.tmpl
cat <mise.toml
-[system.files]
+[dotfiles]
"~/.gitconfig" = "dotfiles/gitconfig"
"~/.config/starship.toml" = { source = "dotfiles/starship.toml", mode = "copy" }
"~/.ssh/config" = { source = "dotfiles/ssh_config.tmpl", mode = "template" }
@@ -18,14 +18,18 @@ cat <mise.toml
EOF
# everything is missing before applying
-assert_contains "mise system status" "missing"
-assert_fail "mise system status --missing"
+assert_contains "mise dotfiles status" "missing"
+assert_fail "mise dotfiles status --missing"
+
+# system commands do not manage dotfiles
+assert_succeed "mise bootstrap packages install --yes"
+assert_fail "cat ~/.gitconfig"
# dry-run prints actions without writing anything
-assert_contains "mise system install --dry-run" "ln -sf"
+assert_contains "mise dotfiles apply --dry-run" "ln -sf"
assert_fail "cat ~/.gitconfig"
-assert_succeed "mise system install --yes"
+assert_succeed "mise dotfiles apply --yes"
assert "readlink ~/.gitconfig" "$PWD/dotfiles/gitconfig"
assert "cat ~/.config/starship.toml" "starship config"
assert "cat ~/.ssh/config" "sum = 2"
@@ -33,6 +37,15 @@ assert "readlink ~/.config/nvim" "$PWD/dotfiles/nvim"
assert "readlink ~/.local/mybin/one" "$PWD/dotfiles/bin/one"
assert "readlink ~/.local/mybin/two" "$PWD/dotfiles/bin/two"
+# a table containing only source is a whole-file dotfile entry, not an edit
+echo "source-only table" >dotfiles/source-only
+cat <>mise.toml
+"~/.source-only" = { source = "dotfiles/source-only" }
+EOF
+assert_contains "mise dotfiles status ~/.source-only" "symlink"
+assert_succeed "mise dotfiles apply ~/.source-only --yes"
+assert "readlink ~/.source-only" "$PWD/dotfiles/source-only"
+
# wildcard sources expand to concrete target paths using matching target wildcards
mkdir -p dotfiles/config dotfiles/question dotfiles/classes dotfiles/tree/nested
echo "bat config" >dotfiles/config/bat.conf
@@ -46,7 +59,7 @@ cat <>mise.toml
"~/.local/classes/theme-[ab].conf" = { source = "dotfiles/classes/theme-[ab].conf", mode = "copy" }
"~/.local/tree/**/*.conf" = "dotfiles/tree/**/*.conf"
EOF
-assert_succeed "mise system install --yes"
+assert_succeed "mise dotfiles apply --yes"
assert "cat ~/.config/bat.conf" "bat config"
assert "cat ~/.local/question/app1.conf" "question config"
assert "cat ~/.local/classes/theme-a.conf" "class config"
@@ -57,30 +70,30 @@ assert "readlink ~/.local/tree/nested/tool.conf" "$PWD/dotfiles/tree/nested/tool
cat <>mise.toml
"~/.config/missing-*.conf" = "dotfiles/config/missing-*.conf"
EOF
-assert_succeed "mise system status"
-assert_contains "mise system status 2>&1" "source pattern matched no files"
-assert_succeed "mise system install --yes"
+assert_succeed "mise dotfiles status"
+assert_contains "mise dotfiles status 2>&1" "source pattern matched no files"
+assert_succeed "mise dotfiles apply --yes"
# symlink-each leaves room for files mise doesn't manage
touch ~/.local/mybin/unmanaged
# idempotent: everything reports applied and re-running is a no-op
-assert_succeed "mise system status --missing"
-assert_contains "mise system status" "applied"
-assert_contains "mise system install --yes 2>&1" "all files are applied"
+assert_succeed "mise dotfiles status --missing"
+assert_contains "mise dotfiles status" "applied"
+assert_contains "mise dotfiles apply --yes 2>&1" "all files are applied"
assert "ls ~/.local/mybin/unmanaged" "$HOME/.local/mybin/unmanaged"
# copy entries pick up source changes on reapply
echo "starship v2" >dotfiles/starship.toml
-assert_contains "mise system status" "differs"
-assert_succeed "mise system install --yes"
+assert_contains "mise dotfiles status" "differs"
+assert_succeed "mise dotfiles apply --yes"
assert "cat ~/.config/starship.toml" "starship v2"
# template permission drift is detected and repaired
chmod 644 ~/.ssh/config
chmod 600 dotfiles/ssh_config.tmpl
-assert_contains "mise system status" "permissions differ"
-assert_succeed "mise system install --yes"
+assert_contains "mise dotfiles status" "permissions differ"
+assert_succeed "mise dotfiles apply --yes"
case "$(uname -s)" in
Darwin) assert "stat -f %Lp ~/.ssh/config" "600" ;;
*) assert "stat -c %a ~/.ssh/config" "600" ;;
@@ -91,9 +104,9 @@ mkdir -p dotfiles/emptydir
cat <>mise.toml
"~/.local/emptydir" = { source = "dotfiles/emptydir", mode = "copy" }
EOF
-assert_succeed "mise system install --yes"
+assert_succeed "mise dotfiles apply --yes"
assert_directory_exists "$HOME/.local/emptydir"
-assert_succeed "mise system status --missing"
+assert_succeed "mise dotfiles status --missing"
# same for symlink-each, including a blocking file being a --force-able
# conflict rather than silently reported applied
@@ -102,10 +115,10 @@ cat <>mise.toml
"~/.local/emptylinks" = { source = "dotfiles/emptylinks", mode = "symlink-each" }
EOF
echo "blocker" >~/.local/emptylinks
-assert_fail "mise system install --yes"
-assert_succeed "mise system install --yes --force"
+assert_fail "mise dotfiles apply --yes"
+assert_succeed "mise dotfiles apply --yes --force"
assert_directory_exists "$HOME/.local/emptylinks"
-assert_succeed "mise system status --missing"
+assert_succeed "mise dotfiles status --missing"
# dry-run never renders templates (exec() in a template must not run)
cat <dotfiles/exec.tmpl
@@ -114,79 +127,169 @@ EOF
cat <>mise.toml
"~/.local/exec-out" = { source = "dotfiles/exec.tmpl", mode = "template" }
EOF
-assert_succeed "mise system install --dry-run"
+assert_succeed "mise dotfiles apply --dry-run"
assert_fail "ls exec-ran"
# a dangling symlink at a copy target is replaced, not an IO error
rm ~/.config/starship.toml
ln -s /nonexistent-source ~/.config/starship.toml
-assert_contains "mise system status" "differs"
-assert_succeed "mise system install --yes"
+assert_contains "mise dotfiles status" "differs"
+assert_succeed "mise dotfiles apply --yes"
assert "cat ~/.config/starship.toml" "starship v2"
# directory copies are additive: unmanaged files survive a reapply
cat <>mise.toml
"~/.local/copydir" = { source = "dotfiles/bin", mode = "copy" }
EOF
-assert_succeed "mise system install --yes"
+assert_succeed "mise dotfiles apply --yes"
assert "cat ~/.local/copydir/one" "one"
echo "keep me" >~/.local/copydir/unmanaged
echo "one v2" >dotfiles/bin/one
-assert_succeed "mise system install --yes"
+assert_succeed "mise dotfiles apply --yes"
assert "cat ~/.local/copydir/one" "one v2"
assert "cat ~/.local/copydir/unmanaged" "keep me"
# a real file where a symlink should go is a conflict: not clobbered
rm ~/.gitconfig
echo "precious" >~/.gitconfig
-assert_fail "mise system install --yes"
+assert_fail "mise dotfiles apply --yes"
assert "cat ~/.gitconfig" "precious"
# --force replaces it
-assert_succeed "mise system install --yes --force"
+assert_succeed "mise dotfiles apply --yes --force"
assert "readlink ~/.gitconfig" "$PWD/dotfiles/gitconfig"
# symlink-each: a real file where the target directory should go is also a
# conflict, not a raw filesystem error
rm -rf ~/.local/mybin
echo "blocker" >~/.local/mybin
-assert_fail "mise system install --yes"
+assert_fail "mise dotfiles apply --yes"
assert "cat ~/.local/mybin" "blocker"
-assert_succeed "mise system install --yes --force"
+assert_succeed "mise dotfiles apply --yes --force"
assert "readlink ~/.local/mybin/one" "$PWD/dotfiles/bin/one"
# unknown modes warn but don't fail (forward compatibility)
cat <mise.toml
-[system.files]
+[dotfiles]
"~/.gitconfig" = { source = "dotfiles/gitconfig", mode = "hardlink" }
EOF
-assert_succeed "mise system status"
-assert_contains "mise system status 2>&1" "unknown mode"
+assert_succeed "mise dotfiles status"
+assert_contains "mise dotfiles status 2>&1" "unknown mode"
# relative targets warn but don't fail
cat <mise.toml
-[system.files]
+[dotfiles]
"relative/path" = "dotfiles/gitconfig"
EOF
-assert_succeed "mise system status"
-assert_contains "mise system status 2>&1" "must be absolute"
+assert_succeed "mise dotfiles status"
+assert_contains "mise dotfiles status 2>&1" "must be absolute"
# missing sources are visible in status and error on install
cat <mise.toml
-[system.files]
+[dotfiles]
"~/.missing" = "dotfiles/does-not-exist"
EOF
-assert_contains "mise system status" "source missing"
-assert_fail "mise system install --yes"
+assert_contains "mise dotfiles status" "source missing"
+assert_fail "mise dotfiles apply --yes"
# all problems are reported in one pass: a broken template doesn't hide a
# missing source (or vice versa)
echo "{{ not_a_real_function() }}" >dotfiles/bad.tmpl
cat <mise.toml
-[system.files]
+[dotfiles]
"~/.broken" = { source = "dotfiles/bad.tmpl", mode = "template" }
"~/.missing" = "dotfiles/does-not-exist"
EOF
-assert_fail "mise system install --yes"
-install_out="$(mise system install --yes 2>&1 || true)"
+if install_out="$(mise dotfiles apply --yes 2>&1)"; then
+ fail "[mise dotfiles apply --yes] expected failure"
+fi
assert_contains_text "$install_out" "sources do not exist"
assert_contains_text "$install_out" "failed to render"
+
+# implied sources mirror home-relative paths under dotfiles.root and use
+# dotfiles.default_mode when mode is omitted
+mkdir -p implied-root/.config
+echo "implied content" >implied-root/.implied
+echo "implied starship" >implied-root/.config/implied-starship.toml
+cat <mise.toml
+[settings]
+dotfiles.root = "$PWD/implied-root"
+dotfiles.default_mode = "copy"
+
+[dotfiles]
+"~/.implied" = {}
+"~/.config/implied-starship.toml" = { mode = "copy" }
+EOF
+assert_contains "mise dotfiles status ~/.implied" "copy"
+assert_succeed "mise dotfiles apply ~/.implied --yes"
+assert "cat ~/.implied" "implied content"
+assert_fail "cat ~/.config/implied-starship.toml"
+assert_succeed "mise dotfiles apply --yes"
+assert "cat ~/.config/implied-starship.toml" "implied starship"
+echo "implied content v2" >implied-root/.implied
+verbose_out="$(mise dotfiles apply ~/.implied --dry-run --verbose 2>&1)"
+assert_contains_text "$verbose_out" "content differs"
+
+# add captures live files into dotfiles.root and writes explicit mode
+echo "captured content" >~/.captured
+MISE_DOTFILES_ROOT="$PWD/captured-root" MISE_DOTFILES_DEFAULT_MODE=symlink mise dotfiles add '~/.captured' --yes
+assert "cat captured-root/.captured" "captured content"
+assert_contains "cat ~/.config/mise/config.toml" '"~/.captured" = { mode = "symlink" }'
+rm ~/.config/mise/config.toml
+
+# add on an already-managed copied file stores live changes back to the source
+cat <mise.toml
+[settings]
+dotfiles.root = "$PWD/copied-root"
+
+[dotfiles]
+"~/.copied" = { mode = "copy" }
+EOF
+mkdir -p copied-root
+echo "source copy" >copied-root/.copied
+assert_succeed "mise dotfiles apply --yes"
+echo "live copy edit" >~/.copied
+assert_succeed "mise dotfiles add ~/.copied --yes"
+assert "cat copied-root/.copied" "live copy edit"
+mode_warn="$(mise dotfiles add --mode symlink ~/.copied --yes 2>&1)"
+assert_contains "echo \"$mode_warn\"" "--mode symlink was ignored"
+
+# edit opens the managed source, including symlink sources
+cat <<'EOF' >editor-write
+#!/usr/bin/env bash
+echo "edited source" >"$1"
+EOF
+chmod +x editor-write
+echo "before edit" >edit-source
+cat <mise.toml
+[dotfiles]
+"~/.edit-symlink" = { source = "edit-source", mode = "symlink" }
+EOF
+assert_succeed "mise dotfiles apply --yes"
+EDITOR="$PWD/editor-write" mise dotfiles edit ~/.edit-symlink
+assert "cat edit-source" "edited source"
+assert "cat ~/.edit-symlink" "edited source"
+assert_succeed "mise dotfiles add ~/.edit-symlink --yes"
+assert "cat edit-source" "edited source"
+
+# edit --apply updates copied targets after editing the source
+echo "copy before edit" >edit-copy-source
+cat <mise.toml
+[dotfiles]
+"~/.edit-copy" = { source = "edit-copy-source", mode = "copy" }
+EOF
+assert_succeed "mise dotfiles apply --yes"
+EDITOR="$PWD/editor-write" mise dotfiles edit --apply ~/.edit-copy
+assert "cat ~/.edit-copy" "edited source"
+
+# inline edit entries open the owning mise config file
+cat <<'EOF' >editor-append
+#!/usr/bin/env bash
+echo "# edited config" >>"$1"
+EOF
+chmod +x editor-append
+cat <mise.toml
+[dotfiles]
+"~/inline-edit/dev" = { line = "inline content" }
+EOF
+EDITOR="$PWD/editor-append" mise dotfiles edit ~/inline-edit
+assert_contains "cat mise.toml" "# edited config"
diff --git a/e2e/cli/test_system_defaults b/e2e/cli/test_system_defaults
index 637927ff31..437a64ea33 100644
--- a/e2e/cli/test_system_defaults
+++ b/e2e/cli/test_system_defaults
@@ -1,49 +1,45 @@
#!/usr/bin/env bash
cat <mise.toml
-[system.defaults.NSGlobalDomain]
-KeyRepeat = 2
-
-[system.defaults."com.apple.dock"]
-autohide = true
-tilesize = 48
-orientation = "left"
+[bootstrap.macos.defaults]
+NSGlobalDomain = { KeyRepeat = 2 }
+"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"'
+assert_succeed "mise bootstrap macos-defaults status"
+assert_contains "mise bootstrap macos-defaults status" "com.apple.dock"
+assert_contains "mise bootstrap macos-defaults status --json" '"macos_defaults"'
if [[ $(uname) != "Darwin" ]]; then
- assert_contains "mise system status" "skipped"
- assert_contains "mise system status --json" '"available": false'
+ assert_contains "mise bootstrap macos-defaults status" "skipped"
+ assert_contains "mise bootstrap macos-defaults status --json" '"available": false'
# unavailable entries don't count as missing (cross-platform configs)
- assert_succeed "mise system status --missing"
+ assert_succeed "mise bootstrap macos-defaults status --missing"
# install skips defaults silently off-macOS
- assert_succeed "mise system install --yes"
+ assert_succeed "mise bootstrap macos-defaults apply --yes"
fi
# dry-run never writes anything
-assert_succeed "mise system install --dry-run --yes"
+assert_succeed "mise bootstrap macos-defaults apply --dry-run --yes"
# unsupported value types warn but don't fail
cat <mise.toml
-[system.defaults."com.apple.dock"]
-future-array = [1, 2]
+[bootstrap.macos.defaults]
+"com.apple.dock" = { future-array = [1, 2] }
EOF
-assert_succeed "mise system status"
-assert_contains "mise system status 2>&1" "unsupported value type"
+assert_succeed "mise bootstrap macos-defaults status"
+assert_contains "mise bootstrap macos-defaults status 2>&1" "unsupported value type"
# a domain entry that isn't a table warns but doesn't fail
cat <mise.toml
-[system.defaults]
+[bootstrap.macos.defaults]
autohide = true
EOF
-assert_succeed "mise system status"
-assert_contains "mise system status 2>&1" "expected a table"
+assert_succeed "mise bootstrap macos-defaults status"
+assert_contains "mise bootstrap macos-defaults status 2>&1" "expected a table"
-# empty [system.defaults] section
+# empty [bootstrap.macos.defaults] section
cat <mise.toml
-[system.defaults]
+[bootstrap.macos.defaults]
EOF
-assert_succeed "mise system status"
+assert_succeed "mise bootstrap macos-defaults status"
diff --git a/e2e/cli/test_system_edits b/e2e/cli/test_system_edits
deleted file mode 100644
index c6cb2a4ec9..0000000000
--- a/e2e/cli/test_system_edits
+++ /dev/null
@@ -1,188 +0,0 @@
-#!/usr/bin/env bash
-# the single-quoted activate lines are intentionally literal
-# shellcheck disable=SC2016
-
-# the multiline inline table exercises mise's TOML 1.1 parsing; the dotted
-# keys are the one-entry-per-line spelling of the same structure
-cat <<'EOF' >mise.toml
-[system.edits]
-"~/.zshrc" = {
- activate = 'eval "$(mise activate zsh)"',
- aliases = '''
-alias ll='ls -l'
-alias la='ls -la'
-''',
-}
-"~/hosts".dev = { line = "127.0.0.1 dev.local" }
-"~/init.lua".mise = "require('mise')"
-"~/rendered.conf".sum = { block = "sum = {{ 1 + 1 }}", template = "tera" }
-"~/execd.conf".execd = { block = '{{ exec(command="touch $HOME/exec-ran") }}ok', template = "tera" }
-EOF
-
-# everything is missing before applying
-assert_contains "mise system status" "missing"
-assert_contains "mise system status" "block:activate"
-assert_contains "mise system status" "line:dev"
-assert_fail "mise system status --missing"
-
-# dry-run prints actions without writing anything; template blocks are not
-# rendered (exec() must not run) and are listed as "(if changed)".
-# status, by contrast, renders templates by design — clear its exec traces
-rm -f ~/exec-ran
-dry_out="$(mise system install --dry-run 2>&1)"
-assert_contains_text "$dry_out" "block:activate"
-assert_contains_text "$dry_out" "(if changed)"
-assert_fail "cat ~/.zshrc"
-assert_fail "cat ~/exec-ran"
-
-# line edits append to existing files without touching other content
-echo "preexisting content" >~/hosts
-
-assert_succeed "mise system install --yes"
-
-# two blocks coexist in one file, delimited by id'd markers; multiline
-# block content lands verbatim
-assert_contains "cat ~/.zshrc" ">>> mise:activate >>>"
-assert_contains "cat ~/.zshrc" 'eval "$(mise activate zsh)"'
-assert_contains "cat ~/.zshrc" ">>> mise:aliases >>>"
-assert_contains "cat ~/.zshrc" "alias ll='ls -l'"
-assert_contains "cat ~/.zshrc" "alias la='ls -la'"
-# line was appended, existing content kept
-assert "head -1 ~/hosts" "preexisting content"
-assert_contains "cat ~/hosts" "127.0.0.1 dev.local"
-# comment prefix inferred from extension
-assert_contains "cat ~/init.lua" "-- >>> mise:mise >>>"
-# template rendered
-assert_contains "cat ~/rendered.conf" "sum = 2"
-# a real install does render templates — exec() ran this time
-assert_contains "cat ~/execd.conf" "ok"
-assert_succeed "test -f ~/exec-ran"
-
-# idempotent: re-running changes nothing
-assert_succeed "mise system status --missing"
-assert_contains "mise system install --yes 2>&1" "all edits are applied"
-assert "grep -c 'dev.local' ~/hosts" "1"
-assert "grep -c 'mise:activate >>>' ~/.zshrc" "1"
-
-# content changes are detected and replace only the block, preserving
-# everything around it
-echo "# user content at the end" >>~/.zshrc
-cat <<'EOF' >mise.toml
-[system.edits]
-"~/.zshrc".activate = 'eval "$(mise activate zsh --shims)"'
-"~/.zshrc".aliases = "alias ll='ls -l'"
-EOF
-assert_contains "mise system status" "differs (block content differs)"
-assert_succeed "mise system install --yes"
-assert_contains "cat ~/.zshrc" "--shims"
-assert_not_contains "cat ~/.zshrc" 'eval "$(mise activate zsh)"'
-assert_contains "cat ~/.zshrc" "alias ll='ls -l'"
-assert_contains "cat ~/.zshrc" "# user content at the end"
-
-# content that merely mentions a marker is not treated as one
-cat <<'EOF' >mise.toml
-[system.edits]
-"~/.zshrc".activate = 'echo "keep the >>> mise:activate >>> line intact"'
-"~/.zshrc".aliases = "alias ll='ls -l'"
-EOF
-assert_succeed "mise system install --yes"
-assert_succeed "mise system status --missing"
-cat <<'EOF' >mise.toml
-[system.edits]
-"~/.zshrc".activate = 'eval "$(mise activate zsh --shims)"'
-"~/.zshrc".aliases = "alias ll='ls -l'"
-EOF
-assert_succeed "mise system install --yes"
-
-# corrupted markers are an error, not a guess
-sed -i.bak '/<<< mise:activate <<real.conf
-ln -s "$PWD/real.conf" ~/linked.conf
-cat <<'EOF' >mise.toml
-[system.edits]
-"~/linked.conf".added = { line = "added" }
-EOF
-assert_contains "mise system status" "differs (target is a symlink"
-assert_fail "mise system install --yes"
-assert "cat real.conf" "real file"
-
-# block content can come from a source file
-echo "source content" >snippet.txt
-cat <<'EOF' >mise.toml
-[system.edits]
-"~/fromfile.conf".snippet = { source = "snippet.txt" }
-EOF
-assert_succeed "mise system install --yes"
-assert_contains "cat ~/fromfile.conf" "source content"
-# missing sources are visible and error on install
-rm snippet.txt
-assert_contains "mise system status" "source missing"
-assert_fail "mise system install --yes"
-
-# two entries with different ids but identical line text don't write the
-# line twice in one batch
-cat <<'EOF' >mise.toml
-[system.edits]
-"~/dup.conf".a = { line = "same line" }
-"~/dup.conf".b = { line = "same line" }
-EOF
-assert_succeed "mise system install --yes"
-assert "grep -c 'same line' ~/dup.conf" "1"
-assert_succeed "mise system status --missing"
-
-# all problems are reported in one pass: a broken template doesn't hide a
-# missing source
-cat <<'EOF' >mise.toml
-[system.edits]
-"~/broken.conf".tmpl = { block = "{{ not_a_real_function() }}", template = "tera" }
-"~/missing.conf".snippet = { source = "no-such-snippet.txt" }
-EOF
-if install_out="$(mise system install --yes 2>&1)"; then
- fail "[mise system install --yes] expected failure"
-fi
-assert_contains_text "$install_out" "failed to render"
-assert_contains_text "$install_out" "source does not exist"
-
-# invalid entries warn but don't fail (forward compatibility)
-cat <<'EOF' >mise.toml
-[system.edits]
-"~/x.conf".noop = { something_else = true }
-EOF
-assert_succeed "mise system status"
-assert_contains "mise system status 2>&1" "no recognized operation"
-
-cat <<'EOF' >mise.toml
-[system.edits]
-"~/x.conf".both = { block = "a", line = "b" }
-EOF
-assert_succeed "mise system status"
-assert_contains "mise system status 2>&1" "mutually exclusive"
-
-# ids are restricted to marker-safe characters
-cat <<'EOF' >mise.toml
-[system.edits]
-"~/x.conf"."bad id!" = "content"
-EOF
-assert_succeed "mise system status"
-assert_contains "mise system status 2>&1" "ids may only contain"
-
-# unknown template engines warn but don't fail (forward compatibility)
-cat <<'EOF' >mise.toml
-[system.edits]
-"~/x.conf".tmpl = { block = "a", template = "jinja" }
-EOF
-assert_succeed "mise system status"
-assert_contains "mise system status 2>&1" "unknown template engine"
-
-# a multi-line line value can never converge — rejected, use a block instead
-cat <<'EOF' >mise.toml
-[system.edits]
-"~/x.conf".multi = { line = "first\nsecond" }
-EOF
-assert_succeed "mise system status"
-assert_contains "mise system status 2>&1" "line may not contain a newline"
diff --git a/e2e/cli/test_system_install_apt b/e2e/cli/test_system_install_apt
index c12111ab22..88e62f928e 100644
--- a/e2e/cli/test_system_install_apt
+++ b/e2e/cli/test_system_install_apt
@@ -10,39 +10,39 @@ if [[ "$(uname)" != "Linux" ]] || ! command -v apt-get >/dev/null || [[ "$(id -u
fi
cat <mise.toml
-[system.packages]
+[bootstrap.packages]
"apt:bc" = "latest"
EOF
apt-get remove -y bc >/dev/null 2>&1 || true
-assert_contains "mise system status" "missing"
-assert_fail "mise system status --missing"
+assert_contains "mise bootstrap packages status" "missing"
+assert_fail "mise bootstrap packages status --missing"
# dry-run prints the command without installing
-assert_contains "mise system install --dry-run" "apt-get install -y -- bc"
-assert_contains "mise system status" "missing"
+assert_contains "mise bootstrap packages install --dry-run" "apt-get install -y -- bc"
+assert_contains "mise bootstrap packages status" "missing"
# explicit manager:package specs work, configured or not
-assert_contains "mise system install apt:bc --dry-run" "apt-get install -y -- bc"
+assert_contains "mise bootstrap packages install apt:bc --dry-run" "apt-get install -y -- bc"
-mise system install --yes
-assert_contains "mise system status" "installed"
-assert_succeed "mise system status --missing"
+mise bootstrap packages install --yes
+assert_contains "mise bootstrap packages status" "installed"
+assert_succeed "mise bootstrap packages status --missing"
assert_succeed "bc --version"
# second install is a no-op
-assert_contains "mise system install --yes 2>&1" "already installed"
+assert_contains "mise bootstrap packages install --yes 2>&1" "already installed"
# upgrade refreshes lists and only touches installed packages
-assert_contains "mise system upgrade --dry-run" "apt-get update"
-assert_contains "mise system upgrade --dry-run" "apt-get install -y --only-upgrade -- bc"
-assert_succeed "mise system upgrade --yes"
+assert_contains "mise bootstrap packages upgrade --dry-run" "apt-get update"
+assert_contains "mise bootstrap packages upgrade --dry-run" "apt-get install -y --only-upgrade -- bc"
+assert_succeed "mise bootstrap packages upgrade --yes"
# `use` writes the config entry and installs in one step
rm mise.toml
apt-get remove -y bc >/dev/null 2>&1 || true
-assert_succeed "mise system use --yes apt:bc"
+assert_succeed "mise bootstrap packages use --yes apt:bc"
assert_contains "cat mise.toml" '"apt:bc" = "latest"'
-assert_contains "mise system status" "installed"
+assert_contains "mise bootstrap packages status" "installed"
assert_succeed "bc --version"
diff --git a/e2e/cli/test_system_install_brew_linux b/e2e/cli/test_system_install_brew_linux
index bab953a953..0363825634 100644
--- a/e2e/cli/test_system_install_brew_linux
+++ b/e2e/cli/test_system_install_brew_linux
@@ -15,17 +15,17 @@ esac
trap 'rm -rf /home/linuxbrew' EXIT
cat <mise.toml
-[system.packages]
+[bootstrap.packages]
"brew:xz" = "latest"
EOF
-assert_contains "mise system status" "missing"
+assert_contains "mise bootstrap packages status" "missing"
-mise system install --yes
-assert_contains "mise system status" "installed"
+mise bootstrap packages install --yes
+assert_contains "mise bootstrap packages status" "installed"
# the poured binary runs at the canonical prefix (relocation + linking worked)
assert_contains "/home/linuxbrew/.linuxbrew/bin/xz --version" "xz"
# idempotent
-assert_contains "mise system install --yes 2>&1" "already"
+assert_contains "mise bootstrap packages install --yes 2>&1" "already"
diff --git a/e2e/cli/test_system_install_brew_source_slow b/e2e/cli/test_system_install_brew_source_slow
index 4a68525ef0..358cd87c56 100644
--- a/e2e/cli/test_system_install_brew_source_slow
+++ b/e2e/cli/test_system_install_brew_source_slow
@@ -16,19 +16,19 @@ export MISE_SYSTEM_BREW_PREFIX="$HOME/brew-source-prefix"
export MISE_SYSTEM_BREW_FORCE_SOURCE=hello
cat <mise.toml
-[system.packages]
+[bootstrap.packages]
"brew:hello" = "latest"
EOF
-assert_contains "mise system status" "missing"
-assert_contains "mise system install --dry-run" "build hello/"
+assert_contains "mise bootstrap packages status" "missing"
+assert_contains "mise bootstrap packages install --dry-run" "build hello/"
-mise system install --yes
-assert_contains "mise system status" "installed"
+mise bootstrap packages install --yes
+assert_contains "mise bootstrap packages status" "installed"
# the built binary runs from the prefix and the keg carries a receipt
assert_contains "$MISE_SYSTEM_BREW_PREFIX/bin/hello --greeting=mise" "mise"
assert_contains "cat $MISE_SYSTEM_BREW_PREFIX/Cellar/hello/*/INSTALL_RECEIPT.json" '"poured_from_bottle":false'
# idempotent
-assert_contains "mise system install --yes 2>&1" "already"
+assert_contains "mise bootstrap packages install --yes 2>&1" "already"
diff --git a/e2e/cli/test_system_login_shell b/e2e/cli/test_system_login_shell
index ba0bf8785c..5cdbe90598 100644
--- a/e2e/cli/test_system_login_shell
+++ b/e2e/cli/test_system_login_shell
@@ -11,69 +11,72 @@ chmod +x "$HOME/bin/chsh"
export MISE_TEST_SHELLS_FILE="$PWD/shells"
cat <mise.toml
-[system]
+[bootstrap.user]
login_shell = "/bin/sh"
EOF
-current="$(mise system status --json | jq -r '.login_shell.current')"
+current="$(mise bootstrap user status --json | jq -r '.login_shell.current')"
assert_not_empty "printf '%s' '$current'"
printf '%s\n' "$current" >"$MISE_TEST_SHELLS_FILE"
-# Matching login shell is in sync and install is a no-op.
+# Matching login shell is in sync and bootstrap is a no-op.
cat <mise.toml
-[system]
+[bootstrap.user]
login_shell = "$current"
EOF
-assert_succeed "mise system status --missing"
-assert_contains "mise system status --json" '"state": "set"'
-assert_succeed "mise system install --yes"
+assert_succeed "mise bootstrap user status --missing"
+assert_contains "mise bootstrap user status --json" '"state": "set"'
+assert_succeed "mise bootstrap --yes"
assert_fail "test -f '$CHSH_LOG'"
# A local config overrides the global login shell.
cat <"$MISE_CONFIG_DIR/config.toml"
-[system]
+[bootstrap.user]
login_shell = "/tmp/global-shell"
EOF
cat <mise.toml
-[system]
+[bootstrap.user]
login_shell = " /tmp/local-shell "
EOF
-assert_contains "mise system status --json | jq -r '.login_shell.shell'" "/tmp/local-shell"
+assert_contains "mise bootstrap user status --json | jq -r '.login_shell.shell'" "/tmp/local-shell"
rm "$MISE_CONFIG_DIR/config.toml"
# Invalid local values are skipped, so a valid global value can still apply.
cat <"$MISE_CONFIG_DIR/config.toml"
-[system]
+[bootstrap.user]
login_shell = "/tmp/global-shell"
EOF
cat <mise.toml
-[system]
+[bootstrap.user]
login_shell = "relative-shell"
EOF
-assert_contains "mise system status --json 2>&1" "shell must be an absolute path"
-assert_contains "mise system status --json | jq -r '.login_shell.shell'" "/tmp/global-shell"
+assert_contains "mise bootstrap user status --json 2>&1" "shell must be an absolute path"
+assert_contains "mise bootstrap user status --json | jq -r '.login_shell.shell'" "/tmp/global-shell"
rm "$MISE_CONFIG_DIR/config.toml"
-# Dry-run prints the chsh command without calling it.
+# package install does not change the login shell; bootstrap does.
cat <mise.toml
-[system]
+[bootstrap.user]
login_shell = "/tmp/mise-test-shell"
EOF
-assert_fail "mise system status --missing"
-assert_contains "mise system status --json" '"shell_listed": false'
-assert_contains "mise system install --dry-run --yes" "chsh -s /tmp/mise-test-shell"
+assert_fail "mise bootstrap user status --missing"
+assert_contains "mise bootstrap user status --json" '"shell_listed": false'
+assert_succeed "mise bootstrap packages install --dry-run --yes"
+assert_not_contains "mise bootstrap packages install --dry-run --yes" "chsh -s /tmp/mise-test-shell"
+assert_contains "mise bootstrap user apply --dry-run --yes" "chsh -s /tmp/mise-test-shell"
+assert_contains "mise bootstrap --dry-run --yes" "chsh -s /tmp/mise-test-shell"
assert_fail "test -f '$CHSH_LOG'"
assert_fail "grep -q /tmp/mise-test-shell '$MISE_TEST_SHELLS_FILE'"
# Real apply lists the shell first, then invokes chsh with the requested shell.
-assert_succeed "mise system install --yes"
+assert_succeed "mise bootstrap user apply --yes"
assert_contains "cat '$MISE_TEST_SHELLS_FILE'" "/tmp/mise-test-shell"
assert "cat '$CHSH_LOG'" "-s /tmp/mise-test-shell"
-# Invalid values warn and are skipped like other malformed [system] entries.
+# Invalid values warn and are skipped like other malformed [bootstrap.user] entries.
cat <mise.toml
-[system]
+[bootstrap.user]
login_shell = "relative-shell"
EOF
-assert_succeed "mise system status"
-assert_contains "mise system status 2>&1" "shell must be an absolute path"
+assert_succeed "mise bootstrap user status"
+assert_contains "mise bootstrap user status 2>&1" "shell must be an absolute path"
diff --git a/e2e/cli/test_system_status b/e2e/cli/test_system_status
index 809e01a8e6..f814c480ae 100644
--- a/e2e/cli/test_system_status
+++ b/e2e/cli/test_system_status
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
cat <mise.toml
-[system.packages]
+[bootstrap.packages]
"apt:bc" = "latest"
"brew:jq" = "latest"
"dnf:bc" = "latest"
@@ -9,34 +9,34 @@ cat <mise.toml
EOF
# status renders on any platform; unavailable managers are skipped, not errors
-assert_succeed "mise system status"
-assert_contains "mise system status" "bc"
-assert_contains "mise system status --json" '"apt"'
+assert_succeed "mise bootstrap packages status"
+assert_contains "mise bootstrap packages status" "bc"
+assert_contains "mise bootstrap packages status --json" '"apt"'
# unknown managers warn but don't fail
cat <mise.toml
-[system.packages]
+[bootstrap.packages]
"not-a-real-manager:whatever" = "latest"
EOF
-assert_succeed "mise system status"
-assert_contains "mise system status 2>&1" "unknown system package manager"
+assert_succeed "mise bootstrap packages status"
+assert_contains "mise bootstrap packages status 2>&1" "unknown bootstrap package manager"
# the manager prefix is required; entries without one warn but don't fail
cat <mise.toml
-[system.packages]
+[bootstrap.packages]
"noprefix" = "latest"
EOF
-assert_succeed "mise system status"
-assert_contains "mise system status 2>&1" "invalid system package spec"
+assert_succeed "mise bootstrap packages status"
+assert_contains "mise bootstrap packages status 2>&1" "invalid system package spec"
-# empty [system.packages] section
+# empty [bootstrap.packages] section
cat <mise.toml
-[system.packages]
+[bootstrap.packages]
EOF
-assert_succeed "mise system status"
+assert_succeed "mise bootstrap packages status"
# no [system] section at all
cat <mise.toml
[tools]
EOF
-assert_succeed "mise system status"
+assert_succeed "mise bootstrap packages status"
diff --git a/e2e/cli/test_system_use b/e2e/cli/test_system_use
index 106a848d69..2cd64e719b 100644
--- a/e2e/cli/test_system_use
+++ b/e2e/cli/test_system_use
@@ -1,25 +1,25 @@
#!/usr/bin/env bash
-# `mise system use` config-writing works on any platform — managers that
+# `mise bootstrap packages use` config-writing works on any platform — managers that
# aren't available on this machine are added to the config without installing
# (brew specs are avoided here: even a dry-run resolves the formula closure
# over the network)
# dry-run prints what would be written without creating the file
-assert_contains "mise system use --dry-run apt:zlib1g-dev" '"apt:zlib1g-dev" = "latest"'
+assert_contains "mise bootstrap packages use --dry-run apt:zlib1g-dev" '"apt:zlib1g-dev" = "latest"'
assert_fail "cat mise.toml"
# version pins use @, like mise use
-assert_contains "mise system use --dry-run apt:curl@8.5.0-2" '"apt:curl" = "8.5.0-2"'
+assert_contains "mise bootstrap packages use --dry-run apt:curl@8.5.0-2" '"apt:curl" = "8.5.0-2"'
# bad specs and unknown managers fail before anything is written
-assert_fail "mise system use noprefix"
-assert_fail "mise system use not-a-real-manager:pkg"
+assert_fail "mise bootstrap packages use noprefix"
+assert_fail "mise bootstrap packages use not-a-real-manager:pkg"
assert_fail "cat mise.toml"
# writes the entry even when the manager isn't available on this machine
# (guarded so a real Arch box doesn't actually install ripgrep)
if ! command -v pacman >/dev/null; then
- assert_succeed "mise system use --yes pacman:ripgrep"
+ assert_succeed "mise bootstrap packages use --yes pacman:ripgrep"
assert_contains "cat mise.toml" '"pacman:ripgrep" = "latest"'
fi
diff --git a/e2e/oci/test_oci_build_slow b/e2e/oci/test_oci_build_slow
index e8b9224022..5bf08d1894 100644
--- a/e2e/oci/test_oci_build_slow
+++ b/e2e/oci/test_oci_build_slow
@@ -100,7 +100,7 @@ mf_oci="$(jq -r '.manifests[0].digest | ltrimstr("sha256:")' ./out-oci-scope/ind
config_oci_digest="$(jq -r '.config.digest | ltrimstr("sha256:")' "./out-oci-scope/blobs/sha256/$mf_oci")"
assert_not_contains "jq -r '.config.Env | join(\"\n\")' ./out-oci-scope/blobs/sha256/$config_oci_digest" "/should-not-appear"
-# --- 9. [system.files] is baked into its own image layer ---
+# --- 9. [dotfiles] is baked into its own image layer ---
cat >profile.sh <mise.toml <mise.toml < []
+.PP
+\fBOptions:\fR
+.PP
+.TP
+\fB\-n, \-\-dry\-run\fR
+Print the command that would run without running it
+\fBArguments:\fR
+.PP
+.TP
+\fB\fR
+Tap name, e.g. `owner/repo`
+.TP
+\fB\fR
+Git URL for non\-GitHub or otherwise custom taps
+.SH "MISE BOOTSTRAP PACKAGES BREW UNTAP"
+Untap Homebrew formula repositories
+.PP
+\fBUsage:\fR mise bootstrap packages brew untap [OPTIONS] ...
+.PP
+\fBOptions:\fR
+.PP
+.TP
+\fB\-n, \-\-dry\-run\fR
+Print the command that would run without running it
+\fBArguments:\fR
+.PP
+.TP
+\fB\fR
+Tap name(s), e.g. `owner/repo`
+.SH "MISE BOOTSTRAP PACKAGES INSTALL"
+Install missing system packages from `[bootstrap.packages]`
+
+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).
+
+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. Explicit packages and `\-\-manager` scope the run to packages
+only.
+.PP
+\fBUsage:\fR mise bootstrap packages install [OPTIONS] [] ...
+.PP
+\fBOptions:\fR
+.PP
+.TP
+\fB\-m, \-\-manager\fR \fI\fR
+Only install packages for this manager, e.g. `apt` or `brew`
+.TP
+\fB\-n, \-\-dry\-run\fR
+Print the commands that would run without running them
+.TP
+\fB\-y, \-\-yes\fR
+Skip the confirmation prompt
+.TP
+\fB\-\-update\fR
+Refresh package manager metadata first (apt: `apt\-get update`)
+\fBArguments:\fR
+.PP
+.TP
+\fB\fR
+Packages in `manager:package` form; defaults to everything configured in [bootstrap.packages]
+.SH "MISE BOOTSTRAP PACKAGES STATUS"
+Show the status of system packages from `[bootstrap.packages]`
+.PP
+\fBUsage:\fR mise bootstrap packages status [OPTIONS]
+.PP
+\fBOptions:\fR
+.PP
+.TP
+\fB\-J, \-\-json\fR
+Output in JSON format
+.TP
+\fB\-\-missing\fR
+Exit with code 1 if any configured packages are not in their desired state
+.SH "MISE BOOTSTRAP PACKAGES UPGRADE"
+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. 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
+\fBUsage:\fR mise bootstrap packages upgrade [OPTIONS] [] ...
+.PP
+\fBOptions:\fR
+.PP
+.TP
+\fB\-m, \-\-manager\fR \fI\fR
+Only upgrade packages for this manager, e.g. `apt` or `brew`
+.TP
+\fB\-n, \-\-dry\-run\fR
+Print the commands that would run without running them
+.TP
+\fB\-y, \-\-yes\fR
+Skip the confirmation prompt
+\fBArguments:\fR
+.PP
+.TP
+\fB\fR
+Packages in `manager:package` form; defaults to everything configured in [bootstrap.packages]
+.SH "MISE BOOTSTRAP PACKAGES USE"
+Add bootstrap packages to [bootstrap.packages] and install them
+
+Like `mise use` for tools: writes `"manager:package" = "version"` entries
+to mise.toml (the local config by default, the global one with `\-g`) and
+then installs whatever is missing.
+
+Versions are pinned with `@`: `mise bootstrap packages use apt:curl@8.5.0\-2`. Without
+`@` (or with `@latest`) no pin is written. brew formulae version through
+their names instead (`brew:postgresql@17`), so `@` is always part of the
+formula name there.
+.PP
+\fBUsage:\fR mise bootstrap packages use [OPTIONS] ...
+.PP
+\fBOptions:\fR
+.PP
+.TP
+\fB\-e, \-\-env\fR \fI\fR
+Write to the config file for this environment (mise..toml)
+.TP
+\fB\-g, \-\-global\fR
+Write to the global config (~/.config/mise/config.toml) instead of the local one
+.TP
+\fB\-n, \-\-dry\-run\fR
+Print the commands that would run without writing config or installing
+.TP
+\fB\-p, \-\-path\fR \fI\fR
+Write to this config file or directory
+.TP
+\fB\-y, \-\-yes\fR
+Skip the confirmation prompt
+\fBArguments:\fR
+.PP
+.TP
+\fB\fR
+Packages in `manager:package[@version]` form
+.SH "MISE BOOTSTRAP USER APPLY"
+\fBUsage:\fR mise bootstrap user apply [OPTIONS]
+.PP
+\fBOptions:\fR
+.PP
+.TP
+\fB\-n, \-\-dry\-run\fR
+Print the commands that would run without running them
+.TP
+\fB\-y, \-\-yes\fR
+Skip the confirmation prompt
+.SH "MISE BOOTSTRAP USER STATUS"
+\fBUsage:\fR mise bootstrap user status [OPTIONS]
+.PP
+\fBOptions:\fR
+.PP
+.TP
+\fB\-J, \-\-json\fR
+Output in JSON format
+.TP
+\fB\-\-missing\fR
+Exit with code 1 if any configured user setting is not in its desired state
.SH "MISE CACHE CLEAR"
Deletes all cache files in mise
.PP
@@ -918,6 +1142,115 @@ The path of the config to display
.TP
\fB\fR
The value to set the key to (optional if provided as KEY=VALUE)
+.SH "MISE DOTFILES ADD"
+Add or update dotfiles in `[dotfiles]`
+
+If the target is already managed, this updates its source from the live
+target. Otherwise it creates a `[dotfiles]` entry and seeds the source
+under `dotfiles.root` unless `\-\-source` is provided.
+.PP
+\fBUsage:\fR mise dotfiles add [OPTIONS] ...
+.PP
+\fBOptions:\fR
+.PP
+.TP
+\fB\-f, \-\-force\fR
+Overwrite existing sources without prompting
+.TP
+\fB\-g, \-\-global\fR
+Write to the global config
+.TP
+\fB\-l, \-\-local\fR
+Write to the local config instead of the global config
+.TP
+\fB\-m, \-\-mode\fR \fI\fR
+Dotfile mode to write
+.TP
+\fB\-n, \-\-dry\-run\fR
+Print the config/source updates without writing anything
+.TP
+\fB\-p, \-\-path\fR \fI\fR
+Write to this config file or directory
+.TP
+\fB\-s, \-\-source\fR \fI\fR
+Source path to use for a single target
+.TP
+\fB\-y, \-\-yes\fR
+Skip the confirmation prompt
+\fBArguments:\fR
+.PP
+.TP
+\fB\fR
+Targets to add or update
+.SH "MISE DOTFILES APPLY"
+Apply dotfiles from `[dotfiles]`
+
+Applies configured whole\-file entries and edits that aren't in their
+desired state. Whole\-file entries may symlink, copy, or render templates.
+Edit entries manage a marker\-delimited block or a single line in a file
+mise doesn't otherwise own.
+.PP
+\fBUsage:\fR mise dotfiles apply [OPTIONS] [] ...
+.PP
+\fBOptions:\fR
+.PP
+.TP
+\fB\-f, \-\-force\fR
+Overwrite existing files that conflict with whole\-file dotfile entries
+.TP
+\fB\-n, \-\-dry\-run\fR
+Print the actions that would run without writing anything
+.TP
+\fB\-y, \-\-yes\fR
+Skip the confirmation prompt
+\fBArguments:\fR
+.PP
+.TP
+\fB\fR
+Only apply these targets
+.SH "MISE DOTFILES EDIT"
+Edit a managed dotfile source
+.PP
+\fBUsage:\fR mise dotfiles edit [OPTIONS]
+.PP
+\fBOptions:\fR
+.PP
+.TP
+\fB\-\-apply\fR
+Apply this target after the editor exits
+.TP
+\fB\-m, \-\-mode\fR \fI\fR
+Dotfile mode to use if the target is not yet managed
+.TP
+\fB\-s, \-\-source\fR \fI\fR
+Source path to use if the target is not yet managed
+.TP
+\fB\-y, \-\-yes\fR
+Skip the confirmation prompt when adding an unmanaged target
+\fBArguments:\fR
+.PP
+.TP
+\fB\fR
+Target to edit
+.SH "MISE DOTFILES STATUS"
+Show the status of dotfiles from `[dotfiles]`
+.PP
+\fBUsage:\fR mise dotfiles status [OPTIONS] [] ...
+.PP
+\fBOptions:\fR
+.PP
+.TP
+\fB\-J, \-\-json\fR
+Output in JSON format
+.TP
+\fB\-\-missing\fR
+Exit with code 1 if any configured dotfiles are not in their desired
+state (missing, source missing, differs)
+\fBArguments:\fR
+.PP
+.TP
+\fB\fR
+Only show these targets
.SH "MISE DOCTOR"
Check mise installation for possible problems
.PP
@@ -2789,163 +3122,6 @@ Symlinks all ruby tool versions from an external tool into mise
.TP
\fB\-\-brew\fR
Get tool versions from Homebrew
-.SH "MISE SYSTEM BREW TAP"
-Tap a Homebrew formula repository
-.PP
-\fBUsage:\fR mise system brew tap [OPTIONS] []
-.PP
-\fBOptions:\fR
-.PP
-.TP
-\fB\-n, \-\-dry\-run\fR
-Print the command that would run without running it
-\fBArguments:\fR
-.PP
-.TP
-\fB\fR
-Tap name, e.g. `owner/repo`
-.TP
-\fB\fR
-Git URL for non\-GitHub or otherwise custom taps
-.SH "MISE SYSTEM BREW UNTAP"
-Untap Homebrew formula repositories
-.PP
-\fBUsage:\fR mise system brew untap [OPTIONS] ...
-.PP
-\fBOptions:\fR
-.PP
-.TP
-\fB\-n, \-\-dry\-run\fR
-Print the command that would run without running it
-\fBArguments:\fR
-.PP
-.TP
-\fB\fR
-Tap name(s), e.g. `owner/repo`
-.SH "MISE SYSTEM INSTALL"
-Install missing system packages from `[system.packages]`, apply files
-from `[system.files]` and edits from `[system.edits]`, write macOS
-defaults from `[system.defaults]`, and set Unix login shell from
-`[system].login_shell`
-
-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). Afterwards, `[system.files]`
-and `[system.edits]` entries that aren't in their desired state are
-applied, on macOS any `[system.defaults]` entries that are unset or
-differ are written, and on Unix `[system].login_shell` is added to
-`/etc/shells` if needed before `chsh \-s` applies it.
-
-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. Explicit packages and `\-\-manager` scope the run to packages
-only.
-.PP
-\fBUsage:\fR mise system install [OPTIONS] [] ...
-.PP
-\fBOptions:\fR
-.PP
-.TP
-\fB\-f, \-\-force\fR
-Overwrite existing files that conflict with `[system.files]` entries
-.TP
-\fB\-m, \-\-manager\fR \fI\fR
-Only install packages for this manager, e.g. `apt` or `brew`
-.TP
-\fB\-n, \-\-dry\-run\fR
-Print the commands that would run without running them
-.TP
-\fB\-y, \-\-yes\fR
-Skip the confirmation prompt
-.TP
-\fB\-\-update\fR
-Refresh package manager metadata first (apt: `apt\-get update`)
-\fBArguments:\fR
-.PP
-.TP
-\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]`, files from
-`[system.files]`, edits from `[system.edits]`, and macOS defaults from
-`[system.defaults]`, and Unix login shell from `[system].login_shell`
-.PP
-\fBUsage:\fR mise system status [OPTIONS]
-.PP
-\fBOptions:\fR
-.PP
-.TP
-\fB\-J, \-\-json\fR
-Output in JSON format
-.TP
-\fB\-\-missing\fR
-Exit with code 1 if any configured packages, files, edits, defaults, or
-login shell are not in their desired state
-.SH "MISE SYSTEM UPGRADE"
-Upgrade installed system packages from `[system.packages]`
-
-Refreshes package manager metadata and upgrades the configured packages
-that are already installed: apt/dnf/pacman upgrade to the newest available
-version (apt and dnf honor a version pinned in config), brew pours the
-formula's current bottle and replaces the old keg. Packages that are not
-installed yet are skipped — use `mise system install` for those.
-
-Packages can also be given explicitly in `manager:package` form.
-.PP
-\fBUsage:\fR mise system upgrade [OPTIONS] [] ...
-.PP
-\fBOptions:\fR
-.PP
-.TP
-\fB\-m, \-\-manager\fR \fI\fR
-Only upgrade packages for this manager, e.g. `apt` or `brew`
-.TP
-\fB\-n, \-\-dry\-run\fR
-Print the commands that would run without running them
-.TP
-\fB\-y, \-\-yes\fR
-Skip the confirmation prompt
-\fBArguments:\fR
-.PP
-.TP
-\fB\fR
-Packages in `manager:package` form; defaults to everything configured in [system.packages]
-.SH "MISE SYSTEM USE"
-Add system packages to [system.packages] and install them
-
-Like `mise use` for tools: writes `"manager:package" = "version"` entries
-to mise.toml (the local config by default, the global one with `\-g`) and
-then installs whatever is missing.
-
-Versions are pinned with `@`: `mise system use apt:curl@8.5.0\-2`. Without
-`@` (or with `@latest`) no pin is written. brew formulae version through
-their names instead (`brew:postgresql@17`), so `@` is always part of the
-formula name there.
-.PP
-\fBUsage:\fR mise system use [OPTIONS] ...
-.PP
-\fBOptions:\fR
-.PP
-.TP
-\fB\-e, \-\-env\fR \fI\fR
-Write to the config file for this environment (mise..toml)
-.TP
-\fB\-g, \-\-global\fR
-Write to the global config (~/.config/mise/config.toml) instead of the local one
-.TP
-\fB\-n, \-\-dry\-run\fR
-Print the commands that would run without writing config or installing
-.TP
-\fB\-p, \-\-path\fR \fI\fR
-Write to this config file or directory
-.TP
-\fB\-y, \-\-yes\fR
-Skip the confirmation prompt
-\fBArguments:\fR
-.PP
-.TP
-\fB\fR
-Packages in `manager:package[@version]` form
.SH "MISE TASKS"
Manage tasks
.PP
diff --git a/mise.usage.kdl b/mise.usage.kdl
index f1e77b17a4..da44073c67 100644
--- a/mise.usage.kdl
+++ b/mise.usage.kdl
@@ -286,11 +286,15 @@ cmd bootstrap help="[experimental] Set up a machine for the current config in on
Runs the bootstrap steps for the current config in order:
-1. `mise system install` — install missing `[system.packages]`, apply
- `[system.files]` and `[system.edits]`, write `[system.defaults]`
- (macOS), and set `[system].login_shell` (Unix)
-2. `mise install` — install missing tools from `[tools]`
-3. `mise run bootstrap` — if a task named `bootstrap` is defined
+1. `mise bootstrap packages install` — install missing
+ `[bootstrap.packages]`
+2. `mise dotfiles apply` — apply dotfiles from `[dotfiles]`
+3. `mise bootstrap macos-defaults apply` — write
+ `[bootstrap.macos.defaults]` entries (macOS)
+4. `mise bootstrap user apply` — set `[bootstrap.user].login_shell`
+ (Unix)
+5. `mise install` — install missing tools from `[tools]`
+6. `mise run bootstrap` — if a task named `bootstrap` is defined
The declarative steps converge — anything already in its desired state
is skipped, so re-running is safe. The `bootstrap` task runs on every
@@ -301,14 +305,178 @@ databases, etc.) — it runs with the installed tools on PATH.
after_long_help #"""
Examples:
- $ mise bootstrap # system packages + tools + bootstrap task
- $ mise bootstrap --yes # don't prompt before installing system packages
- $ mise bootstrap --dry-run # show what would happen
+ $ mise bootstrap # packages + dotfiles + tools + bootstrap task
+ $ mise bootstrap packages install --yes
+ $ mise bootstrap macos-defaults status
+ $ mise bootstrap user apply --dry-run
"""#
flag "-n --dry-run" help="Print what would happen without installing anything"
flag "-y --yes" help="Skip confirmation prompts"
flag --update help="Refresh system package manager metadata first (apt: `apt-get update`)"
+ cmd macos-defaults subcommand_required=#true help="Manage macOS defaults from `[bootstrap.macos.defaults]`" {
+ cmd apply {
+ flag "-n --dry-run" help="Print the commands that would run without running them"
+ flag "-y --yes" help="Skip the confirmation prompt"
+ }
+ cmd status {
+ flag "-J --json" help="Output in JSON format"
+ flag --missing help="Exit with code 1 if any configured defaults are not in their desired state"
+ }
+ }
+ cmd packages subcommand_required=#true help="Manage bootstrap system packages from `[bootstrap.packages]`" {
+ cmd brew subcommand_required=#true help="Manage Homebrew taps used by bootstrap packages" {
+ long_help #"""
+Manage Homebrew taps used by bootstrap packages
+
+These commands shell out to Homebrew and do not modify `mise.toml`. Use
+`[bootstrap.brew.taps]` when you want tap sources shared in config.
+"""#
+ cmd tap help="Tap a Homebrew formula repository" {
+ after_long_help #"""
+Examples:
+
+ $ mise bootstrap packages brew tap railwaycat/emacsmacport
+ $ mise bootstrap packages brew tap acme/tools https://git.example.com/acme/homebrew-tools.git
+
+"""#
+ flag "-n --dry-run" help="Print the command that would run without running it"
+ arg help="Tap name, e.g. `owner/repo`"
+ arg "[URL]" help="Git URL for non-GitHub or otherwise custom taps" required=#false
+ }
+ cmd untap help="Untap Homebrew formula repositories" {
+ alias remove rm
+ after_long_help #"""
+Examples:
+
+ $ mise bootstrap packages brew untap railwaycat/emacsmacport
+
+"""#
+ flag "-n --dry-run" help="Print the command that would run without running it"
+ arg … help="Tap name(s), e.g. `owner/repo`" var=#true
+ }
+ }
+ cmd install help="Install missing system packages from `[bootstrap.packages]`" {
+ alias i
+ long_help #"""
+Install missing system packages from `[bootstrap.packages]`
+
+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).
+
+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. Explicit packages and `--manager` scope the run to packages
+only.
+"""#
+ after_long_help #"""
+Examples:
+
+ $ mise bootstrap packages install
+ $ mise bootstrap packages install apt:curl brew:jq
+ $ 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` or `brew`" {
+ arg {
+ choices apt brew dnf pacman
+ }
+ }
+ flag "-n --dry-run" help="Print the commands that would run without running them"
+ flag "-y --yes" help="Skip the confirmation prompt"
+ 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 [bootstrap.packages]" required=#false var=#true
+ }
+ cmd status help="Show the status of system packages from `[bootstrap.packages]`" {
+ alias ls
+ after_long_help #"""
+Examples:
+
+ $ mise bootstrap packages status
+ $ mise bootstrap packages status --json
+ $ mise bootstrap packages status --missing # exit 1 if anything is out of sync
+
+"""#
+ flag "-J --json" help="Output in JSON format"
+ flag --missing help="Exit with code 1 if any configured packages are not in their desired state"
+ }
+ cmd upgrade help="Upgrade installed bootstrap packages from `[bootstrap.packages]`" {
+ alias up
+ long_help #"""
+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. 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.
+"""#
+ after_long_help #"""
+Examples:
+
+ $ mise bootstrap packages upgrade
+ $ mise bootstrap packages upgrade brew:postgresql@17
+ $ 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` or `brew`" {
+ arg {
+ choices apt brew dnf pacman
+ }
+ }
+ flag "-n --dry-run" help="Print the commands that would run without running them"
+ flag "-y --yes" help="Skip the confirmation prompt"
+ arg "[PACKAGE]…" help="Packages in `manager:package` form; defaults to everything configured in [bootstrap.packages]" required=#false var=#true
+ }
+ cmd use help="Add bootstrap packages to [bootstrap.packages] and install them" {
+ alias u
+ long_help #"""
+Add bootstrap packages to [bootstrap.packages] and install them
+
+Like `mise use` for tools: writes `"manager:package" = "version"` entries
+to mise.toml (the local config by default, the global one with `-g`) and
+then installs whatever is missing.
+
+Versions are pinned with `@`: `mise bootstrap packages use apt:curl@8.5.0-2`. Without
+`@` (or with `@latest`) no pin is written. brew formulae version through
+their names instead (`brew:postgresql@17`), so `@` is always part of the
+formula name there.
+"""#
+ after_long_help #"""
+Examples:
+
+ $ mise bootstrap packages use apt:curl brew:jq
+ $ mise bootstrap packages use -g brew:postgresql@17
+ $ mise bootstrap packages use apt:curl@8.5.0-2
+
+"""#
+ flag "-e --env" help="Write to the config file for this environment (mise..toml)" {
+ arg
+ }
+ flag "-g --global" help="Write to the global config (~/.config/mise/config.toml) instead of the local one"
+ flag "-n --dry-run" help="Print the commands that would run without writing config or installing"
+ flag "-p --path" help="Write to this config file or directory" {
+ arg
+ }
+ flag "-y --yes" help="Skip the confirmation prompt"
+ arg … help="Packages in `manager:package[@version]` form" var=#true
+ }
+ }
+ cmd user subcommand_required=#true help="Manage current-user bootstrap settings from `[bootstrap.user]`" {
+ cmd apply {
+ flag "-n --dry-run" help="Print the commands that would run without running them"
+ flag "-y --yes" help="Skip the confirmation prompt"
+ }
+ cmd status {
+ flag "-J --json" help="Output in JSON format"
+ flag --missing help="Exit with code 1 if any configured user setting is not in its desired state"
+ }
+ }
}
cmd cache help="Manage the mise cache" {
long_help #"""
@@ -536,6 +704,107 @@ for direnv to consume.
for direnv to consume.
"""#
}
+cmd dotfiles subcommand_required=#true help="[experimental] Manage dotfiles from `[dotfiles]`" {
+ long_help #"""
+[experimental] Manage dotfiles from `[dotfiles]`
+
+Dotfiles are config files symlinked, copied, or rendered to target paths,
+plus marker-delimited blocks or single lines in files mise doesn't own.
+Unlike `[tools]`, dotfiles are only acted on when explicitly requested with
+`mise dotfiles apply` or `mise bootstrap`.
+"""#
+ cmd add help="Add or update dotfiles in `[dotfiles]`" {
+ long_help #"""
+Add or update dotfiles in `[dotfiles]`
+
+If the target is already managed, this updates its source from the live
+target. Otherwise it creates a `[dotfiles]` entry and seeds the source
+under `dotfiles.root` unless `--source` is provided.
+"""#
+ after_long_help #"""
+Examples:
+
+ $ mise dotfiles add ~/.zshrc
+ $ mise dotfiles add --mode copy ~/.config/starship.toml
+ $ mise dotfiles add --source dotfiles/gitconfig ~/.gitconfig
+
+"""#
+ flag "-f --force" help="Overwrite existing sources without prompting"
+ flag "-g --global" help="Write to the global config"
+ flag "-l --local" help="Write to the local config instead of the global config"
+ flag "-m --mode" help="Dotfile mode to write" {
+ arg
+ }
+ flag "-n --dry-run" help="Print the config/source updates without writing anything"
+ flag "-p --path" help="Write to this config file or directory" {
+ arg
+ }
+ flag "-s --source" help="Source path to use for a single target" {
+ arg
+ }
+ flag "-y --yes" help="Skip the confirmation prompt"
+ arg … help="Targets to add or update" var=#true
+ }
+ cmd apply help="Apply dotfiles from `[dotfiles]`" {
+ long_help #"""
+Apply dotfiles from `[dotfiles]`
+
+Applies configured whole-file entries and edits that aren't in their
+desired state. Whole-file entries may symlink, copy, or render templates.
+Edit entries manage a marker-delimited block or a single line in a file
+mise doesn't otherwise own.
+"""#
+ after_long_help #"""
+Examples:
+
+ $ mise dotfiles apply
+ $ mise dotfiles apply --dry-run
+ $ mise dotfiles apply --dry-run --verbose
+ $ mise dotfiles apply --force --yes
+
+"""#
+ flag "-f --force" help="Overwrite existing files that conflict with whole-file dotfile entries"
+ flag "-n --dry-run" help="Print the actions that would run without writing anything"
+ flag "-y --yes" help="Skip the confirmation prompt"
+ arg "[TARGET]…" help="Only apply these targets" required=#false var=#true
+ }
+ cmd edit help="Edit a managed dotfile source" {
+ after_long_help #"""
+Examples:
+
+ $ mise dotfiles edit ~/.zshrc
+ $ mise dotfiles edit --apply ~/.config/starship.toml
+
+"""#
+ flag --apply help="Apply this target after the editor exits"
+ flag "-m --mode" help="Dotfile mode to use if the target is not yet managed" {
+ arg
+ }
+ flag "-s --source" help="Source path to use if the target is not yet managed" {
+ arg
+ }
+ flag "-y --yes" help="Skip the confirmation prompt when adding an unmanaged target"
+ arg help="Target to edit"
+ }
+ cmd status help="Show the status of dotfiles from `[dotfiles]`" {
+ alias ls
+ after_long_help #"""
+Examples:
+
+ $ mise dotfiles status
+ $ mise dotfiles status ~/.zshrc
+ $ mise dotfiles status --json
+ $ mise dotfiles status --missing # exit 1 if anything is out of sync
+
+"""#
+ flag "-J --json" help="Output in JSON format"
+ flag --missing help=#"""
+Exit with code 1 if any configured dotfiles are not in their desired
+state (missing, source missing, differs)
+"""#
+ arg "[TARGET]…" help="Only show these targets" required=#false var=#true
+ }
+}
cmd doctor help="Check mise installation for possible problems" {
alias dr
after_long_help #"""
@@ -2877,189 +3146,6 @@ Examples:
flag --brew help="Get tool versions from Homebrew"
}
}
-cmd system subcommand_required=#true help=#"""
-[experimental] Manage system packages from `[system.packages]`, files
-from `[system.files]`, edits from `[system.edits]`, macOS defaults
-from `[system.defaults]`, and Unix login shell from `[system].login_shell`
-"""# {
- long_help #"""
-[experimental] Manage system packages from `[system.packages]`, files
-from `[system.files]`, edits from `[system.edits]`, macOS defaults
-from `[system.defaults]`, and Unix login shell from `[system].login_shell`
-
-System packages are machine-global packages installed by the OS package
-manager (apt, dnf, pacman) or mise's Homebrew-bottle installer (brew).
-System files are config files (dotfiles) symlinked, copied, or rendered
-to machine-global paths. System edits manage one piece of a file
-something else owns — a marker-delimited block or a single line. macOS
-defaults are user preferences written with `defaults write`. Unlike
-`[tools]`, none of these are version-pinned per-project and they are only
-ever acted on when explicitly requested with `mise system install` (or
-`mise bootstrap`). Login shell changes are current-user settings applied
-with `chsh -s`.
-"""#
- cmd brew subcommand_required=#true help="Manage Homebrew taps used by system packages" {
- long_help #"""
-Manage Homebrew taps used by system packages
-
-These commands shell out to Homebrew and do not modify `mise.toml`. Use
-`[system.brew.taps]` when you want tap sources shared in config.
-"""#
- cmd tap help="Tap a Homebrew formula repository" {
- after_long_help #"""
-Examples:
-
- $ mise system brew tap railwaycat/emacsmacport
- $ mise system brew tap acme/tools https://git.example.com/acme/homebrew-tools.git
-
-"""#
- flag "-n --dry-run" help="Print the command that would run without running it"
- arg help="Tap name, e.g. `owner/repo`"
- arg "[URL]" help="Git URL for non-GitHub or otherwise custom taps" required=#false
- }
- cmd untap help="Untap Homebrew formula repositories" {
- alias remove rm
- after_long_help #"""
-Examples:
-
- $ mise system brew untap railwaycat/emacsmacport
-
-"""#
- flag "-n --dry-run" help="Print the command that would run without running it"
- arg … help="Tap name(s), e.g. `owner/repo`" var=#true
- }
- }
- cmd install help=#"""
-Install missing system packages from `[system.packages]`, apply files
-from `[system.files]` and edits from `[system.edits]`, write macOS
-defaults from `[system.defaults]`, and set Unix login shell from
-`[system].login_shell`
-"""# {
- alias i
- long_help #"""
-Install missing system packages from `[system.packages]`, apply files
-from `[system.files]` and edits from `[system.edits]`, write macOS
-defaults from `[system.defaults]`, and set Unix login shell from
-`[system].login_shell`
-
-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). Afterwards, `[system.files]`
-and `[system.edits]` entries that aren't in their desired state are
-applied, on macOS any `[system.defaults]` entries that are unset or
-differ are written, and on Unix `[system].login_shell` is added to
-`/etc/shells` if needed before `chsh -s` applies it.
-
-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. Explicit packages and `--manager` scope the run to packages
-only.
-"""#
- after_long_help #"""
-Examples:
-
- $ mise system install
- $ mise system install apt:curl brew:jq
- $ mise system install --dry-run
- $ mise system install --manager apt --yes
-
-"""#
- flag "-f --force" help="Overwrite existing files that conflict with `[system.files]` entries"
- flag "-m --manager" help="Only install packages for this manager, e.g. `apt` or `brew`" {
- arg {
- choices apt brew dnf pacman
- }
- }
- flag "-n --dry-run" help="Print the commands that would run without running them"
- flag "-y --yes" help="Skip the confirmation prompt"
- 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]`, files from
-`[system.files]`, edits from `[system.edits]`, and macOS defaults from
-`[system.defaults]`, and Unix login shell from `[system].login_shell`
-"""# {
- alias ls
- after_long_help #"""
-Examples:
-
- $ mise system status
- $ mise system status --json
- $ mise system status --missing # exit 1 if anything is out of sync
-
-"""#
- flag "-J --json" help="Output in JSON format"
- flag --missing help=#"""
-Exit with code 1 if any configured packages, files, edits, defaults, or
-login shell are not in their desired state
-"""#
- }
- cmd upgrade help="Upgrade installed system packages from `[system.packages]`" {
- alias up
- long_help #"""
-Upgrade installed system packages from `[system.packages]`
-
-Refreshes package manager metadata and upgrades the configured packages
-that are already installed: apt/dnf/pacman upgrade to the newest available
-version (apt and dnf honor a version pinned in config), brew pours the
-formula's current bottle and replaces the old keg. Packages that are not
-installed yet are skipped — use `mise system install` for those.
-
-Packages can also be given explicitly in `manager:package` form.
-"""#
- after_long_help #"""
-Examples:
-
- $ mise system upgrade
- $ mise system upgrade brew:postgresql@17
- $ mise system upgrade --manager apt --yes
- $ mise system upgrade --dry-run
-
-"""#
- flag "-m --manager" help="Only upgrade packages for this manager, e.g. `apt` or `brew`" {
- arg {
- choices apt brew dnf pacman
- }
- }
- flag "-n --dry-run" help="Print the commands that would run without running them"
- flag "-y --yes" help="Skip the confirmation prompt"
- arg "[PACKAGE]…" help="Packages in `manager:package` form; defaults to everything configured in [system.packages]" required=#false var=#true
- }
- cmd use help="Add system packages to [system.packages] and install them" {
- alias u
- long_help #"""
-Add system packages to [system.packages] and install them
-
-Like `mise use` for tools: writes `"manager:package" = "version"` entries
-to mise.toml (the local config by default, the global one with `-g`) and
-then installs whatever is missing.
-
-Versions are pinned with `@`: `mise system use apt:curl@8.5.0-2`. Without
-`@` (or with `@latest`) no pin is written. brew formulae version through
-their names instead (`brew:postgresql@17`), so `@` is always part of the
-formula name there.
-"""#
- after_long_help #"""
-Examples:
-
- $ mise system use apt:curl brew:jq
- $ mise system use -g brew:postgresql@17
- $ mise system use apt:curl@8.5.0-2
-
-"""#
- flag "-e --env" help="Write to the config file for this environment (mise..toml)" {
- arg
- }
- flag "-g --global" help="Write to the global config (~/.config/mise/config.toml) instead of the local one"
- flag "-n --dry-run" help="Print the commands that would run without writing config or installing"
- flag "-p --path" help="Write to this config file or directory" {
- arg
- }
- flag "-y --yes" help="Skip the confirmation prompt"
- arg … help="Packages in `manager:package[@version]` form" var=#true
- }
-}
cmd tasks help="Manage tasks" {
alias t
alias task hide=#true
diff --git a/schema/mise.json b/schema/mise.json
index c0c03e6df7..3fd01694d9 100644
--- a/schema/mise.json
+++ b/schema/mise.json
@@ -667,6 +667,22 @@
"type": "string"
}
},
+ "dotfiles": {
+ "type": "object",
+ "unevaluatedProperties": false,
+ "properties": {
+ "default_mode": {
+ "default": "symlink",
+ "description": "Default mode for dotfile entries when mode is omitted. Options: `symlink`, `symlink-each`, `copy`, `template`.",
+ "type": "string"
+ },
+ "root": {
+ "default": "~/.dotfiles",
+ "description": "Root directory used for implied dotfile sources.",
+ "type": "string"
+ }
+ }
+ },
"dotnet": {
"type": "object",
"unevaluatedProperties": false,
@@ -1591,7 +1607,7 @@
},
"sudo": {
"default": true,
- "description": "[experimental] Allow `mise system install` to elevate with sudo when not running as root.",
+ "description": "[experimental] Allow `mise bootstrap packages install` to elevate with sudo when not running as root.",
"type": "boolean"
}
}
@@ -2957,104 +2973,71 @@
"description": "mise settings",
"type": "object"
},
- "system": {
+ "dotfiles": {
"type": "object",
- "description": "[experimental] machine-global bootstrapping (system packages, files, edits, macOS defaults, login shell)",
- "properties": {
- "login_shell": {
- "type": "string",
- "pattern": "^/",
- "description": "current user's desired login shell, applied with `chsh -s`; must be an absolute path"
- },
- "edits": {
- "type": "object",
- "description": "edits to files mise doesn't own, applied with `mise system install`: keyed by target path, then by an id naming each edit (the block marker name)",
- "additionalProperties": {
+ "description": "[experimental] dotfiles applied with `mise dotfiles apply` or `mise bootstrap`, keyed by target path",
+ "additionalProperties": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "source path for a whole-file dotfile entry"
+ },
+ {
"type": "object",
- "description": "edits for one file, keyed by id",
- "propertyNames": {
- "pattern": "^[A-Za-z0-9_.-]+$"
- },
- "additionalProperties": {
- "oneOf": [
- {
- "type": "string",
- "description": "inline block content, maintained between marker comments"
- },
- {
- "type": "object",
- "properties": {
- "block": {
- "type": "string",
- "description": "block content, maintained between marker comments"
- },
- "source": {
- "type": "string",
- "description": "block content from a file, relative to this config file"
- },
- "template": {
- "type": "string",
- "description": "template engine to render the block content with; currently only \"tera\"",
- "anyOf": [
- {
- "enum": ["tera"]
- },
- {
- "type": "string"
- }
- ]
- },
- "line": {
- "type": "string",
- "description": "exact line to ensure exists (appended if absent)"
- },
- "comment": {
- "type": "string",
- "description": "comment prefix for the marker lines; inferred from the file extension when omitted"
- }
+ "description": "whole-file dotfile entry or file edit entry",
+ "properties": {
+ "source": {
+ "type": "string",
+ "description": "source file or directory"
+ },
+ "mode": {
+ "type": "string",
+ "description": "how to apply a whole-file source to the target; omitted uses dotfiles.default_mode",
+ "anyOf": [
+ {
+ "enum": ["symlink", "symlink-each", "copy", "template"]
+ },
+ {
+ "type": "string"
}
- }
- ]
- }
- }
- },
- "files": {
- "type": "object",
- "description": "files to apply with `mise system install`, keyed by target path (e.g. \"~/.gitconfig\"); sources resolve relative to this config file",
- "additionalProperties": {
- "oneOf": [
- {
+ ]
+ },
+ "block": {
"type": "string",
- "description": "source path, symlinked to the target"
+ "description": "block content, maintained between marker comments"
},
- {
- "type": "object",
- "required": ["source"],
- "properties": {
- "source": {
- "type": "string",
- "description": "source file or directory"
+ "template": {
+ "type": "string",
+ "description": "template engine to render block content with; currently only \"tera\"",
+ "anyOf": [
+ {
+ "enum": ["tera"]
},
- "mode": {
- "type": "string",
- "description": "how to apply the source to the target: symlink (default), symlink-each, copy, or template; unknown modes from newer mise versions warn and are skipped",
- "anyOf": [
- {
- "enum": ["symlink", "symlink-each", "copy", "template"]
- },
- {
- "type": "string"
- }
- ]
+ {
+ "type": "string"
}
- }
+ ]
+ },
+ "line": {
+ "type": "string",
+ "description": "exact line to ensure exists (appended if absent)"
+ },
+ "comment": {
+ "type": "string",
+ "description": "comment prefix for edit marker lines; inferred from the file extension when omitted"
}
- ]
+ }
}
- },
+ ]
+ }
+ },
+ "bootstrap": {
+ "type": "object",
+ "description": "[experimental] machine-global bootstrapping (system packages, macOS defaults, login shell)",
+ "properties": {
"packages": {
"type": "object",
- "description": "system packages to install with `mise system install`, keyed by \"manager:package\" (e.g. \"apt:libssl-dev\", \"brew:postgresql@17\", \"dnf:openssl-devel\", \"pacman:base-devel\")",
+ "description": "system packages to install with `mise bootstrap packages install`, keyed by \"manager:package\" (e.g. \"apt:libssl-dev\", \"brew:postgresql@17\", \"dnf:openssl-devel\", \"pacman:base-devel\")",
"propertyNames": {
"pattern": "^[A-Za-z0-9_-]+:.+$"
},
@@ -3063,28 +3046,58 @@
"description": "version pin in the manager's native format, or \"latest\""
}
},
- "defaults": {
+ "macos": {
"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": "macOS-specific bootstrap config",
+ "properties": {
+ "defaults": {
+ "type": "object",
+ "description": "macOS user defaults to apply with `mise bootstrap macos-defaults apply`, 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)"
}
- ],
- "description": "desired value, written with the matching `defaults write` type (-bool, -int, -float, -string)"
+ }
+ }
+ }
+ },
+ "user": {
+ "type": "object",
+ "description": "current-user bootstrap settings",
+ "properties": {
+ "login_shell": {
+ "type": "string",
+ "pattern": "^/",
+ "description": "current user's desired login shell, applied with `chsh -s`; must be an absolute path"
+ }
+ }
+ },
+ "brew": {
+ "type": "object",
+ "description": "Homebrew-specific bootstrap package config",
+ "properties": {
+ "taps": {
+ "type": "object",
+ "description": "Homebrew tap names mapped to custom git URLs",
+ "additionalProperties": {
+ "type": "string"
+ }
}
}
}
diff --git a/settings.toml b/settings.toml
index 76fe774319..1825596b74 100644
--- a/settings.toml
+++ b/settings.toml
@@ -448,6 +448,18 @@ parse_env = "set_by_comma"
rust_type = "BTreeSet"
type = "SetString"
+[dotfiles.default_mode]
+default = "symlink"
+description = "Default mode for dotfile entries when mode is omitted. Options: `symlink`, `symlink-each`, `copy`, `template`."
+env = "MISE_DOTFILES_DEFAULT_MODE"
+type = "String"
+
+[dotfiles.root]
+default = "~/.dotfiles"
+description = "Root directory used for implied dotfile sources."
+env = "MISE_DOTFILES_ROOT"
+type = "Path"
+
[dotnet.cli_telemetry_optout]
description = "Set DOTNET_CLI_TELEMETRY_OPTOUT to opt out of .NET CLI telemetry."
docs = """
@@ -2316,7 +2328,7 @@ description = "[experimental] Restrict which system package managers mise will u
docs = """
[experimental] Restrict which system package managers mise will use.
-By default mise acts on every manager in `[system.packages]` that is available
+By default mise acts on every manager in `[bootstrap.packages]` that is available
on the current machine. When more than one could apply (e.g. several package
managers installed on one machine, or a shared config listing managers you
don't want on this machine), set this to the subset you want:
@@ -2337,13 +2349,13 @@ type = "ListString"
[system_packages.sudo]
default = true
-description = "[experimental] Allow `mise system install` to elevate with sudo when not running as root."
+description = "[experimental] Allow `mise bootstrap packages install` to elevate with sudo when not running as root."
docs = """
-[experimental] Allow `mise system install` to elevate with sudo when not running as root.
+[experimental] Allow `mise bootstrap packages install` to elevate with sudo when not running as root.
When disabled, mise never runs sudo: if elevation would be required, it errors
with the exact command to run manually. mise only elevates for explicit
-`mise system install` invocations, logs the full command line before running
+`mise bootstrap packages install` invocations, logs the full command line before running
it, and skips sudo entirely when already running as root (e.g. containers/CI).
"""
env = "MISE_SYSTEM_PACKAGES_SUDO"
diff --git a/src/cli/bootstrap.rs b/src/cli/bootstrap.rs
index 23d9cabe3d..8b10130674 100644
--- a/src/cli/bootstrap.rs
+++ b/src/cli/bootstrap.rs
@@ -1,20 +1,30 @@
use eyre::Result;
+use serde_json::json;
use super::install::Install;
use super::run;
use super::system::driver::{self, Action, DriverOpts};
+use super::system::{install, status, upgrade, r#use};
use crate::config::{Config, Settings};
use crate::system;
+use crate::system::defaults::DefaultsState;
+use crate::system::login_shell::LoginShellState;
+use crate::ui::table::MiseTable;
+use clap::Subcommand;
/// [experimental] Set up a machine for the current config in one command
///
/// Runs the bootstrap steps for the current config in order:
///
-/// 1. `mise system install` — install missing `[system.packages]`, apply
-/// `[system.files]` and `[system.edits]`, write `[system.defaults]`
-/// (macOS), and set `[system].login_shell` (Unix)
-/// 2. `mise install` — install missing tools from `[tools]`
-/// 3. `mise run bootstrap` — if a task named `bootstrap` is defined
+/// 1. `mise bootstrap packages install` — install missing
+/// `[bootstrap.packages]`
+/// 2. `mise dotfiles apply` — apply dotfiles from `[dotfiles]`
+/// 3. `mise bootstrap macos-defaults apply` — write
+/// `[bootstrap.macos.defaults]` entries (macOS)
+/// 4. `mise bootstrap user apply` — set `[bootstrap.user].login_shell`
+/// (Unix)
+/// 5. `mise install` — install missing tools from `[tools]`
+/// 6. `mise run bootstrap` — if a task named `bootstrap` is defined
///
/// The declarative steps converge — anything already in its desired state
/// is skipped, so re-running is safe. The `bootstrap` task runs on every
@@ -24,6 +34,9 @@ use crate::system;
#[derive(Debug, clap::Args)]
#[clap(verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)]
pub struct Bootstrap {
+ #[clap(subcommand)]
+ command: Option,
+
/// Print what would happen without installing anything
#[clap(long, short = 'n')]
dry_run: bool,
@@ -37,14 +50,114 @@ pub struct Bootstrap {
update: bool,
}
+#[derive(Debug, Subcommand)]
+enum Commands {
+ MacosDefaults(BootstrapMacosDefaults),
+ Packages(BootstrapPackages),
+ User(BootstrapUser),
+}
+
+/// Manage bootstrap system packages from `[bootstrap.packages]`
+#[derive(Debug, clap::Args)]
+#[clap(verbatim_doc_comment)]
+struct BootstrapPackages {
+ #[clap(subcommand)]
+ command: BootstrapPackagesCommands,
+}
+
+#[derive(Debug, Subcommand)]
+enum BootstrapPackagesCommands {
+ #[cfg(unix)]
+ Brew(super::system::brew::SystemBrew),
+ Install(install::SystemInstall),
+ Status(status::SystemStatus),
+ Upgrade(upgrade::SystemUpgrade),
+ Use(r#use::SystemUse),
+}
+
+/// Manage macOS defaults from `[bootstrap.macos.defaults]`
+#[derive(Debug, clap::Args)]
+#[clap(verbatim_doc_comment)]
+struct BootstrapMacosDefaults {
+ #[clap(subcommand)]
+ command: BootstrapMacosDefaultsCommands,
+}
+
+#[derive(Debug, Subcommand)]
+enum BootstrapMacosDefaultsCommands {
+ Apply(BootstrapMacosDefaultsApply),
+ Status(BootstrapMacosDefaultsStatus),
+}
+
+#[derive(Debug, clap::Args)]
+struct BootstrapMacosDefaultsApply {
+ /// Print the commands that would run without running them
+ #[clap(long, short = 'n')]
+ dry_run: bool,
+
+ /// Skip the confirmation prompt
+ #[clap(long, short)]
+ yes: bool,
+}
+
+#[derive(Debug, clap::Args)]
+struct BootstrapMacosDefaultsStatus {
+ /// Output in JSON format
+ #[clap(long, short = 'J')]
+ json: bool,
+
+ /// Exit with code 1 if any configured defaults are not in their desired state
+ #[clap(long, verbatim_doc_comment)]
+ missing: bool,
+}
+
+/// Manage current-user bootstrap settings from `[bootstrap.user]`
+#[derive(Debug, clap::Args)]
+#[clap(verbatim_doc_comment)]
+struct BootstrapUser {
+ #[clap(subcommand)]
+ command: BootstrapUserCommands,
+}
+
+#[derive(Debug, Subcommand)]
+enum BootstrapUserCommands {
+ Apply(BootstrapUserApply),
+ Status(BootstrapUserStatus),
+}
+
+#[derive(Debug, clap::Args)]
+struct BootstrapUserApply {
+ /// Print the commands that would run without running them
+ #[clap(long, short = 'n')]
+ dry_run: bool,
+
+ /// Skip the confirmation prompt
+ #[clap(long, short)]
+ yes: bool,
+}
+
+#[derive(Debug, clap::Args)]
+struct BootstrapUserStatus {
+ /// Output in JSON format
+ #[clap(long, short = 'J')]
+ json: bool,
+
+ /// Exit with code 1 if any configured user setting is not in its desired state
+ #[clap(long, verbatim_doc_comment)]
+ missing: bool,
+}
+
impl Bootstrap {
pub async fn run(self) -> Result<()> {
Settings::get().ensure_experimental("mise bootstrap")?;
+ if let Some(command) = self.command {
+ return command.run().await;
+ }
let config = Config::get().await?;
let mgrs = system::packages_from_config(&config);
if mgrs.is_empty() {
- debug!("bootstrap: no [system.packages] configured, skipping");
+ debug!("bootstrap: no [bootstrap.packages] configured, skipping");
} else {
info!("bootstrap: system packages");
let opts = DriverOpts {
@@ -59,13 +172,14 @@ impl Bootstrap {
let files = system::files::files_from_config(&config);
if files.is_empty() {
- debug!("bootstrap: no [system.files] configured, skipping");
+ debug!("bootstrap: no whole-file [dotfiles] entries configured, skipping");
} else {
- info!("bootstrap: system files");
+ info!("bootstrap: dotfiles");
let opts = system::files::ApplyOpts {
dry_run: self.dry_run,
+ verbose: false,
// conflicts shouldn't be steamrolled by a bootstrap;
- // `mise system install --force` is the explicit way
+ // `mise dotfiles apply --force` is the explicit way
force: false,
yes: self.yes,
};
@@ -74,11 +188,12 @@ impl Bootstrap {
let edits = system::edits::edits_from_config(&config);
if edits.is_empty() {
- debug!("bootstrap: no [system.edits] configured, skipping");
+ debug!("bootstrap: no edit [dotfiles] entries configured, skipping");
} else {
- info!("bootstrap: system edits");
+ info!("bootstrap: dotfile edits");
let opts = system::edits::ApplyOpts {
dry_run: self.dry_run,
+ verbose: false,
yes: self.yes,
};
system::edits::apply(&config, &edits, &opts)?;
@@ -86,18 +201,18 @@ impl Bootstrap {
let defaults = system::defaults_from_config(&config);
if defaults.is_empty() {
- debug!("bootstrap: no [system.defaults] configured, skipping");
+ debug!("bootstrap: no [bootstrap.macos.defaults] configured, skipping");
} else {
info!("bootstrap: system defaults");
- super::system::install::apply_defaults(defaults, self.dry_run, self.yes).await?;
+ install::apply_defaults(defaults, self.dry_run, self.yes).await?;
}
let login_shell = system::login_shell_from_config(&config);
if login_shell.is_none() {
- debug!("bootstrap: no [system].login_shell configured, skipping");
+ debug!("bootstrap: no [bootstrap.user].login_shell configured, skipping");
} else {
info!("bootstrap: login shell");
- super::system::install::apply_login_shell(login_shell, self.dry_run, self.yes)?;
+ install::apply_login_shell(login_shell, self.dry_run, self.yes)?;
}
info!("bootstrap: tools");
@@ -162,11 +277,242 @@ impl Bootstrap {
}
}
+impl Commands {
+ async fn run(self) -> Result<()> {
+ match self {
+ Self::MacosDefaults(cmd) => cmd.run().await,
+ Self::Packages(cmd) => cmd.run().await,
+ Self::User(cmd) => cmd.run().await,
+ }
+ }
+}
+
+impl BootstrapPackages {
+ async fn run(self) -> Result<()> {
+ Settings::get().ensure_experimental("mise bootstrap")?;
+ match self.command {
+ #[cfg(unix)]
+ BootstrapPackagesCommands::Brew(cmd) => cmd.run().await,
+ BootstrapPackagesCommands::Install(cmd) => cmd.run().await,
+ BootstrapPackagesCommands::Status(cmd) => cmd.run().await,
+ BootstrapPackagesCommands::Upgrade(cmd) => cmd.run().await,
+ BootstrapPackagesCommands::Use(cmd) => cmd.run().await,
+ }
+ }
+}
+
+impl BootstrapMacosDefaults {
+ async fn run(self) -> Result<()> {
+ Settings::get().ensure_experimental("mise bootstrap")?;
+ match self.command {
+ BootstrapMacosDefaultsCommands::Apply(cmd) => cmd.run().await,
+ BootstrapMacosDefaultsCommands::Status(cmd) => cmd.run().await,
+ }
+ }
+}
+
+impl BootstrapMacosDefaultsApply {
+ async fn run(self) -> Result<()> {
+ let config = Config::get().await?;
+ install::apply_defaults(
+ system::defaults_from_config(&config),
+ self.dry_run,
+ self.yes,
+ )
+ .await
+ }
+}
+
+impl BootstrapMacosDefaultsStatus {
+ async fn run(self) -> Result<()> {
+ let config = Config::get().await?;
+ let defaults = system::defaults_from_config(&config);
+ let mut any_missing = false;
+ let mut rows: Vec> = vec![];
+ let mut json_out = serde_json::Map::new();
+ if !defaults.is_empty() {
+ if !system::defaults::is_available() {
+ let reason = system::defaults::unavailable_reason();
+ if self.json {
+ json_out.insert(
+ "macos_defaults".to_string(),
+ json!({ "available": false, "reason": reason }),
+ );
+ } else {
+ for req in &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 {
+ 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(
+ "macos_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!("nothing configured in [bootstrap.macos.defaults]");
+ } else {
+ let mut table = MiseTable::new(false, &["Domain", "Key", "Value", "Current", "State"]);
+ for row in rows {
+ table.add_row(row);
+ }
+ table.print()?;
+ }
+ if self.missing && any_missing {
+ crate::exit(1);
+ }
+ Ok(())
+ }
+}
+
+impl BootstrapUser {
+ async fn run(self) -> Result<()> {
+ Settings::get().ensure_experimental("mise bootstrap")?;
+ match self.command {
+ BootstrapUserCommands::Apply(cmd) => cmd.run().await,
+ BootstrapUserCommands::Status(cmd) => cmd.run().await,
+ }
+ }
+}
+
+impl BootstrapUserApply {
+ async fn run(self) -> Result<()> {
+ let config = Config::get().await?;
+ install::apply_login_shell(
+ system::login_shell_from_config(&config),
+ self.dry_run,
+ self.yes,
+ )
+ }
+}
+
+impl BootstrapUserStatus {
+ async fn run(self) -> Result<()> {
+ let config = Config::get().await?;
+ let login_shell = system::login_shell_from_config(&config);
+ let mut any_missing = false;
+ let mut rows: Vec> = vec![];
+ let mut json_out = serde_json::Map::new();
+ if let Some(req) = login_shell {
+ if !system::login_shell::is_available() {
+ let reason = system::login_shell::unavailable_reason();
+ if self.json {
+ json_out.insert(
+ "login_shell".to_string(),
+ json!({
+ "available": false,
+ "reason": reason,
+ "shell": req.shell,
+ }),
+ );
+ } else {
+ rows.push(vec![
+ req.shell,
+ "".to_string(),
+ format!("skipped ({reason})"),
+ ]);
+ }
+ } else {
+ let status = system::login_shell::status(&req)?;
+ let state = match &status.state {
+ LoginShellState::Set => "set",
+ LoginShellState::Differs { .. } => {
+ any_missing = true;
+ "differs"
+ }
+ LoginShellState::MissingFromShells { .. } => {
+ any_missing = true;
+ "missing from /etc/shells"
+ }
+ };
+ if self.json {
+ json_out.insert(
+ "login_shell".to_string(),
+ json!({
+ "available": true,
+ "shell": status.request.shell,
+ "user": status.user,
+ "current": status.current,
+ "shell_listed": status.shell_listed,
+ "state": state,
+ }),
+ );
+ } else {
+ rows.push(vec![
+ status.request.shell,
+ status.current,
+ state.to_string(),
+ ]);
+ }
+ }
+ }
+ if self.json {
+ miseprintln!("{}", serde_json::to_string_pretty(&json_out)?);
+ } else if rows.is_empty() {
+ info!("nothing configured in [bootstrap.user]");
+ } else {
+ let mut table = MiseTable::new(false, &["Shell", "Current", "State"]);
+ for row in rows {
+ table.add_row(row);
+ }
+ table.print()?;
+ }
+ if self.missing && any_missing {
+ crate::exit(1);
+ }
+ Ok(())
+ }
+}
+
static AFTER_LONG_HELP: &str = color_print::cstr!(
r#"Examples:
- $ mise bootstrap # system packages + tools + bootstrap task
- $ mise bootstrap --yes # don't prompt before installing system packages
- $ mise bootstrap --dry-run # show what would happen
+ $ mise bootstrap # packages + dotfiles + tools + bootstrap task
+ $ mise bootstrap packages install --yes
+ $ mise bootstrap macos-defaults status
+ $ mise bootstrap user apply --dry-run
"#
);
diff --git a/src/cli/doctor/mod.rs b/src/cli/doctor/mod.rs
index d608af862b..7d4accaae4 100644
--- a/src/cli/doctor/mod.rs
+++ b/src/cli/doctor/mod.rs
@@ -48,7 +48,7 @@ pub enum Commands {
Path(path::Path),
}
-/// outcome of the `[system.defaults]` doctor check
+/// outcome of the `[bootstrap.macos.defaults]` doctor check
enum SystemDefaultsDiagnosis {
Unavailable {
requested: usize,
@@ -64,7 +64,7 @@ enum SystemDefaultsDiagnosis {
},
}
-/// outcome of the `[system].login_shell` doctor check
+/// outcome of the `[bootstrap.user].login_shell` doctor check
enum SystemLoginShellDiagnosis {
Unavailable {
reason: String,
@@ -469,13 +469,13 @@ impl Doctor {
}
if total_missing > 0 {
self.warnings.push(format!(
- "{total_missing} system package(s) are missing, install them with `mise system install`"
+ "{total_missing} system package(s) are missing, install them with `mise bootstrap packages install`"
));
}
Some(map.into())
}
- /// Shared `[system.defaults]` check for the text and JSON doctor paths.
+ /// Shared `[bootstrap.macos.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,
@@ -503,7 +503,7 @@ impl Doctor {
.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`"
+ "{out_of_sync} macOS default(s) are out of sync, apply them with `mise bootstrap macos-defaults apply`"
));
}
Some(SystemDefaultsDiagnosis::Checked {
@@ -567,7 +567,7 @@ impl Doctor {
Ok(())
}
- /// Shared `[system].login_shell` check for the text and JSON doctor paths.
+ /// Shared `[bootstrap.user].login_shell` check for the text and JSON doctor paths.
/// Pushes the relevant warnings; returns None when nothing is configured.
async fn check_system_login_shell(
&mut self,
@@ -588,7 +588,7 @@ impl Doctor {
let out_of_sync = status.state != crate::system::login_shell::LoginShellState::Set;
if out_of_sync {
self.warnings.push(
- "login shell is out of sync, apply it with `mise system install`"
+ "login shell is out of sync, apply it with `mise bootstrap user apply`"
.to_string(),
);
}
@@ -705,7 +705,7 @@ impl Doctor {
}
if total_missing > 0 {
self.warnings.push(format!(
- "{total_missing} system package(s) are missing, install them with `mise system install`"
+ "{total_missing} system package(s) are missing, install them with `mise bootstrap packages install`"
));
}
info::section("system_packages", lines.join("\n"))?;
diff --git a/src/cli/dotfiles/add.rs b/src/cli/dotfiles/add.rs
new file mode 100644
index 0000000000..0a687f2b44
--- /dev/null
+++ b/src/cli/dotfiles/add.rs
@@ -0,0 +1,285 @@
+use std::path::PathBuf;
+
+use eyre::{Result, bail};
+use toml_edit::{DocumentMut, InlineTable, Item, Table, Value};
+
+use crate::config::config_file::ConfigFile;
+use crate::config::config_file::mise_toml::MiseToml;
+use crate::config::{Config, ConfigPathOptions, Settings, resolve_target_config_path};
+use crate::file;
+use crate::path::PathExt;
+use crate::system;
+use crate::system::files::{FileMode, FileRequest};
+use crate::ui::prompt;
+
+/// Add or update dotfiles in `[dotfiles]`
+///
+/// If the target is already managed, this updates its source from the live
+/// target. Otherwise it creates a `[dotfiles]` entry and seeds the source
+/// under `dotfiles.root` unless `--source` is provided.
+#[derive(Debug, clap::Args)]
+#[clap(verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)]
+pub struct DotfilesAdd {
+ /// Targets to add or update
+ #[clap(value_name = "TARGET", required = true)]
+ pub(super) targets: Vec,
+
+ /// Overwrite existing sources without prompting
+ #[clap(long, short)]
+ pub(super) force: bool,
+
+ /// Write to the global config
+ #[clap(long, short, conflicts_with_all = ["local", "path"])]
+ pub(super) global: bool,
+
+ /// Write to the local config instead of the global config
+ #[clap(long, short, conflicts_with_all = ["global", "path"])]
+ pub(super) local: bool,
+
+ /// Dotfile mode to write
+ #[clap(long, short)]
+ pub(super) mode: Option,
+
+ /// Print the config/source updates without writing anything
+ #[clap(long, short = 'n')]
+ pub(super) dry_run: bool,
+
+ /// Write to this config file or directory
+ #[clap(long, short, value_name = "PATH", conflicts_with_all = ["global", "local"])]
+ pub(super) path: Option,
+
+ /// Source path to use for a single target
+ #[clap(long, short, value_name = "PATH")]
+ pub(super) source: Option,
+
+ /// Skip the confirmation prompt
+ #[clap(long, short)]
+ pub(super) yes: bool,
+}
+
+impl DotfilesAdd {
+ pub async fn run(self) -> Result<()> {
+ Settings::get().ensure_experimental("mise dotfiles")?;
+ if self.source.is_some() && self.targets.len() != 1 {
+ bail!("--source can only be used with one target");
+ }
+ let mode = match self.mode.as_deref() {
+ Some(mode) => {
+ FileMode::parse(mode).ok_or_else(|| eyre::eyre!("unknown dotfile mode: {mode}"))?
+ }
+ None => system::files::default_mode(),
+ };
+ let config = Config::get().await?;
+ let managed = system::files::files_from_config(&config);
+ let config_path = resolve_target_config_path(ConfigPathOptions {
+ global: self.global || !self.local,
+ path: self.path.clone(),
+ env: None,
+ cwd: None,
+ prefer_toml: true,
+ prevent_home_local: true,
+ })?;
+
+ let mut planned = vec![];
+ let managed_edits = system::edits::edits_from_config(&config);
+ for target_raw in &self.targets {
+ let target = system::files::resolve_target_arg(target_raw);
+ if target.is_relative() {
+ bail!("{target_raw}: target must be absolute or start with ~/");
+ }
+ if managed_edits.iter().any(|req| {
+ system::files::matches_target(
+ &req.path,
+ &req.path_raw,
+ std::slice::from_ref(target_raw),
+ )
+ }) {
+ bail!(
+ "{target_raw}: target is already managed by [dotfiles] edits; remove or rename those entries before adding a whole-file dotfile"
+ );
+ }
+ let existing = managed.iter().find(|req| {
+ system::files::matches_target(
+ &req.target,
+ &req.target_raw,
+ std::slice::from_ref(target_raw),
+ )
+ });
+ let source = if let Some(req) = existing {
+ req.source.clone()
+ } else if let Some(source) = &self.source {
+ file::replace_path(source)
+ } else {
+ system::files::implied_source(&target)?
+ };
+ let write_mode = existing.map(|req| req.mode).unwrap_or(mode);
+ if let Some(req) = existing
+ && self.mode.is_some()
+ && req.mode != mode
+ {
+ warn!(
+ "dotfiles: {} is already managed with mode {}; --mode {} was ignored",
+ target_raw,
+ req.mode.name(),
+ mode.name()
+ );
+ }
+ planned.push(PlannedAdd {
+ target_raw: target_raw.clone(),
+ target,
+ source,
+ mode: write_mode,
+ implied_source: self.source.is_none(),
+ already_managed: existing.cloned(),
+ });
+ }
+
+ if self.dry_run {
+ for item in &planned {
+ if item.already_managed.is_none() {
+ miseprintln!(
+ "{}: \"{}\" = {}",
+ config_path.display_user(),
+ item.target_raw,
+ inline_entry(item)
+ );
+ }
+ if item.target.exists() {
+ miseprintln!(
+ "cp {} {}",
+ item.target.display_user(),
+ item.source.display_user()
+ );
+ }
+ }
+ return Ok(());
+ }
+
+ let writes_config = planned.iter().any(|item| item.already_managed.is_none());
+ let mut doc = if writes_config {
+ if !config_path.exists() {
+ let cf = MiseToml::init(&config_path);
+ cf.save()?;
+ }
+ let raw = file::read_to_string(&config_path)?;
+ let mut doc: DocumentMut = raw.parse()?;
+ ensure_dotfiles_table(&mut doc);
+ Some(doc)
+ } else {
+ None
+ };
+
+ let mut added_targets = vec![];
+ let mut updated_targets = vec![];
+ for item in &planned {
+ if item.target.exists() && !same_file(&item.target, &item.source) {
+ if item.source.exists()
+ && !self.force
+ && !self.yes
+ && console::user_attended_stderr()
+ {
+ let ok = prompt::confirm(format!(
+ "dotfiles: overwrite source {} from {}?",
+ item.source.display_user(),
+ item.target.display_user()
+ ))?;
+ if !ok {
+ info!("dotfiles: skipped {}", item.target_raw);
+ continue;
+ }
+ }
+ system::files::copy_path(&item.target, &item.source)?;
+ } else if !item.source.exists() {
+ if let Some(parent) = item.source.parent() {
+ file::create_dir_all(parent)?;
+ }
+ file::write(&item.source, "")?;
+ }
+ if item.already_managed.is_none()
+ && let Some(doc) = &mut doc
+ {
+ write_entry(doc, item);
+ added_targets.push(item.target_raw.as_str());
+ } else {
+ updated_targets.push(item.target_raw.as_str());
+ }
+ }
+
+ if let Some(doc) = doc {
+ file::write(&config_path, doc.to_string())?;
+ if !added_targets.is_empty() {
+ info!(
+ "{}: added {}",
+ config_path.display_user(),
+ added_targets.join(", ")
+ );
+ }
+ }
+ if !updated_targets.is_empty() {
+ info!("dotfiles: updated {}", updated_targets.join(", "));
+ }
+ Ok(())
+ }
+}
+
+#[derive(Debug)]
+struct PlannedAdd {
+ target_raw: String,
+ target: PathBuf,
+ source: PathBuf,
+ mode: FileMode,
+ implied_source: bool,
+ already_managed: Option,
+}
+
+fn ensure_dotfiles_table(doc: &mut DocumentMut) {
+ if !doc.as_table().contains_key("dotfiles") {
+ doc["dotfiles"] = Item::Table(Table::new());
+ }
+}
+
+fn write_entry(doc: &mut DocumentMut, item: &PlannedAdd) {
+ doc["dotfiles"][&item.target_raw] = Item::Value(inline_entry(item));
+}
+
+fn inline_entry(item: &PlannedAdd) -> Value {
+ let mut table = InlineTable::new();
+ if !item.implied_source {
+ table.insert(
+ "source",
+ Value::String(toml_edit::Formatted::new(
+ item.source.display_user().to_string(),
+ )),
+ );
+ } else if let Some(req) = &item.already_managed
+ && !system::files::source_is_implied(req)
+ {
+ table.insert(
+ "source",
+ Value::String(toml_edit::Formatted::new(
+ item.source.display_user().to_string(),
+ )),
+ );
+ }
+ table.insert(
+ "mode",
+ Value::String(toml_edit::Formatted::new(item.mode.name().to_string())),
+ );
+ Value::InlineTable(table)
+}
+
+fn same_file(a: &std::path::Path, b: &std::path::Path) -> bool {
+ match (a.canonicalize(), b.canonicalize()) {
+ (Ok(a), Ok(b)) => a == b,
+ _ => false,
+ }
+}
+
+static AFTER_LONG_HELP: &str = color_print::cstr!(
+ r#"Examples:
+
+ $ mise dotfiles add ~/.zshrc
+ $ mise dotfiles add --mode copy ~/.config/starship.toml
+ $ mise dotfiles add --source dotfiles/gitconfig ~/.gitconfig
+"#
+);
diff --git a/src/cli/dotfiles/apply.rs b/src/cli/dotfiles/apply.rs
new file mode 100644
index 0000000000..2447c4d4c3
--- /dev/null
+++ b/src/cli/dotfiles/apply.rs
@@ -0,0 +1,93 @@
+use eyre::Result;
+
+use crate::config::{Config, Settings};
+use crate::system;
+
+/// Apply dotfiles from `[dotfiles]`
+///
+/// Applies configured whole-file entries and edits that aren't in their
+/// desired state. Whole-file entries may symlink, copy, or render templates.
+/// Edit entries manage a marker-delimited block or a single line in a file
+/// mise doesn't otherwise own.
+#[derive(Debug, clap::Args)]
+#[clap(verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)]
+pub struct DotfilesApply {
+ /// Only apply these targets
+ #[clap(value_name = "TARGET")]
+ targets: Vec,
+
+ /// Overwrite existing files that conflict with whole-file dotfile entries
+ #[clap(long, short)]
+ force: bool,
+
+ /// Print the actions that would run without writing anything
+ #[clap(long, short = 'n')]
+ dry_run: bool,
+
+ /// Skip the confirmation prompt
+ #[clap(long, short)]
+ yes: bool,
+}
+
+impl DotfilesApply {
+ pub async fn run(self) -> Result<()> {
+ Settings::get().ensure_experimental("mise dotfiles")?;
+ let config = Config::get().await?;
+ let all_files = system::files::files_from_config(&config);
+ let files = all_files
+ .iter()
+ .filter(|req| {
+ system::files::matches_target(&req.target, &req.target_raw, &self.targets)
+ })
+ .cloned()
+ .collect::>();
+ let all_edits = system::edits::edits_from_config(&config);
+ let edits = all_edits
+ .iter()
+ .filter(|req| system::edits::matches_target(req, &self.targets))
+ .cloned()
+ .collect::>();
+ if files.is_empty()
+ && edits.is_empty()
+ && !self.targets.is_empty()
+ && (!all_files.is_empty() || !all_edits.is_empty())
+ {
+ eyre::bail!(
+ "no dotfiles matched target filter: {}",
+ self.targets.join(", ")
+ );
+ }
+ if files.is_empty() && edits.is_empty() {
+ info!("no dotfiles configured in [dotfiles]");
+ return Ok(());
+ }
+ if !files.is_empty() {
+ let opts = system::files::ApplyOpts {
+ dry_run: self.dry_run,
+ verbose: Settings::get().verbose,
+ force: self.force,
+ yes: self.yes,
+ };
+ system::files::apply(&config, &files, &opts)?;
+ }
+ if !edits.is_empty() {
+ let opts = system::edits::ApplyOpts {
+ dry_run: self.dry_run,
+ verbose: Settings::get().verbose,
+ yes: self.yes,
+ };
+ system::edits::apply(&config, &edits, &opts)?;
+ }
+ Ok(())
+ }
+}
+
+static AFTER_LONG_HELP: &str = color_print::cstr!(
+ r#"Examples:
+
+ $ mise dotfiles apply
+ $ mise dotfiles apply --dry-run
+ $ mise dotfiles apply --dry-run --verbose
+ $ mise dotfiles apply --force --yes
+"#
+);
diff --git a/src/cli/dotfiles/edit.rs b/src/cli/dotfiles/edit.rs
new file mode 100644
index 0000000000..ae99d1a2a0
--- /dev/null
+++ b/src/cli/dotfiles/edit.rs
@@ -0,0 +1,180 @@
+use std::path::PathBuf;
+
+use eyre::{Result, bail};
+
+use super::add::DotfilesAdd;
+use crate::config::{Config, Settings};
+use crate::file;
+use crate::system;
+use crate::system::edits::{BlockSource, EditOp};
+use crate::ui::prompt;
+
+/// Edit a managed dotfile source
+#[derive(Debug, clap::Args)]
+#[clap(verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)]
+pub struct DotfilesEdit {
+ /// Target to edit
+ #[clap(value_name = "TARGET")]
+ target: String,
+
+ /// Apply this target after the editor exits
+ #[clap(long)]
+ apply: bool,
+
+ /// Dotfile mode to use if the target is not yet managed
+ #[clap(long, short)]
+ mode: Option,
+
+ /// Source path to use if the target is not yet managed
+ #[clap(long, short, value_name = "PATH")]
+ source: Option,
+
+ /// Skip the confirmation prompt when adding an unmanaged target
+ #[clap(long, short)]
+ yes: bool,
+}
+
+impl DotfilesEdit {
+ pub async fn run(self) -> Result<()> {
+ Settings::get().ensure_experimental("mise dotfiles")?;
+ let mut config = Config::get().await?;
+ let target = system::files::resolve_target_arg(&self.target);
+
+ if let Some(path) = source_for_target(&config, &target, &self.target)? {
+ open_or_create(&path)?;
+ super::open_in_editor(&path)?;
+ if self.apply {
+ apply_target(&self.target).await?;
+ }
+ return Ok(());
+ }
+
+ if !self.yes && console::user_attended_stderr() {
+ let ok = prompt::confirm(format!("dotfiles: add {}?", self.target))?;
+ if !ok {
+ info!("dotfiles: skipped");
+ return Ok(());
+ }
+ } else if !self.yes {
+ bail!("{} is not managed by [dotfiles]", self.target);
+ }
+
+ DotfilesAdd {
+ targets: vec![self.target.clone()],
+ mode: self.mode.clone(),
+ source: self.source.clone(),
+ global: true,
+ local: false,
+ path: None,
+ dry_run: false,
+ force: false,
+ yes: true,
+ }
+ .run()
+ .await?;
+
+ config = Config::reset().await?;
+ let Some(path) = source_for_target(&config, &target, &self.target)? else {
+ bail!("failed to add {}", self.target);
+ };
+ open_or_create(&path)?;
+ super::open_in_editor(&path)?;
+ if self.apply {
+ apply_target(&self.target).await?;
+ }
+ Ok(())
+ }
+}
+
+fn source_for_target(
+ config: &Config,
+ target: &std::path::Path,
+ raw: &str,
+) -> Result