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> { + for req in system::files::files_from_config(config) { + if system::files::matches_target(&req.target, &req.target_raw, &[raw.to_string()]) { + return Ok(Some(req.source)); + } + } + let matching_edits = system::edits::edits_from_config(config) + .into_iter() + .filter(|req| system::edits::matches_target(req, &[raw.to_string()])) + .collect::>(); + match matching_edits.as_slice() { + [] => {} + [req] => { + return Ok(Some(match &req.op { + EditOp::Block { + source: BlockSource::File(path), + .. + } => path.clone(), + EditOp::Block { + source: BlockSource::Inline(_), + .. + } + | EditOp::Line { .. } => req.config_path.clone(), + })); + } + edits => { + let keys = edits + .iter() + .map(|req| req.config_key()) + .collect::>() + .join(", "); + bail!("{raw}: multiple [dotfiles] edit entries match; choose one of: {keys}"); + } + } + if target.is_relative() { + bail!("{raw}: target must be absolute or start with ~/"); + } + Ok(None) +} + +fn open_or_create(path: &std::path::Path) -> Result<()> { + if !path.exists() { + if let Some(parent) = path.parent() { + file::create_dir_all(parent)?; + } + file::write(path, "")?; + } + Ok(()) +} + +async fn apply_target(target: &str) -> Result<()> { + let config = Config::reset().await?; + let targets = vec![target.to_string()]; + let files = system::files::files_from_config(&config) + .into_iter() + .filter(|req| system::files::matches_target(&req.target, &req.target_raw, &targets)) + .collect::>(); + let edits = system::edits::edits_from_config(&config) + .into_iter() + .filter(|req| system::edits::matches_target(req, &targets)) + .collect::>(); + if !files.is_empty() { + let opts = system::files::ApplyOpts { + dry_run: false, + verbose: false, + force: false, + yes: true, + }; + system::files::apply(&config, &files, &opts)?; + } + if !edits.is_empty() { + let opts = system::edits::ApplyOpts { + dry_run: false, + verbose: false, + yes: true, + }; + system::edits::apply(&config, &edits, &opts)?; + } + Ok(()) +} + +static AFTER_LONG_HELP: &str = color_print::cstr!( + r#"Examples: + + $ mise dotfiles edit ~/.zshrc + $ mise dotfiles edit --apply ~/.config/starship.toml +"# +); diff --git a/src/cli/dotfiles/mod.rs b/src/cli/dotfiles/mod.rs new file mode 100644 index 0000000000..7a7ac8ca45 --- /dev/null +++ b/src/cli/dotfiles/mod.rs @@ -0,0 +1,57 @@ +use clap::Subcommand; +use eyre::{Result, eyre}; +use std::path::Path; + +mod add; +mod apply; +mod edit; +mod status; + +/// [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`. +#[derive(Debug, clap::Args)] +#[clap(verbatim_doc_comment)] +pub struct Dotfiles { + #[clap(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand)] +enum Commands { + Add(add::DotfilesAdd), + Apply(apply::DotfilesApply), + Edit(edit::DotfilesEdit), + Status(status::DotfilesStatus), +} + +impl Dotfiles { + pub async fn run(self) -> Result<()> { + match self.command { + Commands::Add(cmd) => cmd.run().await, + Commands::Apply(cmd) => cmd.run().await, + Commands::Edit(cmd) => cmd.run().await, + Commands::Status(cmd) => cmd.run().await, + } + } +} + +fn open_in_editor(file: &Path) -> Result<()> { + let (program, mut args) = split_editor_command(&crate::env::EDITOR)?; + args.push(file.as_os_str().into()); + crate::cmd::cmd(&program, args).run()?; + Ok(()) +} + +fn split_editor_command(editor: &str) -> Result<(String, Vec)> { + let mut parts = shell_words::split(editor) + .map_err(|e| eyre!("failed to parse EDITOR/VISUAL value {:?}: {}", editor, e))? + .into_iter(); + let program = parts + .next() + .ok_or_else(|| eyre!("EDITOR/VISUAL is empty"))?; + Ok((program, parts.map(Into::into).collect())) +} diff --git a/src/cli/dotfiles/status.rs b/src/cli/dotfiles/status.rs new file mode 100644 index 0000000000..04739b00c9 --- /dev/null +++ b/src/cli/dotfiles/status.rs @@ -0,0 +1,166 @@ +use eyre::Result; +use serde_json::json; + +use crate::config::{Config, Settings}; +use crate::path::PathExt; +use crate::system; +use crate::system::files::FileState; +use crate::ui::table::MiseTable; + +/// Show the status of dotfiles from `[dotfiles]` +#[derive(Debug, clap::Args)] +#[clap(visible_alias = "ls", verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] +pub struct DotfilesStatus { + /// Only show these targets + #[clap(value_name = "TARGET")] + targets: Vec, + + /// Output in JSON format + #[clap(long, short = 'J')] + json: bool, + + /// Exit with code 1 if any configured dotfiles are not in their desired + /// state (missing, source missing, differs) + #[clap(long, verbatim_doc_comment)] + missing: bool, +} + +impl DotfilesStatus { + pub async fn run(self) -> Result<()> { + Settings::get().ensure_experimental("mise dotfiles")?; + let config = Config::get().await?; + let mut any_missing = false; + + 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 mut file_rows: Vec> = vec![]; + let mut json_files = vec![]; + for req in &files { + let state = match system::files::check(&config, req) { + Ok(state) => state, + Err(err) => FileState::Differs(format!("{err}")), + }; + let state_str = match &state { + FileState::Applied => "applied".to_string(), + FileState::Missing => "missing".to_string(), + FileState::SourceMissing => "source missing".to_string(), + FileState::Differs(reason) => format!("differs ({reason})"), + }; + any_missing |= state != FileState::Applied; + if self.json { + json_files.push(json!({ + "target": req.target_raw, + "source": req.source.display_user(), + "mode": req.mode.name(), + "state": match &state { + FileState::Applied => "applied", + FileState::Missing => "missing", + FileState::SourceMissing => "source_missing", + FileState::Differs(_) => "differs", + }, + })); + } else { + file_rows.push(vec![ + req.target_raw.clone(), + req.mode.name().to_string(), + req.source.display_user(), + state_str, + ]); + } + } + + 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(", ") + ); + } + let mut edit_rows: Vec> = vec![]; + let mut json_edits = vec![]; + for req in &edits { + let state = match system::edits::check(&config, req) { + Ok(state) => state, + Err(err) => FileState::Differs(format!("{err}")), + }; + let state_str = match &state { + FileState::Applied => "applied".to_string(), + FileState::Missing => "missing".to_string(), + FileState::SourceMissing => "source missing".to_string(), + FileState::Differs(reason) => format!("differs ({reason})"), + }; + any_missing |= state != FileState::Applied; + if self.json { + json_edits.push(json!({ + "path": req.path_raw, + "edit": req.describe_op(), + "state": match &state { + FileState::Applied => "applied", + FileState::Missing => "missing", + FileState::SourceMissing => "source_missing", + FileState::Differs(_) => "differs", + }, + })); + } else { + edit_rows.push(vec![req.path_raw.clone(), req.describe_op(), state_str]); + } + } + + if self.json { + miseprintln!( + "{}", + serde_json::to_string_pretty(&json!({ + "files": json_files, + "edits": json_edits, + }))? + ); + } else { + if file_rows.is_empty() && edit_rows.is_empty() { + info!("nothing configured in [dotfiles]"); + } + if !file_rows.is_empty() { + let mut table = MiseTable::new(false, &["Target", "Mode", "Source", "State"]); + for row in file_rows { + table.add_row(row); + } + table.print()?; + } + if !edit_rows.is_empty() { + let mut table = MiseTable::new(false, &["File", "Edit", "State"]); + for row in edit_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 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/src/cli/install.rs b/src/cli/install.rs index 28d4f85259..3505bb30e9 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -115,7 +115,7 @@ impl Install { Ok(()) } - /// one-time hint when `[system.packages]` entries are missing — mise + /// one-time hint when `[bootstrap.packages]` entries are missing — mise /// never installs system packages implicitly async fn hint_missing_system_packages(&self) { // the status queries spawn package-manager processes; skip them @@ -135,7 +135,7 @@ impl Install { // when everything is satisfied the hint never fires, so also // throttle the checks to once per day — but only while the set of // packages that would actually be checked is unchanged, so editing - // [system.packages] or widening system_packages.managers re-checks + // [bootstrap.packages] or widening system_packages.managers re-checks // immediately let fingerprint = mgrs .iter() @@ -186,8 +186,8 @@ impl Install { if missing > 0 { hint!( "system_packages_missing", - "{missing} system package(s) from [system.packages] are missing. Install them with", - "mise system install" + "{missing} system package(s) from [bootstrap.packages] are missing. Install them with", + "mise bootstrap packages install" ); } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0f96f15ff2..0813600b37 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -22,6 +22,7 @@ mod current; mod deactivate; mod direnv; mod doctor; +mod dotfiles; mod en; mod env; pub mod exec; @@ -217,6 +218,7 @@ pub enum Commands { Current(current::Current), Deactivate(deactivate::Deactivate), Direnv(direnv::Direnv), + Dotfiles(dotfiles::Dotfiles), Doctor(doctor::Doctor), En(en::En), Env(env::Env), @@ -257,7 +259,6 @@ pub enum Commands { ShellAlias(shell_alias::ShellAlias), Sponsors(sponsors::Sponsors), Sync(sync::Sync), - System(system::System), Tasks(tasks::Tasks), TestTool(test_tool::TestTool), Token(token::Token), @@ -292,6 +293,7 @@ impl Commands { Self::Current(cmd) => cmd.run().await, Self::Deactivate(cmd) => cmd.run(), Self::Direnv(cmd) => cmd.run().await, + Self::Dotfiles(cmd) => cmd.run().await, Self::Doctor(cmd) => cmd.run().await, Self::En(cmd) => cmd.run().await, Self::Env(cmd) => cmd.run().await, @@ -332,7 +334,6 @@ impl Commands { Self::ShellAlias(cmd) => cmd.run().await, Self::Sponsors(cmd) => cmd.run(), Self::Sync(cmd) => cmd.run().await, - Self::System(cmd) => cmd.run().await, Self::Tasks(cmd) => cmd.run().await, Self::TestTool(cmd) => cmd.run().await, Self::Token(cmd) => cmd.run().await, diff --git a/src/cli/oci/common.rs b/src/cli/oci/common.rs index 19de46f8d9..893c839cbd 100644 --- a/src/cli/oci/common.rs +++ b/src/cli/oci/common.rs @@ -58,7 +58,7 @@ pub async fn perform_build(opts: BuildOptions, include_global: bool) -> Result Result Result<()> { let mut defaults = 0usize; for cf in config_files.values() { - let Some(system) = cf.system_config() else { + let Some(system) = cf.bootstrap_config() else { continue; }; defaults += system + .macos .defaults .values() .map(|v| v.as_table().map_or(1, |t| t.len())) @@ -132,7 +133,7 @@ fn reject_unsupported_system_defaults(config_files: &ConfigMap) -> Result<()> { if defaults > 0 { bail!( - "mise oci does not support [system.defaults] (found {defaults} default entries); \ + "mise oci does not support [bootstrap.macos.defaults] (found {defaults} default entries); \ macOS defaults do not apply to OCI images." ); } diff --git a/src/cli/system/brew/mod.rs b/src/cli/system/brew/mod.rs index 19ed1e0580..1d5e8fdcb0 100644 --- a/src/cli/system/brew/mod.rs +++ b/src/cli/system/brew/mod.rs @@ -1,13 +1,13 @@ use clap::Subcommand; use eyre::Result; -mod tap; -mod untap; +pub(super) mod tap; +pub(super) mod untap; -/// Manage Homebrew taps used by system packages +/// Manage Homebrew taps used by bootstrap 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. +/// `[bootstrap.brew.taps]` when you want tap sources shared in config. #[derive(Debug, clap::Args)] #[clap(verbatim_doc_comment)] pub struct SystemBrew { @@ -23,7 +23,7 @@ enum Commands { impl SystemBrew { pub async fn run(self) -> Result<()> { - crate::config::Settings::get().ensure_experimental("mise system")?; + crate::config::Settings::get().ensure_experimental("mise bootstrap")?; match self.command { Commands::Tap(cmd) => cmd.run().await, Commands::Untap(cmd) => cmd.run().await, diff --git a/src/cli/system/brew/tap.rs b/src/cli/system/brew/tap.rs index 368f227b29..307d3c087a 100644 --- a/src/cli/system/brew/tap.rs +++ b/src/cli/system/brew/tap.rs @@ -25,7 +25,7 @@ impl SystemBrewTap { static AFTER_LONG_HELP: &str = color_print::cstr!( r#"Examples: - $ mise system brew tap railwaycat/emacsmacport - $ mise system brew tap acme/tools https://git.example.com/acme/homebrew-tools.git + $ 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/src/cli/system/brew/untap.rs b/src/cli/system/brew/untap.rs index cf5e62aa3f..b935a20fd3 100644 --- a/src/cli/system/brew/untap.rs +++ b/src/cli/system/brew/untap.rs @@ -22,6 +22,6 @@ impl SystemBrewUntap { static AFTER_LONG_HELP: &str = color_print::cstr!( r#"Examples: - $ mise system brew untap railwaycat/emacsmacport + $ mise bootstrap packages brew untap railwaycat/emacsmacport "# ); diff --git a/src/cli/system/driver.rs b/src/cli/system/driver.rs index 4f129a11fb..288472daa0 100644 --- a/src/cli/system/driver.rs +++ b/src/cli/system/driver.rs @@ -1,4 +1,4 @@ -//! Shared per-manager execution loop for `mise system install`/`upgrade`/`use`. +//! Shared per-manager execution loop for `mise bootstrap packages install`/`upgrade`/`use`. use std::collections::HashMap; @@ -57,7 +57,7 @@ pub(crate) async fn run(mgrs: Vec, action: Action, d: &DriverOp bail!("no packages requested for manager '{only}'"); } if mgrs.is_empty() { - info!("no system packages configured in [system.packages]"); + info!("no bootstrap packages configured in [bootstrap.packages]"); return Ok(()); } let opts = InstallOpts { @@ -104,7 +104,9 @@ pub(crate) async fn run(mgrs: Vec, action: Action, d: &DriverOp .collect(); let skipped = statuses.len() - targets.len(); if action == Action::Upgrade && skipped > 0 { - warn!("{name}: {skipped} package(s) not installed — run `mise system install` first"); + warn!( + "{name}: {skipped} package(s) not installed — run `mise bootstrap packages install` first" + ); } // a pin this manager can never satisfy must not block the rest // of the batch — it stays visible in `status` as a mismatch diff --git a/src/cli/system/install.rs b/src/cli/system/install.rs index 9fb964f697..e5f3f2b202 100644 --- a/src/cli/system/install.rs +++ b/src/cli/system/install.rs @@ -4,18 +4,11 @@ use super::driver::{self, Action, DriverOpts}; use crate::config::{Config, Settings}; use crate::system; -/// 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` +/// 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). 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. +/// 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 @@ -25,14 +18,10 @@ use crate::system; #[clap(visible_alias = "i", verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] pub struct SystemInstall { /// Packages in `manager:package` form; defaults to everything configured - /// in [system.packages] + /// in [bootstrap.packages] #[clap(value_name = "PACKAGE")] packages: Vec, - /// Overwrite existing files that conflict with `[system.files]` entries - #[clap(long, short)] - force: bool, - /// Only install packages for this manager, e.g. `apt` or `brew` #[clap(long, short, value_parser = ["apt", "brew", "dnf", "pacman"])] manager: Option, @@ -52,27 +41,14 @@ pub struct SystemInstall { impl SystemInstall { pub async fn run(self) -> Result<()> { - Settings::get().ensure_experimental("mise system")?; - // defaults only participate in the full converge-everything form — - // explicit package specs and --manager filters scope the run to - // packages - let mut defaults = vec![]; - let mut login_shell = None; + Settings::get().ensure_experimental("mise bootstrap")?; let mgrs = if self.packages.is_empty() { let config = Config::get().await?; - if self.manager.is_none() { - defaults = system::defaults_from_config(&config); - login_shell = system::login_shell_from_config(&config); - } system::packages_from_config(&config) } else { let config = Config::get().await?; system::packages_from_specs_with_config(&self.packages, Some(&config))? }; - // explicit packages or a --manager filter narrow the run to those - // packages; files, edits, and defaults are part of the "apply - // everything" form only - let packages_only = !self.packages.is_empty() || self.manager.is_some(); let opts = DriverOpts { manager: self.manager.clone(), explicit: !self.packages.is_empty(), @@ -80,49 +56,12 @@ impl SystemInstall { update: self.update, yes: self.yes, }; - let (files, edits) = if packages_only { - (vec![], vec![]) - } else { - let config = Config::get().await?; - ( - system::files::files_from_config(&config), - system::edits::edits_from_config(&config), - ) - }; - // when only defaults/files/edits are configured, skip the driver so - // it doesn't print "no system packages configured" - if !mgrs.is_empty() - || (defaults.is_empty() - && login_shell.is_none() - && files.is_empty() - && edits.is_empty()) - { - driver::run(mgrs, Action::Install, &opts).await?; - } - if !files.is_empty() { - let config = Config::get().await?; - let apply_opts = system::files::ApplyOpts { - dry_run: self.dry_run, - force: self.force, - yes: self.yes, - }; - system::files::apply(&config, &files, &apply_opts)?; - } - if !edits.is_empty() { - let config = Config::get().await?; - let apply_opts = system::edits::ApplyOpts { - dry_run: self.dry_run, - yes: self.yes, - }; - system::edits::apply(&config, &edits, &apply_opts)?; - } - apply_defaults(defaults, self.dry_run, self.yes).await?; - apply_login_shell(login_shell, self.dry_run, self.yes) + driver::run(mgrs, Action::Install, &opts).await } } -/// Apply `[system.defaults]` entries that are unset or differ — shared by -/// `mise system install` and `mise bootstrap`. Inert off-macOS. +/// Apply `[bootstrap.macos.defaults]` entries that are unset or differ. +/// Inert off-macOS. pub(crate) async fn apply_defaults( defaults: Vec, dry_run: bool, @@ -133,7 +72,7 @@ pub(crate) async fn apply_defaults( return Ok(()); } if !defaults::is_available() { - // cross-platform config: [system.defaults] is simply inert off-macOS + // cross-platform config: [bootstrap.macos.defaults] is simply inert off-macOS debug!("defaults: skipping, {}", defaults::unavailable_reason()); return Ok(()); } @@ -169,8 +108,8 @@ pub(crate) async fn apply_defaults( Ok(()) } -/// Apply `[system].login_shell` when it differs - shared by `mise system -/// install` and `mise bootstrap`. Inert off-Unix or when `chsh` is missing. +/// Apply `[bootstrap.user].login_shell` when it differs for `mise bootstrap`. +/// Inert off-Unix or when `chsh` is missing. pub(crate) fn apply_login_shell( request: Option, dry_run: bool, @@ -212,9 +151,9 @@ pub(crate) fn apply_login_shell( static AFTER_LONG_HELP: &str = color_print::cstr!( r#"Examples: - $ mise system install - $ mise system install apt:curl brew:jq - $ mise system install --dry-run - $ mise system install --manager apt --yes + $ 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/src/cli/system/mod.rs b/src/cli/system/mod.rs index cd2997de9b..19828d074e 100644 --- a/src/cli/system/mod.rs +++ b/src/cli/system/mod.rs @@ -1,55 +1,8 @@ -use clap::Subcommand; -use eyre::Result; - #[cfg(unix)] -mod brew; +pub(super) mod brew; pub(super) mod driver; pub(super) mod install; -mod status; -mod upgrade; +pub(super) mod status; +pub(super) mod upgrade; #[path = "use.rs"] -mod r#use; - -/// [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`. -#[derive(Debug, clap::Args)] -#[clap(verbatim_doc_comment)] -pub struct System { - #[clap(subcommand)] - command: Commands, -} - -#[derive(Debug, Subcommand)] -enum Commands { - #[cfg(unix)] - Brew(brew::SystemBrew), - Install(install::SystemInstall), - Status(status::SystemStatus), - Upgrade(upgrade::SystemUpgrade), - Use(r#use::SystemUse), -} - -impl System { - pub async fn run(self) -> Result<()> { - match self.command { - #[cfg(unix)] - Commands::Brew(cmd) => cmd.run().await, - Commands::Install(cmd) => cmd.run().await, - Commands::Status(cmd) => cmd.run().await, - Commands::Upgrade(cmd) => cmd.run().await, - Commands::Use(cmd) => cmd.run().await, - } - } -} +pub(super) mod r#use; diff --git a/src/cli/system/status.rs b/src/cli/system/status.rs index ebe4dddced..e49c15469c 100644 --- a/src/cli/system/status.rs +++ b/src/cli/system/status.rs @@ -2,17 +2,11 @@ use eyre::Result; use serde_json::json; use crate::config::{Config, Settings}; -use crate::path::PathExt; use crate::system; -use crate::system::defaults::DefaultsState; -use crate::system::files::FileState; -use crate::system::login_shell::LoginShellState; use crate::system::packages::PackageState; use crate::ui::table::MiseTable; -/// 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` +/// Show the status of system packages from `[bootstrap.packages]` #[derive(Debug, clap::Args)] #[clap(visible_alias = "ls", verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] pub struct SystemStatus { @@ -20,15 +14,14 @@ pub struct SystemStatus { #[clap(long, short = 'J')] json: bool, - /// Exit with code 1 if any configured packages, files, edits, defaults, or - /// login shell are not in their desired state + /// Exit with code 1 if any configured packages are not in their desired state #[clap(long, verbatim_doc_comment)] missing: bool, } impl SystemStatus { pub async fn run(self) -> Result<()> { - Settings::get().ensure_experimental("mise system")?; + Settings::get().ensure_experimental("mise bootstrap")?; let config = Config::get().await?; let mgrs = system::packages_from_config(&config); let mut any_missing = false; @@ -96,205 +89,11 @@ impl SystemStatus { ); } } - let files = system::files::files_from_config(&config); - let mut file_rows: Vec> = vec![]; - let mut json_files = vec![]; - for req in &files { - let state = match system::files::check(&config, req) { - Ok(state) => state, - // e.g. a template that fails to render — visible, not fatal - Err(err) => FileState::Differs(format!("{err}")), - }; - let state_str = match &state { - FileState::Applied => "applied".to_string(), - FileState::Missing => "missing".to_string(), - FileState::SourceMissing => "source missing".to_string(), - FileState::Differs(reason) => format!("differs ({reason})"), - }; - any_missing |= state != FileState::Applied; - if self.json { - json_files.push(json!({ - "target": req.target_raw, - "source": req.source.display_user(), - "mode": req.mode.name(), - "state": match &state { - FileState::Applied => "applied", - FileState::Missing => "missing", - FileState::SourceMissing => "source_missing", - FileState::Differs(_) => "differs", - }, - })); - } else { - file_rows.push(vec![ - req.target_raw.clone(), - req.mode.name().to_string(), - req.source.display_user(), - state_str, - ]); - } - } - let edits = system::edits::edits_from_config(&config); - let mut edit_rows: Vec> = vec![]; - let mut json_edits = vec![]; - for req in &edits { - let state = match system::edits::check(&config, req) { - Ok(state) => state, - // e.g. a template that fails to render — visible, not fatal - Err(err) => FileState::Differs(format!("{err}")), - }; - let state_str = match &state { - FileState::Applied => "applied".to_string(), - FileState::Missing => "missing".to_string(), - FileState::SourceMissing => "source missing".to_string(), - FileState::Differs(reason) => format!("differs ({reason})"), - }; - any_missing |= state != FileState::Applied; - if self.json { - json_edits.push(json!({ - "path": req.path_raw, - "edit": req.describe_op(), - "state": match &state { - FileState::Applied => "applied", - FileState::Missing => "missing", - FileState::SourceMissing => "source_missing", - FileState::Differs(_) => "differs", - }, - })); - } else { - edit_rows.push(vec![req.path_raw.clone(), req.describe_op(), state_str]); - } - } - let defaults = system::defaults_from_config(&config); - let mut defaults_rows: Vec> = vec![]; - if !defaults.is_empty() { - if !system::defaults::is_available() { - let reason = system::defaults::unavailable_reason(); - if self.json { - json_out.insert( - "defaults".to_string(), - json!({ "available": false, "reason": reason }), - ); - } else { - for req in &defaults { - defaults_rows.push(vec![ - req.domain.clone(), - req.key.clone(), - req.value.to_string(), - "".to_string(), - format!("skipped ({reason})"), - ]); - } - } - } else { - let statuses = system::defaults::status(&defaults).await?; - let mut json_entries = vec![]; - for s in statuses { - let (current, state) = match &s.state { - DefaultsState::Set => (s.request.value.to_string(), "set"), - DefaultsState::Differs { current } => { - any_missing = true; - (current.clone(), "differs") - } - DefaultsState::Unset => { - any_missing = true; - ("".to_string(), "unset") - } - }; - if self.json { - json_entries.push(json!({ - "domain": s.request.domain, - "key": s.request.key, - "value": s.request.value.to_json(), - "current": current, - "state": state, - })); - } else { - defaults_rows.push(vec![ - s.request.domain.clone(), - s.request.key.clone(), - s.request.value.to_string(), - current, - state.to_string(), - ]); - } - } - if self.json { - json_out.insert( - "defaults".to_string(), - json!({ "available": true, "entries": json_entries }), - ); - } - } - } - let login_shell = system::login_shell_from_config(&config); - let mut login_shell_rows: Vec> = vec![]; - 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 { - login_shell_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 { - login_shell_rows.push(vec![ - status.request.shell, - status.current, - state.to_string(), - ]); - } - } - } if self.json { - json_out.insert("files".to_string(), json!(json_files)); - json_out.insert("edits".to_string(), json!(json_edits)); miseprintln!("{}", serde_json::to_string_pretty(&json_out)?); } else { - if rows.is_empty() - && file_rows.is_empty() - && edit_rows.is_empty() - && defaults_rows.is_empty() - && login_shell_rows.is_empty() - { - info!( - "nothing configured in [system.packages], [system.files], [system.edits], [system.defaults], or [system].login_shell" - ); + if rows.is_empty() { + info!("nothing configured in [bootstrap.packages]"); } if !rows.is_empty() { let mut table = @@ -304,35 +103,6 @@ impl SystemStatus { } table.print()?; } - if !file_rows.is_empty() { - let mut table = MiseTable::new(false, &["Target", "Mode", "Source", "State"]); - for row in file_rows { - table.add_row(row); - } - table.print()?; - } - if !edit_rows.is_empty() { - let mut table = MiseTable::new(false, &["File", "Edit", "State"]); - for row in edit_rows { - table.add_row(row); - } - table.print()?; - } - if !defaults_rows.is_empty() { - let mut table = - MiseTable::new(false, &["Domain", "Key", "Value", "Current", "State"]); - for row in defaults_rows { - table.add_row(row); - } - table.print()?; - } - if !login_shell_rows.is_empty() { - let mut table = MiseTable::new(false, &["Shell", "Current", "State"]); - for row in login_shell_rows { - table.add_row(row); - } - table.print()?; - } } if self.missing && any_missing { crate::exit(1); @@ -344,8 +114,8 @@ impl SystemStatus { static AFTER_LONG_HELP: &str = color_print::cstr!( r#"Examples: - $ mise system status - $ mise system status --json - $ mise system status --missing # exit 1 if anything is out of sync + $ 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/src/cli/system/upgrade.rs b/src/cli/system/upgrade.rs index 0fec44bf0c..ca8834d056 100644 --- a/src/cli/system/upgrade.rs +++ b/src/cli/system/upgrade.rs @@ -4,20 +4,20 @@ use super::driver::{self, Action, DriverOpts}; use crate::config::{Config, Settings}; use crate::system; -/// 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. #[derive(Debug, clap::Args)] #[clap(visible_alias = "up", verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] pub struct SystemUpgrade { /// Packages in `manager:package` form; defaults to everything configured - /// in [system.packages] + /// in [bootstrap.packages] #[clap(value_name = "PACKAGE")] packages: Vec, @@ -36,7 +36,7 @@ pub struct SystemUpgrade { impl SystemUpgrade { pub async fn run(self) -> Result<()> { - Settings::get().ensure_experimental("mise system")?; + Settings::get().ensure_experimental("mise bootstrap")?; let mgrs = if self.packages.is_empty() { let config = Config::get().await?; system::packages_from_config(&config) @@ -60,9 +60,9 @@ impl SystemUpgrade { static AFTER_LONG_HELP: &str = color_print::cstr!( r#"Examples: - $ mise system upgrade - $ mise system upgrade brew:postgresql@17 - $ mise system upgrade --manager apt --yes - $ mise system upgrade --dry-run + $ 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/src/cli/system/use.rs b/src/cli/system/use.rs index bd45344cc6..ddb4e935f1 100644 --- a/src/cli/system/use.rs +++ b/src/cli/system/use.rs @@ -11,13 +11,13 @@ use crate::file::display_path; use crate::system; use crate::system::packages::PackageRequest; -/// 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. @@ -52,7 +52,7 @@ pub struct SystemUse { impl SystemUse { pub async fn run(self) -> Result<()> { - Settings::get().ensure_experimental("mise system")?; + Settings::get().ensure_experimental("mise bootstrap")?; let config = crate::config::Config::get().await?; let mut by_mgr: IndexMap> = IndexMap::new(); let mut entries: Vec<(String, String)> = vec![]; @@ -82,7 +82,7 @@ impl SystemUse { path: self.path.clone(), env: self.env.clone(), cwd: None, - prefer_toml: true, // [system] only exists in mise.toml + prefer_toml: true, // [bootstrap] only exists in mise.toml prevent_home_local: true, // in $HOME, write the global config })?; if self.dry_run { @@ -96,7 +96,7 @@ impl SystemUse { MiseToml::init(&path) }; for (key, version) in &entries { - cf.update_system_package(key, version)?; + cf.update_bootstrap_package(key, version)?; } cf.save()?; info!( @@ -110,7 +110,7 @@ impl SystemUse { ); } - // unlike `mise system install apt:x`, an unavailable manager is not + // unlike `mise bootstrap packages install apt:x`, an unavailable manager is not // an error here: writing apt: entries from a mac into a shared repo // config is the point of a declarative file. Say so (except in // dry-run, where nothing was written), then install best-effort for @@ -140,8 +140,8 @@ impl SystemUse { static AFTER_LONG_HELP: &str = color_print::cstr!( r#"Examples: - $ mise system use apt:curl brew:jq - $ mise system use -g brew:postgresql@17 - $ mise system use apt:curl@8.5.0-2 + $ 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/src/config/config_file/mise_toml.rs b/src/config/config_file/mise_toml.rs index 3f36030f42..311be10dfa 100644 --- a/src/config/config_file/mise_toml.rs +++ b/src/config/config_file/mise_toml.rs @@ -32,7 +32,7 @@ use crate::hooks::{Hook, HookDef, Hooks}; use crate::oci::OciConfig; use crate::redactions::Redactions; use crate::registry::REGISTRY; -use crate::system::SystemTomlConfig; +use crate::system::{BootstrapTomlConfig, DotfilesTomlConfig}; use crate::task::{Task, TaskTemplate}; use crate::tera::{BASE_CONTEXT, contains_template_syntax, get_tera, render_str}; use crate::toolset::{ToolRequest, ToolRequestSet, ToolSource, ToolVersionOptions}; @@ -179,7 +179,9 @@ pub struct MiseToml { #[serde(default)] oci: Option, #[serde(default)] - system: Option, + bootstrap: Option, + #[serde(default)] + dotfiles: Option, #[serde(default)] vars: EnvList, #[serde(default)] @@ -517,24 +519,24 @@ impl MiseToml { Ok(()) } - /// Set `[system.packages].":" = ""`, + /// Set `[bootstrap.packages].":" = ""`, /// creating the tables as needed ("latest" means no pin) - pub fn update_system_package(&mut self, spec: &str, version: &str) -> eyre::Result<()> { - self.system + pub fn update_bootstrap_package(&mut self, spec: &str, version: &str) -> eyre::Result<()> { + self.bootstrap .get_or_insert_with(Default::default) .packages .insert(spec.to_string(), version.to_string()); let mut doc = self.doc_mut()?; - let system = doc + let bootstrap = doc .get_mut() .unwrap() - .entry("system") + .entry("bootstrap") .or_insert_with(table) .as_table_mut() .unwrap(); - // don't render an empty [system] header above [system.packages] - system.set_implicit(true); - let packages = system + // don't render an empty [bootstrap] header above [bootstrap.packages] + bootstrap.set_implicit(true); + let packages = bootstrap .entry("packages") .or_insert_with(table) .as_table_mut() @@ -1051,8 +1053,12 @@ impl ConfigFile for MiseToml { self.oci.clone() } - fn system_config(&self) -> Option { - self.system.clone() + fn bootstrap_config(&self) -> Option { + self.bootstrap.clone() + } + + fn dotfiles_config(&self) -> Option { + self.dotfiles.clone() } } @@ -1129,7 +1135,8 @@ impl Clone for MiseToml { watch_files: self.watch_files.clone(), deps: self.deps.clone(), oci: self.oci.clone(), - system: self.system.clone(), + bootstrap: self.bootstrap.clone(), + dotfiles: self.dotfiles.clone(), vars: self.vars.clone(), monorepo_root: self.monorepo_root, monorepo: self.monorepo.clone(), @@ -2137,25 +2144,25 @@ mod tests { } #[tokio::test] - async fn test_system_packages() { + async fn test_bootstrap_packages() { let _config = Config::get().await.unwrap(); let p = CWD.as_ref().unwrap().join(".test.mise.toml"); file::write( &p, r#" - [system.packages] + [bootstrap.packages] "apt:libssl-dev" = "latest" "apt:curl" = "8.5.0-2" "brew:postgresql@17" = "latest" "future-manager:whatever" = "latest" - [system.brew.taps] + [bootstrap.brew.taps] "railwaycat/emacsmacport" = "https://github.com/railwaycat/homebrew-emacsmacport" "#, ) .unwrap(); let cf = MiseToml::from_file(&p).unwrap(); - let system = cf.system_config().unwrap(); + let system = cf.bootstrap_config().unwrap(); assert_eq!(system.packages.get("apt:libssl-dev").unwrap(), "latest"); assert_eq!(system.packages.get("apt:curl").unwrap(), "8.5.0-2"); assert_eq!(system.packages.get("brew:postgresql@17").unwrap(), "latest"); @@ -2163,68 +2170,60 @@ mod tests { system.brew.taps.get("railwaycat/emacsmacport").unwrap(), "https://github.com/railwaycat/homebrew-emacsmacport" ); - assert_eq!(system.login_shell, None); + assert_eq!(system.user.login_shell, None); // unknown managers parse fine (forward compatibility) assert_eq!( system.packages.get("future-manager:whatever").unwrap(), "latest" ); - // no [system] section -> None + // no [bootstrap] section -> None file::write(&p, "[tools]\n").unwrap(); let cf = MiseToml::from_file(&p).unwrap(); - assert!(cf.system_config().is_none()); + assert!(cf.bootstrap_config().is_none()); file::remove_file(&p).unwrap(); } #[tokio::test] - async fn test_system_login_shell() { + async fn test_bootstrap_login_shell() { let _config = Config::get().await.unwrap(); let p = CWD.as_ref().unwrap().join(".test.mise.toml"); file::write( &p, r#" - [system] + [bootstrap.user] login_shell = "/bin/zsh" "#, ) .unwrap(); let cf = MiseToml::from_file(&p).unwrap(); - let system = cf.system_config().unwrap(); - assert_eq!(system.login_shell.as_deref(), Some("/bin/zsh")); + let system = cf.bootstrap_config().unwrap(); + assert_eq!(system.user.login_shell.as_deref(), Some("/bin/zsh")); file::remove_file(&p).unwrap(); } #[tokio::test] - async fn test_system_defaults() { + async fn test_bootstrap_macos_defaults() { let _config = Config::get().await.unwrap(); let p = CWD.as_ref().unwrap().join(".test.mise.toml"); file::write( &p, r#" - [system.defaults.NSGlobalDomain] - KeyRepeat = 2 - ApplePressAndHoldEnabled = false - - [system.defaults."com.apple.dock"] - autohide = true - tilesize = 48 - magnification-scale = 1.5 - orientation = "left" - # unsupported shapes still parse (forward compatibility) - future-array = [1, 2] + [bootstrap.macos.defaults] + NSGlobalDomain = { KeyRepeat = 2, ApplePressAndHoldEnabled = false } + "com.apple.dock" = { autohide = true, tilesize = 48, magnification-scale = 1.5, orientation = "left", future-array = [1, 2] } "#, ) .unwrap(); let cf = MiseToml::from_file(&p).unwrap(); - let system = cf.system_config().unwrap(); - let global = system.defaults.get("NSGlobalDomain").unwrap(); + let system = cf.bootstrap_config().unwrap(); + let global = system.macos.defaults.get("NSGlobalDomain").unwrap(); assert_eq!(global.get("KeyRepeat").unwrap(), &toml::Value::Integer(2)); assert_eq!( global.get("ApplePressAndHoldEnabled").unwrap(), &toml::Value::Boolean(false) ); - let dock = system.defaults.get("com.apple.dock").unwrap(); + let dock = system.macos.defaults.get("com.apple.dock").unwrap(); assert_eq!(dock.get("autohide").unwrap(), &toml::Value::Boolean(true)); assert_eq!(dock.get("tilesize").unwrap(), &toml::Value::Integer(48)); assert_eq!( @@ -2240,26 +2239,26 @@ mod tests { } #[tokio::test] - async fn test_update_system_package() { + async fn test_update_bootstrap_package() { let _config = Config::get().await.unwrap(); let p = CWD.as_ref().unwrap().join(".test.mise.toml"); - // creates [system.packages] when absent, preserves other sections + // creates [bootstrap.packages] when absent, preserves other sections file::write(&p, "[tools]\njq = \"latest\"\n").unwrap(); let mut cf = MiseToml::from_file(&p).unwrap(); - cf.update_system_package("apt:curl", "latest").unwrap(); - cf.update_system_package("brew:postgresql@17", "latest") + cf.update_bootstrap_package("apt:curl", "latest").unwrap(); + cf.update_bootstrap_package("brew:postgresql@17", "latest") .unwrap(); // overrides an existing pin in place - cf.update_system_package("apt:curl", "8.5.0-2").unwrap(); + cf.update_bootstrap_package("apt:curl", "8.5.0-2").unwrap(); assert_snapshot!(cf.dump().unwrap(), @r#" [tools] jq = "latest" - [system.packages] + [bootstrap.packages] "apt:curl" = "8.5.0-2" "brew:postgresql@17" = "latest" "#); - let system = cf.system_config().unwrap(); + let system = cf.bootstrap_config().unwrap(); assert_eq!(system.packages.get("apt:curl").unwrap(), "8.5.0-2"); file::remove_file(&p).unwrap(); } diff --git a/src/config/config_file/mod.rs b/src/config/config_file/mod.rs index 67a7c62972..2b367aa8aa 100644 --- a/src/config/config_file/mod.rs +++ b/src/config/config_file/mod.rs @@ -149,7 +149,11 @@ pub trait ConfigFile: Debug + Send + Sync { None } - fn system_config(&self) -> Option { + fn bootstrap_config(&self) -> Option { + None + } + + fn dotfiles_config(&self) -> Option { None } } diff --git a/src/oci/builder.rs b/src/oci/builder.rs index 9fd93ec645..3101d2dc20 100644 --- a/src/oci/builder.rs +++ b/src/oci/builder.rs @@ -45,7 +45,7 @@ pub struct Builder { pub ts: Toolset, pub oci: OciConfig, pub opts: BuildOptions, - pub system_files: Vec, + pub dotfiles: Vec, pub system_packages: Vec, } @@ -70,13 +70,13 @@ impl Builder { ts, oci, opts, - system_files: vec![], + dotfiles: vec![], system_packages: vec![], } } - pub fn with_system_files(mut self, system_files: Vec) -> Self { - self.system_files = system_files; + pub fn with_dotfiles(mut self, dotfiles: Vec) -> Self { + self.dotfiles = dotfiles; self } @@ -265,15 +265,11 @@ impl Builder { } } - // --- 5. System files layer (optional) --- - let system_files_layer = if self.system_files.is_empty() { + // --- 5. Dotfiles layer (optional) --- + let dotfiles_layer = if self.dotfiles.is_empty() { None } else { - Some(build_system_files_layer( - &self.cfg, - &self.system_files, - owner, - )?) + Some(build_dotfiles_layer(&self.cfg, &self.dotfiles, owner)?) }; // --- 6. Config layer: /etc/mise/config.toml --- @@ -339,10 +335,10 @@ impl Builder { }); } - if let Some(blob) = &system_files_layer { + if let Some(blob) = &dotfiles_layer { layout.write_blob_with_digest(&blob.digest, &blob.bytes)?; let mut annotations = IndexMap::new(); - annotations.insert("dev.mise.system.files".to_string(), "true".to_string()); + annotations.insert("dev.mise.dotfiles".to_string(), "true".to_string()); manifest_layers.push(Descriptor { media_type: manifest::MEDIA_TYPE_OCI_LAYER_GZIP.to_string(), size: blob.size, @@ -656,17 +652,17 @@ fn resolve_layer_owner(opts_owner: Option, oci: &OciConfig) -> Layer }) } -fn build_system_files_layer( +fn build_dotfiles_layer( cfg: &Config, requests: &[FileRequest], owner: LayerOwner, ) -> Result { - let mut entries = SystemFileLayerEntries::default(); + let mut entries = DotfilesLayerEntries::default(); for req in requests { if !req.source.exists() { bail!( - "[system.files].\"{}\": source does not exist: {}", + "[dotfiles].\"{}\": source does not exist: {}", req.target_raw, req.source.display() ); @@ -676,13 +672,13 @@ fn build_system_files_layer( FileMode::Symlink | FileMode::Copy => { collect_source_as_files(&req.source, &oci_target_path(req)?, &mut entries) .wrap_err_with(|| { - format!("adding [system.files].\"{}\" to OCI image", req.target_raw) + format!("adding [dotfiles].\"{}\" to OCI image", req.target_raw) })?; } FileMode::SymlinkEach => { if !req.source.is_dir() { bail!( - "[system.files].\"{}\": mode symlink-each requires a directory source: {}", + "[dotfiles].\"{}\": mode symlink-each requires a directory source: {}", req.target_raw, req.source.display() ); @@ -715,7 +711,7 @@ fn build_system_files_layer( } } - info!("oci: adding {} [system.files] entries", requests.len()); + info!("oci: adding {} [dotfiles] entries", requests.len()); let (files, dirs) = entries.into_layer_inputs(); layer::build_layer_from_files_and_dirs(&files, &dirs, owner) } @@ -723,7 +719,7 @@ fn build_system_files_layer( fn collect_source_as_files( source: &std::path::Path, target: &str, - entries: &mut SystemFileLayerEntries, + entries: &mut DotfilesLayerEntries, ) -> Result<()> { if source.is_dir() { entries.add_dir(target.to_string())?; @@ -742,7 +738,7 @@ fn collect_source_as_files( let ft = entry.file_type(); if !(ft.is_file() || ft.is_symlink()) { warn!( - "oci: skipping non-file [system.files] source entry {}", + "oci: skipping non-file [dotfiles] source entry {}", entry.path().display() ); continue; @@ -762,23 +758,23 @@ fn collect_source_as_files( } #[derive(Default)] -struct SystemFileLayerEntries { +struct DotfilesLayerEntries { files: IndexMap, u32)>, dirs: IndexSet, } -type SystemFileLayerFile = (String, Vec, u32); -type SystemFileLayerFiles = Vec; -type SystemFileLayerDirs = Vec; +type DotfilesLayerFile = (String, Vec, u32); +type DotfilesLayerFiles = Vec; +type DotfilesLayerDirs = Vec; -impl SystemFileLayerEntries { +impl DotfilesLayerEntries { fn add_file(&mut self, path: String, contents: Vec, mode: u32) -> Result<()> { if self.dirs.contains(&path) { - bail!("[system.files]: duplicate OCI path {path:?} as both file and directory"); + bail!("[dotfiles]: duplicate OCI path {path:?} as both file and directory"); } if let Some((existing_contents, existing_mode)) = self.files.get(&path) { if existing_contents != &contents || *existing_mode != mode { - bail!("[system.files]: duplicate OCI file path {path:?}"); + bail!("[dotfiles]: duplicate OCI file path {path:?}"); } return Ok(()); } @@ -788,13 +784,13 @@ impl SystemFileLayerEntries { fn add_dir(&mut self, path: String) -> Result<()> { if self.files.contains_key(&path) { - bail!("[system.files]: duplicate OCI path {path:?} as both file and directory"); + bail!("[dotfiles]: duplicate OCI path {path:?} as both file and directory"); } self.dirs.insert(path); Ok(()) } - fn into_layer_inputs(self) -> (SystemFileLayerFiles, SystemFileLayerDirs) { + fn into_layer_inputs(self) -> (DotfilesLayerFiles, DotfilesLayerDirs) { let files = self .files .into_iter() @@ -814,13 +810,13 @@ fn oci_target_path(req: &FileRequest) -> Result { } else { req.target .strip_prefix("/") - .map_err(|_| eyre::eyre!("system file target must be absolute: {}", req.target_raw))? + .map_err(|_| eyre::eyre!("dotfile target must be absolute: {}", req.target_raw))? .to_string_lossy() .replace('\\', "/") }; if path.is_empty() || path.split('/').any(|p| p == "..") { bail!( - "[system.files].\"{}\": target is not a safe OCI path", + "[dotfiles].\"{}\": target is not a safe OCI path", req.target_raw ); } diff --git a/src/oci/packages.rs b/src/oci/packages.rs index 91054e2884..33603bc53e 100644 --- a/src/oci/packages.rs +++ b/src/oci/packages.rs @@ -1,4 +1,4 @@ -//! Native `[system.packages]` support for OCI builds. +//! Native `[bootstrap.packages]` support for OCI builds. //! //! This intentionally does not use a container engine. For apt-based base //! images, mise unpacks the pulled base image into a temporary rootfs, asks @@ -51,7 +51,7 @@ pub fn build_system_packages_layer( } if base_layers.is_empty() { bail!( - "mise oci requires an apt-based base image when [system.packages] is configured; \ + "mise oci requires an apt-based base image when [bootstrap.packages] is configured; \ `scratch` has no apt metadata" ); } @@ -69,7 +69,7 @@ pub fn build_system_packages_layer( unpack_base_layers(layout, base_layers, &rootfs)?; if !rootfs.join("etc/apt").is_dir() { bail!( - "mise oci found apt packages in [system.packages], but the base image does not \ + "mise oci found apt packages in [bootstrap.packages], but the base image does not \ contain /etc/apt. Use a Debian/Ubuntu base image or remove the apt entries." ); } @@ -111,7 +111,7 @@ fn collect_apt_requests(managers: &[ManagerPackages]) -> Result <-type> ` //! and checked with `defaults read-type`/`defaults read`. Like -//! `[system.packages]` they are machine-global, declarative, and only ever -//! applied when explicitly requested with `mise system install`. +//! `[bootstrap.packages]` they are machine-global, declarative, and only ever +//! applied when explicitly requested with `mise bootstrap macos-defaults apply` +//! or `mise bootstrap`. use std::process::Stdio; use crate::result::Result; -/// A single `[system.defaults.]` entry: `key = value` +/// A single `[bootstrap.macos.defaults.]` entry: `key = value` #[derive(Debug, Clone, PartialEq)] pub struct DefaultsRequest { /// preferences domain, e.g. "com.apple.dock" or "NSGlobalDomain" @@ -68,7 +69,7 @@ impl DefaultsValue { /// Does the pair from `defaults read-type` ("boolean", "integer", ...) /// and `defaults read` (raw value; booleans print as 1/0) match this /// value? Types are compared strictly: an integer 1 does not satisfy a - /// configured `true` — `mise system install` converges it to the typed + /// configured `true` — `mise bootstrap macos-defaults apply` converges it to the typed /// value. fn matches(&self, read_type: &str, raw: &str) -> bool { match self { diff --git a/src/system/edits.rs b/src/system/edits.rs index 90a3438246..e3624a0c1f 100644 --- a/src/system/edits.rs +++ b/src/system/edits.rs @@ -1,29 +1,27 @@ -//! `[system.edits]` — declarative edits to files mise doesn't own, -//! applied by `mise system install` or `mise bootstrap`. +//! `[dotfiles]` — declarative edits to files mise doesn't own, +//! applied by `mise dotfiles apply` or `mise bootstrap`. //! -//! Where `[system.files]` manages whole files, an edit owns one small piece +//! Where whole-file dotfile entries manage whole files, an edit owns one small piece //! of a file something else owns — the `mise activate` line in a shell rc, -//! an entry in /etc/hosts. Entries are keyed by target path (like -//! `[system.files]`), then by an id naming each edit within the file: +//! an entry in /etc/hosts. Entries are keyed by target path plus an id naming +//! each edit within the file: //! //! ```toml -//! [system.edits] -//! "~/.zshrc" = { -//! activate = 'eval "$(mise activate zsh)"', # string = inline block -//! aliases = { source = "snippets/aliases.sh" }, -//! } -//! "/etc/hosts" = { dev = { line = "127.0.0.1 dev.local" } } +//! [dotfiles] +//! "~/.zshrc/activate" = { block = 'eval "$(mise activate zsh)"' } +//! "~/.zshrc/aliases" = { source = "snippets/aliases.sh", template = "tera" } +//! "/etc/hosts/dev" = { line = "127.0.0.1 dev.local" } //! ``` //! //! A `block` is delimited by marker comments in the target file — //! `# >>> mise:activate >>>` / `# <<< mise:activate <<<` — which double as //! the ownership record: apply replaces only what's between them, so the -//! design stays stateless like the rest of `[system]`. A `line` ensures an +//! design stays stateless like the rest of `[dotfiles]`. A `line` ensures an //! exact line exists, appending it if absent. //! //! Entries merge across the config hierarchy as a union keyed by //! `(path, id)` — a more local config overrides an edit with the same id, -//! exactly like `[system.files]` overrides by target. +//! exactly like whole-file entries override by target. use std::path::{Path, PathBuf}; @@ -37,8 +35,7 @@ use crate::path::PathExt; use crate::system::files::FileState; use crate::ui::prompt; -/// one `[system.edits]` entry as written in mise.toml, keyed by -/// `path -> id`. Operations stay loosely typed so configs using operations +/// one `[dotfiles]` edit entry as written in mise.toml. Operations stay loosely typed so configs using operations /// from newer mise versions still parse (entries with no recognized /// operation warn and are skipped) #[derive(Debug, Clone, Deserialize)] @@ -107,6 +104,8 @@ pub struct EditRequest { /// directory of the declaring config file — base dir for relative /// sources and template functions like `exec` and `read_file` pub base: PathBuf, + /// config file that declared this edit + pub config_path: PathBuf, } impl EditRequest { @@ -117,38 +116,101 @@ impl EditRequest { EditOp::Line { .. } => format!("line:{}", self.id), } } + + pub fn config_key(&self) -> String { + format!("{}/{}", self.path_raw.trim_end_matches('/'), self.id) + } +} + +pub fn matches_target(req: &EditRequest, filters: &[String]) -> bool { + filters.is_empty() + || filters.iter().any(|filter| { + filter == &req.path_raw + || filter == &req.config_key() + || filter == &format!("{}/{}", req.path.display_user(), req.id) + || filter.rsplit_once('/').is_some_and(|(path, id)| { + id == req.id && { + let resolved = crate::system::files::resolve_target_arg(path); + resolved == req.path + } + }) + || { + let resolved = crate::system::files::resolve_target_arg(filter); + resolved == req.path + } + }) } -/// Aggregate `[system.edits]` across all loaded config files. Entries union -/// global -> local, keyed by `(path, id)`; a more local config overrides an -/// edit with the same id. Malformed entries warn and are skipped. +/// Aggregate edit `[dotfiles]` entries across all loaded config files. Entries +/// union global -> local, keyed by `(path, id)`; a more local config overrides +/// an edit with the same id. Malformed entries warn and are skipped. pub fn edits_from_config(config: &Config) -> Vec { let mut merged: IndexMap = IndexMap::new(); // config_files is ordered local -> global; reverse for global -> local for (cf_path, cf) in config.config_files.iter().rev() { - let Some(sys) = cf.system_config() else { + let base = cf_path.parent().unwrap_or(Path::new(".")).to_path_buf(); + let Some(dotfiles) = cf.dotfiles_config() else { continue; }; - let base = cf_path.parent().unwrap_or(Path::new(".")).to_path_buf(); - for (path_raw, entries) in sys.edits { - for (id, entry) in entries { - match resolve_entry(&path_raw, id, entry, &base) { + for (path_and_id, value) in dotfiles.0 { + let Some(entry) = edit_entry_from_toml(&path_and_id, value) else { + continue; + }; + match split_edit_key(&path_and_id) { + Some((path_raw, id)) => match resolve_entry(&path_raw, id, entry, &base, cf_path) { Ok(req) => { merged.insert(format!("{}\u{0}{}", req.path.display(), req.id), req); } - Err(err) => warn!("[system.edits]: {err}"), - } + Err(err) => warn!("[dotfiles]: {err}"), + }, + None => warn!( + "[dotfiles].\"{path_and_id}\": edit entries must end with an id path segment" + ), } } } merged.into_values().collect() } +fn edit_entry_from_toml(path_and_id: &str, value: toml::Value) -> Option { + match &value { + toml::Value::Table(table) => { + let is_whole_file_table = table.is_empty() + || table.contains_key("mode") + || table.contains_key("source") + && !table.contains_key("block") + && !table.contains_key("line") + && !table.contains_key("template") + && !table.contains_key("comment"); + if is_whole_file_table { + return None; + } + } + _ => return None, + } + match value.try_into() { + Ok(entry) => Some(entry), + Err(err) => { + warn!("[dotfiles].\"{path_and_id}\": invalid edit entry: {err}"); + None + } + } +} + +fn split_edit_key(path_and_id: &str) -> Option<(String, String)> { + let (path, id) = path_and_id.rsplit_once('/')?; + if path.is_empty() || path == "~" || path == "/" || id.is_empty() { + return None; + } + Some((path.to_string(), id.to_string())) +} + fn resolve_entry( path_raw: &str, id: String, entry: EditTomlEntry, base: &Path, + config_path: &Path, ) -> Result { let path = file::replace_path(path_raw); if path.is_relative() { @@ -237,6 +299,7 @@ fn resolve_entry( id, op, base: base.to_path_buf(), + config_path: config_path.to_path_buf(), }) } @@ -330,7 +393,7 @@ fn desired_content(config: &Config, req: &EditRequest) -> Result> let mut tera = crate::tera::get_tera(Some(&req.base)); tera.render_str(&raw, &config.tera_ctx).map_err(|err| { eyre::eyre!( - "[system.edits].\"{}\".{}: failed to render template: {err}", + "[dotfiles].\"{}/{}\": failed to render template: {err}", req.path_raw, req.id ) @@ -344,7 +407,7 @@ fn desired_content(config: &Config, req: &EditRequest) -> Result> for pat in [format!(">>> mise:{id} >>>"), format!("<<< mise:{id} <<<")] { if content.lines().any(|l| is_marker_line(l, &pat, comment)) { bail!( - "[system.edits].\"{}\".{}: block content may not contain its own marker lines", + "[dotfiles].\"{}/{}\": block content may not contain its own marker lines", req.path_raw, req.id ); @@ -357,7 +420,7 @@ fn desired_content(config: &Config, req: &EditRequest) -> Result> /// /// Note: comparing a template block against existing markers requires /// rendering it, so this can run the template engine — including `exec()` — -/// from `mise system status`. That's the same trust model as `[env]` +/// from `mise dotfiles status`. That's the same trust model as `[env]` /// templates. Rendering only happens once every render-free outcome (symlink /// target, missing file, absent or corrupted markers) has been ruled out, /// and `--dry-run` skips template rendering entirely (see [`apply`]). @@ -396,7 +459,7 @@ enum EditCheck { /// has been consulted, so blocked entries never execute template code fn precheck(req: &EditRequest) -> Result> { // edits write through symlinks into whatever they point at (often a - // [system.files] source) — surface that instead of silently doing it + // dotfile source) — surface that instead of silently doing it if req.path.is_symlink() { return Ok(Some(EditCheck::Blocked(SYMLINK_REASON.into()))); } @@ -444,6 +507,7 @@ fn block_state(req: &EditRequest, desired: Option<&str>) -> Result { pub struct ApplyOpts { pub dry_run: bool, + pub verbose: bool, pub yes: bool, } @@ -498,7 +562,7 @@ pub fn apply(config: &Config, requests: &[EditRequest], opts: &ApplyOpts) -> Res } // rendering can run exec() — a dry run must not execute anything, // so template blocks are listed without computing their content - // (same policy as [system.files]) + // (same policy as template file entries) if opts.dry_run && matches!(&req.op, EditOp::Block { template: true, .. }) { todo.push((req, None)); continue; @@ -551,6 +615,9 @@ pub fn apply(config: &Config, requests: &[EditRequest], opts: &ApplyOpts) -> Res req.path.display_user(), req.describe_op() ); + if opts.verbose && !conditional { + miseprintln!(" desired {}", req.describe_op()); + } } return Ok(()); } diff --git a/src/system/files.rs b/src/system/files.rs index c26c7908a2..4921a26eb5 100644 --- a/src/system/files.rs +++ b/src/system/files.rs @@ -1,19 +1,20 @@ -//! `[system.files]` — declarative config files (dotfiles) applied by -//! `mise system install` or `mise bootstrap`. +//! `[dotfiles]` — declarative config files (dotfiles) applied by +//! `mise dotfiles apply` or `mise bootstrap`. //! //! Entries are keyed by target path and point at a source file or directory, //! resolved relative to the config file that declares them: //! //! ```toml -//! [system.files] -//! "~/.gitconfig" = "dotfiles/gitconfig" # symlink (default) -//! "~/.config/foo.toml" = { source = "foo.toml", mode = "copy" } +//! [dotfiles] +//! "~/.zshrc" = {} # implied source +//! "~/.gitconfig" = "dotfiles/gitconfig" # explicit source +//! "~/.config/foo.toml" = { mode = "copy" } # implied source //! "~/.ssh/config" = { source = "ssh.tmpl", mode = "template" } //! "~/.config/nvim" = "dotfiles/nvim" # symlink the dir itself //! "~/.local/bin" = { source = "bin", mode = "symlink-each" } //! ``` //! -//! Like `[system.packages]`, entries merge across the config hierarchy +//! Like `[bootstrap.packages]`, entries merge across the config hierarchy //! (global -> local, local overrides by target key) and are only ever //! applied by an explicit command, never implicitly. @@ -25,7 +26,8 @@ use itertools::Itertools; use regex::Regex; use serde::Deserialize; -use crate::config::{Config, ConfigMap}; +use crate::config::{Config, ConfigMap, Settings}; +use crate::dirs; use crate::file; use crate::path::PathExt; use crate::ui::prompt; @@ -46,7 +48,7 @@ pub enum FileMode { } impl FileMode { - fn parse(s: &str) -> Option { + pub fn parse(s: &str) -> Option { match s { "symlink" => Some(Self::Symlink), "symlink-each" => Some(Self::SymlinkEach), @@ -66,17 +68,19 @@ impl FileMode { } } -/// one `[system.files]` entry as written in mise.toml +/// one `[dotfiles]` whole-file entry as written in mise.toml #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] pub enum FileTomlEntry { /// `"~/.gitconfig" = "dotfiles/gitconfig"` Source(String), - /// `"~/.gitconfig" = { source = "...", mode = "..." }` — mode stays a + /// `"~/.gitconfig" = { source = "...", mode = "..." }` — both fields are + /// optional so `{}` can mean implied source/default mode. Mode stays a /// string here so configs using modes from newer mise versions still /// parse (they warn and are skipped, like unknown package managers) Table { - source: String, + #[serde(default)] + source: Option, #[serde(default)] mode: Option, }, @@ -90,7 +94,7 @@ pub struct FileRequest { /// absolute target path (`~` expanded) pub target: PathBuf, /// absolute source path (relative sources resolve against the config - /// file's directory) + /// file's directory; omitted sources resolve under dotfiles.root) pub source: PathBuf, pub mode: FileMode, /// directory of the declaring config file — base dir for template @@ -107,14 +111,14 @@ pub enum FileState { SourceMissing, } -/// Aggregate `[system.files]` across all loaded config files. Keys union -/// global -> local; a more local config overrides an entry for the same -/// target. Malformed entries and unknown modes warn and are skipped. +/// Aggregate whole-file `[dotfiles]` entries across all loaded config files. +/// Keys union global -> local; a more local config overrides an entry for the +/// same target. Malformed entries and unknown modes warn and are skipped. pub fn files_from_config(config: &Config) -> Vec { files_from_config_files(&config.config_files) } -/// Aggregate `[system.files]` across a specific set of config files. This is +/// Aggregate `[dotfiles]` across a specific set of config files. This is /// used by OCI builds, which intentionally scope config to project files by /// default instead of blindly inheriting global dotfiles. pub fn files_from_config_files(config_files: &ConfigMap) -> Vec { @@ -123,46 +127,163 @@ pub fn files_from_config_files(config_files: &ConfigMap) -> Vec { let mut merged: IndexMap = IndexMap::new(); // config_files is ordered local -> global; reverse for global -> local for (path, cf) in config_files.iter().rev() { - let Some(sys) = cf.system_config() else { + let base = path.parent().unwrap_or(Path::new(".")).to_path_buf(); + let Some(dotfiles) = cf.dotfiles_config() else { continue; }; - let base = path.parent().unwrap_or(Path::new(".")).to_path_buf(); - for (target_raw, entry) in sys.files { - let (source, mode) = match entry { - FileTomlEntry::Source(source) => (source, None), - FileTomlEntry::Table { source, mode } => (source, mode), - }; - let mode = match mode.as_deref() { - None => FileMode::Symlink, - Some(m) => match FileMode::parse(m) { - Some(m) => m, - None => { - warn!( - "[system.files].\"{target_raw}\": unknown mode '{m}', ignoring entry" - ); - continue; - } - }, - }; - let target = file::replace_path(&target_raw); - if target.is_relative() { - warn!( - "[system.files].\"{target_raw}\": target must be absolute or start with ~/, ignoring entry" - ); + for (target_raw, value) in dotfiles.0 { + let Some(entry) = file_entry_from_toml(&target_raw, value) else { continue; + }; + merge_file_entry(target_raw, entry, &base, &mut merged); + } + } + merged.into_values().collect() +} + +fn file_entry_from_toml(target_raw: &str, value: toml::Value) -> Option { + match &value { + toml::Value::String(_) => {} + toml::Value::Table(table) + if table.is_empty() + || table.contains_key("mode") + || (table.contains_key("source") + && !table.contains_key("block") + && !table.contains_key("line") + && !table.contains_key("template") + && !table.contains_key("comment")) => {} + toml::Value::Table(_) => return None, + _ => { + warn!("[dotfiles].\"{target_raw}\": expected string or table entry, ignoring entry"); + return None; + } + } + match value.try_into() { + Ok(entry) => Some(entry), + Err(err) => { + warn!("[dotfiles].\"{target_raw}\": invalid file entry: {err}"); + None + } + } +} + +fn merge_file_entry( + target_raw: String, + entry: FileTomlEntry, + base: &Path, + merged: &mut IndexMap, +) { + let (source, mode) = match entry { + FileTomlEntry::Source(source) => (Some(source), None), + FileTomlEntry::Table { source, mode } => (source, mode), + }; + let mode = match mode.as_deref() { + None => default_mode(), + Some(m) => match FileMode::parse(m) { + Some(m) => m, + None => { + warn!("[dotfiles].\"{target_raw}\": unknown mode '{m}', ignoring entry"); + return; } + }, + }; + let target = file::replace_path(&target_raw); + if target.is_relative() { + warn!( + "[dotfiles].\"{target_raw}\": target must be absolute or start with ~/, ignoring entry" + ); + return; + } + let source = match source { + Some(source) => { let source = file::replace_path(&source); - let source = if source.is_relative() { + if source.is_relative() { base.join(source) } else { source - }; - for req in expand_request(target_raw, target, source, mode, base.clone()) { - merged.insert(req.target.clone(), req); } } + None => match implied_source(&target) { + Ok(source) => source, + Err(err) => { + warn!("[dotfiles].\"{target_raw}\": {err}, ignoring entry"); + return; + } + }, + }; + for req in expand_request(target_raw, target, source, mode, base.to_path_buf()) { + merged.insert(req.target.clone(), req); } - merged.into_values().collect() +} + +pub fn default_mode() -> FileMode { + let settings = Settings::get(); + let mode = settings.dotfiles.default_mode.as_str(); + match FileMode::parse(mode) { + Some(mode) => mode, + None => { + warn!("dotfiles.default_mode: unknown mode '{mode}', using symlink"); + FileMode::Symlink + } + } +} + +pub fn dotfiles_root() -> PathBuf { + file::replace_path(&Settings::get().dotfiles.root) +} + +pub fn implied_source(target: &Path) -> Result { + let home: &Path = &dirs::HOME; + let rel = target.strip_prefix(home).map_err(|_| { + eyre::eyre!( + "source is required for targets outside $HOME: {}", + target.display_user() + ) + })?; + if rel.as_os_str().is_empty() { + bail!("source is required for the home directory itself"); + } + Ok(dotfiles_root().join(rel)) +} + +pub fn source_is_implied(req: &FileRequest) -> bool { + match implied_source(&req.target) { + Ok(source) => source == req.source, + Err(_) => false, + } +} + +pub fn resolve_target_arg(target: &str) -> PathBuf { + file::replace_path(target) +} + +pub fn matches_target(req_target: &Path, req_raw: &str, filters: &[String]) -> bool { + filters.is_empty() + || filters.iter().any(|filter| { + filter == req_raw || { + let resolved = resolve_target_arg(filter); + resolved == req_target + } + }) +} + +pub fn copy_path(source: &Path, target: &Path) -> Result<()> { + if let Some(parent) = target.parent() { + file::create_dir_all(parent)?; + } + if source.is_dir() { + if target.exists() && !target.is_dir() { + remove_existing(target)?; + } + file::create_dir_all(target)?; + file::copy_dir_all(source, target)?; + } else { + if target.is_symlink() { + file::remove_file(target)?; + } + file::copy(source, target)?; + } + Ok(()) } fn expand_request( @@ -189,7 +310,7 @@ fn expand_request( Ok(path) => Some(path), Err(err) => { warn!( - "[system.files].\"{target_raw}\": error reading source pattern {source_pattern}: {err}" + "[dotfiles].\"{target_raw}\": error reading source pattern {source_pattern}: {err}" ); None } @@ -197,12 +318,12 @@ fn expand_request( .sorted() .collect_vec(), Err(err) => { - warn!("[system.files].\"{target_raw}\": invalid source pattern: {err}"); + warn!("[dotfiles].\"{target_raw}\": invalid source pattern: {err}"); return vec![]; } }; if matches.is_empty() { - warn!("[system.files].\"{target_raw}\": source pattern matched no files, ignoring entry"); + warn!("[dotfiles].\"{target_raw}\": source pattern matched no files, ignoring entry"); return vec![]; } @@ -210,7 +331,7 @@ fn expand_request( if !is_glob_pattern(&target) { if matches.len() > 1 { warn!( - "[system.files].\"{target_raw}\": source pattern matched multiple paths but target has no wildcard, ignoring entry" + "[dotfiles].\"{target_raw}\": source pattern matched multiple paths but target has no wildcard, ignoring entry" ); return vec![]; } @@ -229,13 +350,13 @@ fn expand_request( let captures = match wildcard_captures(&source_pattern, &matched_source) { Ok(captures) => captures, Err(err) => { - warn!("[system.files].\"{target_raw}\": {err}"); + warn!("[dotfiles].\"{target_raw}\": {err}"); return None; } }; let Some(target_path) = expand_target_pattern(&target_pattern, &captures) else { warn!( - "[system.files].\"{target_raw}\": target wildcard count does not match source pattern, ignoring {}", + "[dotfiles].\"{target_raw}\": target wildcard count does not match source pattern, ignoring {}", matched_source.display_user() ); return None; @@ -377,7 +498,7 @@ where /// Current state of one entry on this machine. /// /// Note: computing a template entry's state requires rendering it, so this -/// runs the template engine — including `exec()` — from `mise system +/// runs the template engine — including `exec()` — from `mise dotfiles /// status`. That's the same trust model as `[env]` templates (which run on /// every command in a trusted config); only `--dry-run` promises to execute /// nothing and therefore skips template checks entirely. @@ -546,7 +667,7 @@ pub fn render_template(config: &Config, req: &FileRequest) -> Result { let mut tera = crate::tera::get_tera(Some(&req.base)); let rendered = tera.render_str(&raw, &config.tera_ctx).map_err(|err| { eyre::eyre!( - "[system.files].\"{}\": failed to render template {}: {err}", + "[dotfiles].\"{}\": failed to render template {}: {err}", req.target_raw, req.source.display_user() ) @@ -588,6 +709,7 @@ fn walk_source_files(req: &FileRequest) -> Result> { pub struct ApplyOpts { pub dry_run: bool, + pub verbose: bool, /// replace conflicting targets (existing real files where a symlink /// should go, or type mismatches) instead of erroring pub force: bool, @@ -610,7 +732,7 @@ pub fn apply(config: &Config, requests: &[FileRequest], opts: &ApplyOpts) -> Res // render or check failure on one entry must not hide the rest if !req.source.exists() { missing_sources.push(format!( - " [system.files].\"{}\": {}", + " [dotfiles].\"{}\": {}", req.target_raw, req.source.display_user() )); @@ -638,7 +760,7 @@ pub fn apply(config: &Config, requests: &[FileRequest], opts: &ApplyOpts) -> Res Ok(FileState::Applied) => continue, Ok(_) => {} Err(err) => { - broken.push(format!(" [system.files].\"{}\": {err}", req.target_raw)); + broken.push(format!(" [dotfiles].\"{}\": {err}", req.target_raw)); continue; } } @@ -679,6 +801,9 @@ pub fn apply(config: &Config, requests: &[FileRequest], opts: &ApplyOpts) -> Res let conditional = req.mode == FileMode::Template && rendered.is_none(); let suffix = if conditional { " (if changed)" } else { "" }; miseprintln!("{}{suffix}", describe(req)?); + if opts.verbose && !conditional { + print_diff(req, rendered.as_deref())?; + } } return Ok(()); } @@ -769,6 +894,63 @@ fn describe(req: &FileRequest) -> Result { }) } +fn print_diff(req: &FileRequest, rendered: Option<&str>) -> Result<()> { + match req.mode { + FileMode::Symlink => { + if req.target.is_symlink() { + let dest = std::fs::read_link(&req.target)?; + miseprintln!( + " current symlink: {} -> {}", + req.target.display_user(), + dest.display_user() + ); + } else if req.target.exists() { + miseprintln!(" current: {} exists", req.target.display_user()); + } else { + miseprintln!(" current: {} missing", req.target.display_user()); + } + miseprintln!( + " desired symlink: {} -> {}", + req.target.display_user(), + req.source.display_user() + ); + } + FileMode::SymlinkEach => { + miseprintln!( + " desired symlink-each: {} files from {}", + walk_source_files(req)?.len(), + req.source.display_user() + ); + } + FileMode::Copy | FileMode::Template if req.source.is_file() => { + let desired = match req.mode { + FileMode::Template => rendered.unwrap_or_default().as_bytes().to_vec(), + _ => file::read(&req.source)?, + }; + let current = if req.target.exists() && req.target.is_file() { + file::read(&req.target)? + } else { + vec![] + }; + if current != desired { + miseprintln!( + " content differs: {} -> {}", + req.source.display_user(), + req.target.display_user() + ); + } + } + FileMode::Copy | FileMode::Template => { + miseprintln!( + " desired directory contents: {} -> {}", + req.source.display_user(), + req.target.display_user() + ); + } + } + Ok(()) +} + fn apply_one(req: &FileRequest, rendered: Option<&str>) -> Result<()> { debug!("files: {}", describe(req)?); if let Some(parent) = req.target.parent() { diff --git a/src/system/login_shell.rs b/src/system/login_shell.rs index 7396ae6438..348a9b0b51 100644 --- a/src/system/login_shell.rs +++ b/src/system/login_shell.rs @@ -1,5 +1,5 @@ -//! `[system].login_shell` - declarative current-user login shell, applied by -//! `mise system install` or `mise bootstrap`. +//! `[bootstrap.user].login_shell` - declarative current-user login shell, +//! applied by `mise bootstrap user apply` or `mise bootstrap`. use std::fs::OpenOptions; use std::io::Write; diff --git a/src/system/mod.rs b/src/system/mod.rs index 8e3704a82c..3e42f58d72 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -1,13 +1,11 @@ -//! `[system]` config section: machine-global bootstrapping. +//! `[bootstrap]` config section: machine-global bootstrapping. //! -//! This is `[system.packages]` — declarative system packages installed by -//! `mise system install` — `[system.files]` — declarative config files -//! (dotfiles) — `[system.defaults]` — declarative macOS user defaults, and -//! `[system].login_shell`, all applied by the same command. These are -//! intentionally not part of `[tools]`: they're unversioned, machine-global, -//! and managed by the OS package manager (or mise's own Homebrew-bottle -//! installer), plain filesystem operations, macOS `defaults`, or system user -//! account tooling, not mise's per-project toolset. +//! This is `[bootstrap.packages]` — declarative system packages installed +//! by `mise bootstrap packages install` — `[dotfiles]` — declarative config +//! files applied by `mise dotfiles apply` — `[bootstrap.macos.defaults]` +//! — declarative macOS user defaults — and `[bootstrap.user].login_shell`. +//! These are intentionally not part of `[tools]`: they're unversioned, +//! machine-global settings and resources, not mise's per-project toolset. use std::path::Path; use std::sync::Arc; @@ -28,44 +26,56 @@ pub mod login_shell; pub mod packages; pub(crate) mod sudo; -/// `[system]` as parsed from a single mise.toml +/// `[bootstrap]` as parsed from a single mise.toml #[derive(Debug, Default, Clone, Deserialize)] -pub struct SystemTomlConfig { +pub struct BootstrapTomlConfig { /// `"manager:package"` -> version (`"latest"` or a manager-native pin). /// String-keyed so configs using managers from newer mise versions (dnf, /// pacman, winget, ...) parse fine on older ones. #[serde(default)] pub packages: IndexMap, - /// `[system.defaults.]` -> key -> value. Values stay raw TOML so - /// shapes from newer mise versions (arrays, dicts) parse fine on older - /// ones; the domain level is also raw so a malformed section warns - /// instead of failing the whole config. + /// macOS-specific bootstrap config. #[serde(default)] - pub defaults: IndexMap, - /// target path -> source (see [`files`]) + pub macos: BootstrapMacosTomlConfig, + /// User-specific bootstrap config. #[serde(default)] - pub files: IndexMap, - /// edits to files mise doesn't own, keyed by target path then edit id - /// (see [`edits`]) + pub user: BootstrapUserTomlConfig, + /// Homebrew-specific bootstrap package config. #[serde(default)] - pub edits: IndexMap>, + pub brew: SystemBrewTomlConfig, +} + +#[derive(Debug, Default, Clone, Deserialize)] +pub struct BootstrapUserTomlConfig { /// desired login shell for the current user, applied with `chsh -s` #[serde(default)] pub login_shell: Option, - /// Homebrew-specific system config. +} + +#[derive(Debug, Default, Clone, Deserialize)] +pub struct BootstrapMacosTomlConfig { + /// `[bootstrap.macos.defaults.]` -> key -> value. Values stay raw TOML so + /// shapes from newer mise versions (arrays, dicts) parse fine on older + /// ones; the domain level is also raw so a malformed section warns + /// instead of failing the whole config. #[serde(default)] - pub brew: SystemBrewTomlConfig, + pub defaults: IndexMap, } #[derive(Debug, Default, Clone, Deserialize)] pub struct SystemBrewTomlConfig { - /// `[system.brew.taps]`: `owner/tap` -> git URL. Like `[plugins]`, + /// `[bootstrap.brew.taps]`: `owner/tap` -> git URL. Like `[plugins]`, /// this lets shared config name non-GitHub or otherwise custom tap /// remotes while package entries stay focused on formulae. #[serde(default)] pub taps: IndexMap, } +/// `[dotfiles]` as parsed from a single mise.toml. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(transparent)] +pub struct DotfilesTomlConfig(pub IndexMap); + /// Packages for one manager, aggregated across the config hierarchy pub struct ManagerPackages { pub manager: Arc, @@ -90,7 +100,7 @@ pub fn parse_spec(spec: &str) -> eyre::Result<(String, String)> { } } -/// Split a `mise system use` spec `manager:package[@version]` into its parts. +/// Split a `mise bootstrap packages use` spec `manager:package[@version]` into its parts. /// /// `@version` mirrors `mise use tool@version`; `@latest` (or no `@`) means no /// pin. brew is exempt from `@` parsing: `@` is part of brew formula *names* @@ -132,7 +142,7 @@ pub fn parse_use_spec(spec: &str) -> eyre::Result<(String, PackageRequest)> { } /// Build [`ManagerPackages`] from already-parsed requests (used by -/// `mise system use`, where version pins come from the CLI spec). Unknown or +/// `mise bootstrap packages use`, where version pins come from the CLI spec). Unknown or /// settings-excluded managers are hard errors. pub fn packages_from_requests( by_mgr: IndexMap>, @@ -150,7 +160,7 @@ pub fn attach_brew_tap_urls(config: &Config, by_mgr: &mut IndexMap local; a more local config overrides the version pin /// of a key the global config declared. Malformed keys and unknown managers @@ -162,7 +172,7 @@ pub fn packages_from_config(config: &Config) -> Vec { packages_from_config_files_with_brew_taps(&config.config_files, &brew_taps) } -/// Aggregate `[system.packages]` across a specific set of config files. +/// Aggregate `[bootstrap.packages]` across a specific set of config files. pub fn packages_from_config_files(config_files: &ConfigMap) -> Vec { packages_from_config_files_with_brew_taps(config_files, &IndexMap::new()) } @@ -174,7 +184,7 @@ fn packages_from_config_files_with_brew_taps( let mut merged: IndexMap = IndexMap::new(); // config_files is ordered local -> global; reverse for global -> local for cf in config_files.values().rev() { - if let Some(sys) = cf.system_config() { + if let Some(sys) = cf.bootstrap_config() { for (spec, version) in sys.packages { merged.insert(spec, version); } @@ -196,13 +206,13 @@ fn packages_from_config_files_with_brew_taps( tap_url, }); } - Err(err) => warn!("[system.packages]: {err}"), + Err(err) => warn!("[bootstrap.packages]: {err}"), } } resolve_managers(by_mgr, false).expect("non-strict resolve is infallible") } -/// Aggregate `[system.defaults]` across all loaded config files. +/// Aggregate `[bootstrap.macos.defaults]` across all loaded config files. /// /// (domain, key) pairs union global -> local; a more local config overrides /// the value a global config declared. Unsupported value shapes warn @@ -211,8 +221,8 @@ pub fn defaults_from_config(config: &Config) -> Vec { let mut merged: IndexMap<(String, String), toml::Value> = IndexMap::new(); // config_files is ordered local -> global; reverse for global -> local for cf in config.config_files.values().rev() { - if let Some(sys) = cf.system_config() { - for (domain, entries) in sys.defaults { + if let Some(sys) = cf.bootstrap_config() { + for (domain, entries) in sys.macos.defaults { match entries { toml::Value::Table(entries) => { for (key, value) in entries { @@ -220,7 +230,7 @@ pub fn defaults_from_config(config: &Config) -> Vec { } } _ => warn!( - "[system.defaults]: expected a table of key/value pairs for domain '{domain}'" + "[bootstrap.macos.defaults]: expected a table of key/value pairs for domain '{domain}'" ), } } @@ -231,7 +241,7 @@ pub fn defaults_from_config(config: &Config) -> Vec { match DefaultsValue::from_toml(&value) { Some(value) => out.push(DefaultsRequest { domain, key, value }), None => warn!( - "[system.defaults]: unsupported value type for {domain} {key} \ + "[bootstrap.macos.defaults]: unsupported value type for {domain} {key} \ (expected bool, integer, float, or string)" ), } @@ -244,16 +254,18 @@ pub fn login_shell_from_config(config: &Config) -> Option global; reverse for global -> local for cf in config.config_files.values().rev() { - if let Some(sys) = cf.system_config() - && let Some(login_shell) = sys.login_shell + if let Some(sys) = cf.bootstrap_config() + && let Some(login_shell) = sys.user.login_shell { let login_shell = login_shell.trim().to_string(); if login_shell.is_empty() { - warn!("[system].login_shell: must not be empty, ignoring entry"); + warn!("[bootstrap.user].login_shell: must not be empty, ignoring entry"); continue; } if !Path::new(&login_shell).is_absolute() { - warn!("[system].login_shell: shell must be an absolute path, ignoring entry"); + warn!( + "[bootstrap.user].login_shell: shell must be an absolute path, ignoring entry" + ); continue; } shell = Some(login_shell); @@ -311,7 +323,7 @@ pub(crate) fn brew_tap_name(name: &str) -> Option<&str> { fn brew_taps_from_config(config: &Config) -> IndexMap { let mut brew_taps: IndexMap = IndexMap::new(); for cf in config.config_files.values().rev() { - if let Some(sys) = cf.system_config() { + if let Some(sys) = cf.bootstrap_config() { for (tap, url) in sys.brew.taps { brew_taps.insert(tap, url); } @@ -346,14 +358,16 @@ fn resolve_managers( }), None => { if strict { - bail!("unknown system package manager '{name}'"); + bail!("unknown bootstrap package manager '{name}'"); } // brew is compiled out on Windows — not unknown, just // unsupported there if cfg!(windows) && name == "brew" { debug!("system package manager 'brew' is not supported on windows"); } else { - warn!("unknown system package manager '{name}' in [system.packages], ignoring"); + warn!( + "unknown bootstrap package manager '{name}' in [bootstrap.packages], ignoring" + ); } } } diff --git a/src/system/packages/brew/mod.rs b/src/system/packages/brew/mod.rs index 7c2ef66d41..e6415241cf 100644 --- a/src/system/packages/brew/mod.rs +++ b/src/system/packages/brew/mod.rs @@ -73,8 +73,8 @@ impl BrewManager { && let Some(alias) = roots.iter().find(|r| rf.formula.aliases.contains(r)) { warn!( - "'{alias}' is an alias of '{}' — use the canonical name in [system.packages] \ - so `mise system status` can track it", + "'{alias}' is an alias of '{}' — use the canonical name in [bootstrap.packages] \ + so `mise bootstrap packages status` can track it", rf.formula.name ); } @@ -129,7 +129,7 @@ impl BrewManager { if prefix::sudo_invoking_user().is_some() { warn!( "running under sudo — poured files will be owned by root; run \ - `mise system install` without sudo instead (mise elevates itself \ + `mise bootstrap packages install` without sudo instead (mise elevates itself \ for the one-time prefix setup)" ); } diff --git a/src/system/packages/brew/pour.rs b/src/system/packages/brew/pour.rs index d37d7972eb..b5ccc606bd 100644 --- a/src/system/packages/brew/pour.rs +++ b/src/system/packages/brew/pour.rs @@ -143,7 +143,7 @@ pub async fn pour( // future installs would skip it — make that state visible warn!( "failed to remove {} after link failure: {rm_err}\n\ - remove it manually, then re-run `mise system install`", + remove it manually, then re-run `mise bootstrap packages install`", keg.display() ); } @@ -344,7 +344,7 @@ pub fn link_keg(name: &str, pkg_version: &str, keg_only: bool) -> Result<()> { // this error — so don't claim it remains usable bail!( "cannot link {name}: these files already exist and were not created by mise or brew:\n{}\n\ - Remove or rename them, then re-run `mise system install`", + Remove or rename them, then re-run `mise bootstrap packages install`", conflicts .iter() .map(|p| format!(" {}", p.display())) diff --git a/src/system/packages/brew/source.rs b/src/system/packages/brew/source.rs index e45a194745..91af3a6026 100644 --- a/src/system/packages/brew/source.rs +++ b/src/system/packages/brew/source.rs @@ -168,7 +168,7 @@ pub async fn build( if let Err(rm_err) = crate::file::remove_all(&keg) { warn!( "failed to remove {} after link failure: {rm_err}\n\ - remove it manually, then re-run `mise system install`", + remove it manually, then re-run `mise bootstrap packages install`", keg.display(), ); } diff --git a/src/system/packages/mod.rs b/src/system/packages/mod.rs index 31182be130..3049aa1e57 100644 --- a/src/system/packages/mod.rs +++ b/src/system/packages/mod.rs @@ -1,4 +1,4 @@ -//! System package managers (apt, brew) for the `[system.packages]` config section. +//! System package managers (apt, brew) for the `[bootstrap.packages]` config section. //! //! These are machine-global, unversioned packages — deliberately separate from //! the `Backend` system, which manages per-project, version-pinned dev tools. @@ -15,7 +15,7 @@ pub mod brew; pub mod dnf; pub mod pacman; -/// A single package entry from `[system.packages]` — the part after the +/// A single package entry from `[bootstrap.packages]` — the part after the /// `manager:` prefix of a `"manager:package" = "version"` config entry. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PackageRequest { @@ -27,7 +27,7 @@ pub struct PackageRequest { /// (apt: `name=version`, dnf: `name-version`). pub version: Option, /// manager-specific source URL. Currently used by brew tapped formulae: - /// `[system.brew.taps]` can attach a git URL to `owner/tap/formula`. + /// `[bootstrap.brew.taps]` can attach a git URL to `owner/tap/formula`. pub tap_url: Option, } diff --git a/xtasks/fig/src/mise.ts b/xtasks/fig/src/mise.ts index 2294539cb2..ebe0b996ee 100644 --- a/xtasks/fig/src/mise.ts +++ b/xtasks/fig/src/mise.ts @@ -698,6 +698,281 @@ const completionSpec: Fig.Spec = { name: "bootstrap", description: "[experimental] Set up a machine for the current config in one command", + subcommands: [ + { + name: "macos-defaults", + description: + "Manage macOS defaults from `[bootstrap.macos.defaults]`", + subcommands: [ + { + name: "apply", + options: [ + { + name: ["-n", "--dry-run"], + description: + "Print the commands that would run without running them", + isRepeatable: false, + }, + { + name: ["-y", "--yes"], + description: "Skip the confirmation prompt", + isRepeatable: false, + }, + ], + }, + { + name: "status", + options: [ + { + name: ["-J", "--json"], + description: "Output in JSON format", + isRepeatable: false, + }, + { + name: "--missing", + description: + "Exit with code 1 if any configured defaults are not in their desired state", + isRepeatable: false, + }, + ], + }, + ], + }, + { + name: "packages", + description: + "Manage bootstrap system packages from `[bootstrap.packages]`", + subcommands: [ + { + name: "brew", + description: "Manage Homebrew taps used by bootstrap packages", + subcommands: [ + { + name: "tap", + description: "Tap a Homebrew formula repository", + options: [ + { + name: ["-n", "--dry-run"], + description: + "Print the command that would run without running it", + isRepeatable: false, + }, + ], + args: [ + { + name: "tap", + description: "Tap name, e.g. `owner/repo`", + }, + { + name: "url", + description: + "Git URL for non-GitHub or otherwise custom taps", + isOptional: true, + }, + ], + }, + { + name: ["untap", "remove", "rm"], + description: "Untap Homebrew formula repositories", + options: [ + { + name: ["-n", "--dry-run"], + description: + "Print the command that would run without running it", + isRepeatable: false, + }, + ], + args: { + name: "taps", + description: "Tap name(s), e.g. `owner/repo`", + isVariadic: true, + }, + }, + ], + }, + { + name: ["install", "i"], + description: + "Install missing system packages from `[bootstrap.packages]`", + options: [ + { + name: ["-m", "--manager"], + description: + "Only install packages for this manager, e.g. `apt` or `brew`", + isRepeatable: false, + args: { + name: "manager", + suggestions: ["apt", "brew", "dnf", "pacman"], + }, + }, + { + name: ["-n", "--dry-run"], + description: + "Print the commands that would run without running them", + isRepeatable: false, + }, + { + name: ["-y", "--yes"], + description: "Skip the confirmation prompt", + isRepeatable: false, + }, + { + name: "--update", + description: + "Refresh package manager metadata first (apt: `apt-get update`)", + isRepeatable: false, + }, + ], + args: { + name: "package", + description: + "Packages in `manager:package` form; defaults to everything configured in [bootstrap.packages]", + isOptional: true, + isVariadic: true, + }, + }, + { + name: ["status", "ls"], + description: + "Show the status of system packages from `[bootstrap.packages]`", + options: [ + { + name: ["-J", "--json"], + description: "Output in JSON format", + isRepeatable: false, + }, + { + name: "--missing", + description: + "Exit with code 1 if any configured packages are not in their desired state", + isRepeatable: false, + }, + ], + }, + { + name: ["upgrade", "up"], + description: + "Upgrade installed bootstrap packages from `[bootstrap.packages]`", + options: [ + { + name: ["-m", "--manager"], + description: + "Only upgrade packages for this manager, e.g. `apt` or `brew`", + isRepeatable: false, + args: { + name: "manager", + suggestions: ["apt", "brew", "dnf", "pacman"], + }, + }, + { + name: ["-n", "--dry-run"], + description: + "Print the commands that would run without running them", + isRepeatable: false, + }, + { + name: ["-y", "--yes"], + description: "Skip the confirmation prompt", + isRepeatable: false, + }, + ], + args: { + name: "package", + description: + "Packages in `manager:package` form; defaults to everything configured in [bootstrap.packages]", + isOptional: true, + isVariadic: true, + }, + }, + { + name: ["use", "u"], + description: + "Add bootstrap packages to [bootstrap.packages] and install them", + options: [ + { + name: ["-e", "--env"], + description: + "Write to the config file for this environment (mise..toml)", + isRepeatable: false, + args: { + name: "env", + }, + }, + { + name: ["-g", "--global"], + description: + "Write to the global config (~/.config/mise/config.toml) instead of the local one", + isRepeatable: false, + }, + { + name: ["-n", "--dry-run"], + description: + "Print the commands that would run without writing config or installing", + isRepeatable: false, + }, + { + name: ["-p", "--path"], + description: "Write to this config file or directory", + isRepeatable: false, + args: { + name: "path", + template: "filepaths", + }, + }, + { + name: ["-y", "--yes"], + description: "Skip the confirmation prompt", + isRepeatable: false, + }, + ], + args: { + name: "package", + description: "Packages in `manager:package[@version]` form", + isVariadic: true, + }, + }, + ], + }, + { + name: "user", + description: + "Manage current-user bootstrap settings from `[bootstrap.user]`", + subcommands: [ + { + name: "apply", + options: [ + { + name: ["-n", "--dry-run"], + description: + "Print the commands that would run without running them", + isRepeatable: false, + }, + { + name: ["-y", "--yes"], + description: "Skip the confirmation prompt", + isRepeatable: false, + }, + ], + }, + { + name: "status", + options: [ + { + name: ["-J", "--json"], + description: "Output in JSON format", + isRepeatable: false, + }, + { + name: "--missing", + description: + "Exit with code 1 if any configured user setting is not in its desired state", + isRepeatable: false, + }, + ], + }, + ], + }, + ], options: [ { name: ["-n", "--dry-run"], @@ -892,6 +1167,168 @@ const completionSpec: Fig.Spec = { name: "deactivate", description: "Disable mise for current shell session", }, + { + name: "dotfiles", + description: "[experimental] Manage dotfiles from `[dotfiles]`", + subcommands: [ + { + name: "add", + description: "Add or update dotfiles in `[dotfiles]`", + options: [ + { + name: ["-f", "--force"], + description: "Overwrite existing sources without prompting", + isRepeatable: false, + }, + { + name: ["-g", "--global"], + description: "Write to the global config", + isRepeatable: false, + }, + { + name: ["-l", "--local"], + description: + "Write to the local config instead of the global config", + isRepeatable: false, + }, + { + name: ["-m", "--mode"], + description: "Dotfile mode to write", + isRepeatable: false, + args: { + name: "mode", + }, + }, + { + name: ["-n", "--dry-run"], + description: + "Print the config/source updates without writing anything", + isRepeatable: false, + }, + { + name: ["-p", "--path"], + description: "Write to this config file or directory", + isRepeatable: false, + args: { + name: "path", + template: "filepaths", + }, + }, + { + name: ["-s", "--source"], + description: "Source path to use for a single target", + isRepeatable: false, + args: { + name: "path", + template: "filepaths", + }, + }, + { + name: ["-y", "--yes"], + description: "Skip the confirmation prompt", + isRepeatable: false, + }, + ], + args: { + name: "target", + description: "Targets to add or update", + isVariadic: true, + }, + }, + { + name: "apply", + description: "Apply dotfiles from `[dotfiles]`", + options: [ + { + name: ["-f", "--force"], + description: + "Overwrite existing files that conflict with whole-file dotfile entries", + isRepeatable: false, + }, + { + name: ["-n", "--dry-run"], + description: + "Print the actions that would run without writing anything", + isRepeatable: false, + }, + { + name: ["-y", "--yes"], + description: "Skip the confirmation prompt", + isRepeatable: false, + }, + ], + args: { + name: "target", + description: "Only apply these targets", + isOptional: true, + isVariadic: true, + }, + }, + { + name: "edit", + description: "Edit a managed dotfile source", + options: [ + { + name: "--apply", + description: "Apply this target after the editor exits", + isRepeatable: false, + }, + { + name: ["-m", "--mode"], + description: + "Dotfile mode to use if the target is not yet managed", + isRepeatable: false, + args: { + name: "mode", + }, + }, + { + name: ["-s", "--source"], + description: + "Source path to use if the target is not yet managed", + isRepeatable: false, + args: { + name: "path", + template: "filepaths", + }, + }, + { + name: ["-y", "--yes"], + description: + "Skip the confirmation prompt when adding an unmanaged target", + isRepeatable: false, + }, + ], + args: { + name: "target", + description: "Target to edit", + }, + }, + { + name: ["status", "ls"], + description: "Show the status of dotfiles from `[dotfiles]`", + options: [ + { + name: ["-J", "--json"], + description: "Output in JSON format", + isRepeatable: false, + }, + { + name: "--missing", + description: + "Exit with code 1 if any configured dotfiles are not in their desired\nstate (missing, source missing, differs)", + isRepeatable: false, + }, + ], + args: { + name: "target", + description: "Only show these targets", + isOptional: true, + isVariadic: true, + }, + }, + ], + }, { name: ["doctor", "dr"], description: "Check mise installation for possible problems", @@ -3230,207 +3667,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: "system", - description: - "[experimental] Manage system packages from `[system.packages]`, files\nfrom `[system.files]`, edits from `[system.edits]`, macOS defaults\nfrom `[system.defaults]`, and Unix login shell from `[system].login_shell`", - subcommands: [ - { - name: "brew", - description: "Manage Homebrew taps used by system packages", - subcommands: [ - { - name: "tap", - description: "Tap a Homebrew formula repository", - options: [ - { - name: ["-n", "--dry-run"], - description: - "Print the command that would run without running it", - isRepeatable: false, - }, - ], - args: [ - { - name: "tap", - description: "Tap name, e.g. `owner/repo`", - }, - { - name: "url", - description: - "Git URL for non-GitHub or otherwise custom taps", - isOptional: true, - }, - ], - }, - { - name: ["untap", "remove", "rm"], - description: "Untap Homebrew formula repositories", - options: [ - { - name: ["-n", "--dry-run"], - description: - "Print the command that would run without running it", - isRepeatable: false, - }, - ], - args: { - name: "taps", - description: "Tap name(s), e.g. `owner/repo`", - isVariadic: true, - }, - }, - ], - }, - { - name: ["install", "i"], - description: - "Install missing system packages from `[system.packages]`, apply files\nfrom `[system.files]` and edits from `[system.edits]`, write macOS\ndefaults from `[system.defaults]`, and set Unix login shell from\n`[system].login_shell`", - options: [ - { - name: ["-f", "--force"], - description: - "Overwrite existing files that conflict with `[system.files]` entries", - isRepeatable: false, - }, - { - name: ["-m", "--manager"], - description: - "Only install packages for this manager, e.g. `apt` or `brew`", - isRepeatable: false, - args: { - name: "manager", - suggestions: ["apt", "brew", "dnf", "pacman"], - }, - }, - { - name: ["-n", "--dry-run"], - description: - "Print the commands that would run without running them", - isRepeatable: false, - }, - { - name: ["-y", "--yes"], - description: "Skip the confirmation prompt", - isRepeatable: false, - }, - { - name: "--update", - description: - "Refresh package manager metadata first (apt: `apt-get update`)", - isRepeatable: false, - }, - ], - args: { - name: "package", - description: - "Packages in `manager:package` form; defaults to everything configured in [system.packages]", - isOptional: true, - isVariadic: true, - }, - }, - { - name: ["status", "ls"], - description: - "Show the status of system packages from `[system.packages]`, files from\n`[system.files]`, edits from `[system.edits]`, and macOS defaults from\n`[system.defaults]`, and Unix login shell from `[system].login_shell`", - options: [ - { - name: ["-J", "--json"], - description: "Output in JSON format", - isRepeatable: false, - }, - { - name: "--missing", - description: - "Exit with code 1 if any configured packages, files, edits, defaults, or\nlogin shell are not in their desired state", - isRepeatable: false, - }, - ], - }, - { - name: ["upgrade", "up"], - description: - "Upgrade installed system packages from `[system.packages]`", - options: [ - { - name: ["-m", "--manager"], - description: - "Only upgrade packages for this manager, e.g. `apt` or `brew`", - isRepeatable: false, - args: { - name: "manager", - suggestions: ["apt", "brew", "dnf", "pacman"], - }, - }, - { - name: ["-n", "--dry-run"], - description: - "Print the commands that would run without running them", - isRepeatable: false, - }, - { - name: ["-y", "--yes"], - description: "Skip the confirmation prompt", - isRepeatable: false, - }, - ], - args: { - name: "package", - description: - "Packages in `manager:package` form; defaults to everything configured in [system.packages]", - isOptional: true, - isVariadic: true, - }, - }, - { - name: ["use", "u"], - description: - "Add system packages to [system.packages] and install them", - options: [ - { - name: ["-e", "--env"], - description: - "Write to the config file for this environment (mise..toml)", - isRepeatable: false, - args: { - name: "env", - }, - }, - { - name: ["-g", "--global"], - description: - "Write to the global config (~/.config/mise/config.toml) instead of the local one", - isRepeatable: false, - }, - { - name: ["-n", "--dry-run"], - description: - "Print the commands that would run without writing config or installing", - isRepeatable: false, - }, - { - name: ["-p", "--path"], - description: "Write to this config file or directory", - isRepeatable: false, - args: { - name: "path", - template: "filepaths", - }, - }, - { - name: ["-y", "--yes"], - description: "Skip the confirmation prompt", - isRepeatable: false, - }, - ], - args: { - name: "package", - description: "Packages in `manager:package[@version]` form", - isVariadic: true, - }, - }, - ], - }, { name: ["tasks", "t"], description: "Manage tasks",