diff --git a/.github/renovate.json b/.github/renovate.json index db10310a586..e8bc7259fc5 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -15,6 +15,13 @@ "matchCurrentValue": "copr", "enabled": false }, + { + "description": "fedora major bumps only take effect when the rpm/copr images are rebuilt at release time, so a broken base image surfaces mid-release rather than in PR CI (see the fedora 40 rollback in 8184bc9). Bump manually against the release path; digest updates stay enabled.", + "matchPackageNames": ["fedora"], + "matchManagers": ["dockerfile"], + "matchUpdateTypes": ["major"], + "enabled": false + }, { "matchPackageNames": ["taiki-e/install-action"], "matchManagers": ["github-actions"], diff --git a/.github/workflows/release-alpine.yml b/.github/workflows/release-alpine.yml index 49908ef58ea..311c64b493e 100644 --- a/.github/workflows/release-alpine.yml +++ b/.github/workflows/release-alpine.yml @@ -23,7 +23,7 @@ env: jobs: bump-alpine: runs-on: ubuntu-latest - container: ghcr.io/jdx/mise:alpine@sha256:1a4fe933142e4cfb2fa7843bbae9b2f10290ccab1a58e260d947ab41da359f40 + container: ghcr.io/jdx/mise:alpine@sha256:a826df7e06cb73e66107f1a65c1da7c512f532430c6067e90b7633b8c39e893e timeout-minutes: 60 if: | (github.event_name == 'release' && diff --git a/Cargo.lock b/Cargo.lock index bb2d18d9aca..6d8749bf038 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,7 +309,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -320,7 +320,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -547,9 +547,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "aube" -version = "2.2.16" +version = "2.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41e136f0fee9c635aacecc7f06945e2764f0005e1697b5868bd7fca15028fa0c" +checksum = "053fc80c9591a43736866352fd38c5c205ed2701badef159cb350ef20fe85fde" dependencies = [ "aube-codes", "aube-linker", @@ -604,9 +604,9 @@ dependencies = [ [[package]] name = "aube-codes" -version = "2.2.16" +version = "2.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db50995fdda1f6c8fbbda867472c00ddcd062d1551f4b04696dc55dd37f0d465" +checksum = "adb69a2593bc9d720db997629847deda24a4bf2b6fa359725453f9250af5b856" dependencies = [ "serde", "serde_json", @@ -614,9 +614,9 @@ dependencies = [ [[package]] name = "aube-linker" -version = "2.2.16" +version = "2.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e922be4290ddaa397dbd933de68e038b0d9193a508695ae16e5f53374310b127" +checksum = "de866b0d4bf774c3ac4470435c0e752da90a25ff07e305d091c56514d61466b9" dependencies = [ "aube-codes", "aube-lockfile", @@ -643,9 +643,9 @@ dependencies = [ [[package]] name = "aube-lockfile" -version = "2.2.16" +version = "2.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be99eb9526e9715ecfdf985e6e75e7881417c893c76967c2906ba44e30f16d4b" +checksum = "b3cd73c5b9794252ed729fd178304ec0c0cd6047a186837f0b4fcbf85a809422" dependencies = [ "aube-codes", "aube-manifest", @@ -669,9 +669,9 @@ dependencies = [ [[package]] name = "aube-manifest" -version = "2.2.16" +version = "2.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1cfd62ba79c0f0a8df45b9e9a543b3678f85d5e71a99ddb1581252c888c3790" +checksum = "4bc1b99b84fa79ae9ae1d4769f3c062c2565b51ad9af20176164e5db2fd9dec4" dependencies = [ "aube-codes", "aube-util", @@ -685,9 +685,9 @@ dependencies = [ [[package]] name = "aube-registry" -version = "2.2.16" +version = "2.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1bdd18243b5d7d82e88737a982ff38e9979785ec8a87ad1308a7e78f9def0a0" +checksum = "84870346cb1220d3d26cf4c01ab1ba40700de6864542c433eaa28decd132866e" dependencies = [ "aube-codes", "aube-manifest", @@ -712,9 +712,9 @@ dependencies = [ [[package]] name = "aube-resolver" -version = "2.2.16" +version = "2.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cb7721b2e68a374a2ed642751a8b622581d29cb72362bdeae382336f230ef5" +checksum = "bf170402dfda3d88b60402ae8629e4b28a305bb930e162c573de36657701a395" dependencies = [ "aube-codes", "aube-lockfile", @@ -742,9 +742,9 @@ dependencies = [ [[package]] name = "aube-runtime" -version = "2.2.16" +version = "2.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d16c8061a41046c49eb0cfd2ab853ec25b15ab3943c2102c1f37a422a4d6c5" +checksum = "d14864f9b2a81c6e9c2757d41e2d5524e64a4288576c2f5984e3c6ce85b42bc1" dependencies = [ "aube-codes", "aube-manifest", @@ -767,9 +767,9 @@ dependencies = [ [[package]] name = "aube-scripts" -version = "2.2.16" +version = "2.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1190305172e772816a99f318e54ba072a13c15afb30759376da864adf802b0c6" +checksum = "b06c3b29c79a83557400540cd418f0f86322f63b3b8cc47e3ee46bcd163aea78" dependencies = [ "aube-codes", "aube-manifest", @@ -787,9 +787,9 @@ dependencies = [ [[package]] name = "aube-settings" -version = "2.2.16" +version = "2.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9126d2876fd17e001a0d808ea3ebe2686152877cf818414fba64fdbba8a24021" +checksum = "4a5d791f5fd3f824396064471f5a14536dcde1df96d4b9e09a179494483827ff" dependencies = [ "aube-codes", "aube-util", @@ -801,9 +801,9 @@ dependencies = [ [[package]] name = "aube-store" -version = "2.2.16" +version = "2.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03b80a025561d40dc133635640720d80d3ea1bce25325e17088e45f63a4acae" +checksum = "b7ec6c7e7449419cd7c27258d52b65f2c4b7c76b753715bab7c3d7332a48a2b4" dependencies = [ "aube-util", "base64 0.23.1", @@ -828,9 +828,9 @@ dependencies = [ [[package]] name = "aube-util" -version = "2.2.16" +version = "2.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6b5a2848eeb299f14777d0e3da942d702a1f9884060bc75d2e571e80af2212e" +checksum = "cdfa21be86a24485c701763cdd854a9310da4db9b35cdf58bb7b8ceaea4b207e" dependencies = [ "aube-codes", "blake3", @@ -850,9 +850,9 @@ dependencies = [ [[package]] name = "aube-workspace" -version = "2.2.16" +version = "2.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc426eb3d957489117dfb737b0096e8360f87926a1fdcb8c40f17d3517c937c0" +checksum = "a31a81023709e921173ec402f40cdd4566a599b0a93f8dc7d92fe19cab41f465" dependencies = [ "aube-codes", "aube-manifest", @@ -1515,7 +1515,7 @@ dependencies = [ "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.10.5", "log", "prettyplease", "proc-macro2", @@ -2188,7 +2188,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3027,7 +3027,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3316,7 +3316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6017,7 +6017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "160f2eade097f30263b548aae5deb12ad349c909baa710fa24b92c9090b2e006" dependencies = [ "scopeguard", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6059,7 +6059,7 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1886916523694cd6ea3d175f03a1e5010699a2a4cc13696d83d7bea1d80638" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6966,7 +6966,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7166,7 +7166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7465,7 +7465,7 @@ dependencies = [ "aes-gcm", "aes-kw", "argon2", - "base64 0.22.1", + "base64 0.21.7", "bitfields", "block-padding 0.3.3", "blowfish", @@ -8018,7 +8018,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9048,7 +9048,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9107,7 +9107,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9783,7 +9783,7 @@ version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ - "errno 0.3.14", + "errno 0.2.8", "libc", ] @@ -10097,7 +10097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10454,7 +10454,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10528,7 +10528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11708,7 +11708,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/docs/bootstrap/setup.md b/docs/bootstrap/setup.md index 16158df5fc0..b242dd28aff 100644 --- a/docs/bootstrap/setup.md +++ b/docs/bootstrap/setup.md @@ -177,8 +177,74 @@ files and applies the saved mise configuration, including the watcher service. If configuration or required template sources are missing from history, bootstrap reports which files you need to track and share from the first machine. -If an existing file differs, follow the reported conflict instructions before -setup can continue. Enable automatic sharing on this machine and check its state: +Any Git host works. `OWNER/REPO` is shorthand for GitHub; for anything else, +pass the full URL: + +```sh +mise bootstrap --adopt git@gitea.example.com:you/setup.git +``` + +The URL must not embed credentials. Authenticate with an SSH agent, or with a +Git credential helper for HTTPS. + +### When the machine already has these files + +A second machine usually already has a `~/.bashrc` or a +`~/.config/mise/config.toml`. mise never overwrites them. Files that match the +repository are accepted silently; each file that **differs** is held for a +decision, and adoption stops: + +``` +the setup from is paused; nothing was bootstrapped. +``` + +This is expected, not a failure of the adoption. List what is waiting, then +take the repository's version of everything: + +```sh +mise dot status +mise dot pull --take-remote-all +``` + +`--take-remote-all` replaces each conflicting file with the shared version; the +versions being replaced are saved first, so `mise dot undo` reverses the whole +pull. Once the last path is decided, the same `pull` writes the remaining +tracked files. Run `mise bootstrap` afterwards to finish the parts that are not +dotfiles, such as tools and services. + +To decide one path at a time instead, name it: + +```sh +mise dot pull --take-remote ~/.bashrc +``` + +To keep this machine's version of a file, save it first. `--keep-local` +resolves a conflict by publishing this machine's saved version, and a fresh +machine has not saved one yet: + +```sh +mise dot save ~/.bashrc +mise dot pull --keep-local ~/.bashrc +``` + +`--keep-local` also names the exceptions to a blanket choice. This takes the +repository's version of everything except `~/.bashrc`: + +```sh +mise dot pull --take-remote-all --keep-local ~/.bashrc +``` + +::: tip +Moving the conflicting files aside before `mise bootstrap --adopt` avoids the +decisions entirely: a path that does not exist is simply written. +::: + +`--replace-history` is a different thing and does not help here: it discards +unrelated local _history_ while adopting, and existing files that differ still +stop the operation. `--force-dotfiles` is unrelated too — it applies to +`[dotfiles]` link and copy targets, not to shared history. + +Enable automatic sharing on this machine and check its state: ```sh mise settings set history.sync sync diff --git a/docs/cli/bootstrap/dotfiles/pull.md b/docs/cli/bootstrap/dotfiles/pull.md index 407a603ab11..6a27c366280 100644 --- a/docs/cli/bootstrap/dotfiles/pull.md +++ b/docs/cli/bootstrap/dotfiles/pull.md @@ -38,6 +38,12 @@ their shared versions follow in the same run. - **`-y --yes`** — Pull without prompting - **`--take-remote `** — Resolve a conflict with the repository's version - **`--keep-local `** — Resolve a conflict by keeping this machine's version (published next) +- **`--take-remote-all`** — Resolve every remaining conflict with the repository's version + + Paths named by --keep-local keep this machine's version; every other conflict takes the repository's. Useful on a newly adopted machine, where each pre-existing file that differs is a separate conflict. +- **`--keep-local-all`** — Resolve every remaining conflict by keeping this machine's version + + Paths named by --take-remote take the repository's version; every other conflict keeps this machine's. Each kept path must already be saved. - **`-h --help`** — Print help Examples: @@ -47,6 +53,8 @@ mise dot pull --dry-run mise dot pull --yes mise dot pull --take-remote ~/.zshrc mise dot pull --keep-local ~/.zshrc +mise dot pull --take-remote-all +mise dot pull --take-remote-all --keep-local ~/.zshrc ``` diff --git a/docs/cli/dotfiles/pull.md b/docs/cli/dotfiles/pull.md index f9a4e15f6ed..54359eabc97 100644 --- a/docs/cli/dotfiles/pull.md +++ b/docs/cli/dotfiles/pull.md @@ -38,6 +38,12 @@ their shared versions follow in the same run. - **`-y --yes`** — Pull without prompting - **`--take-remote `** — Resolve a conflict with the repository's version - **`--keep-local `** — Resolve a conflict by keeping this machine's version (published next) +- **`--take-remote-all`** — Resolve every remaining conflict with the repository's version + + Paths named by --keep-local keep this machine's version; every other conflict takes the repository's. Useful on a newly adopted machine, where each pre-existing file that differs is a separate conflict. +- **`--keep-local-all`** — Resolve every remaining conflict by keeping this machine's version + + Paths named by --take-remote take the repository's version; every other conflict keeps this machine's. Each kept path must already be saved. - **`-h --help`** — Print help Examples: @@ -47,6 +53,8 @@ mise dot pull --dry-run mise dot pull --yes mise dot pull --take-remote ~/.zshrc mise dot pull --keep-local ~/.zshrc +mise dot pull --take-remote-all +mise dot pull --take-remote-all --keep-local ~/.zshrc ``` diff --git a/docs/contributing.md b/docs/contributing.md index 88dff382f46..ae31f3152c3 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -191,7 +191,7 @@ nothing to install. Run the checks explicitly before committing. ### Available Linters in hk The configured steps include Prettier, Markdown linting, Cargo formatting/checking, -ShellCheck, shfmt, Pkl, TOML/schema validation, and Lua checks. The Clippy block +ShellCheck, shfmt, TOML/schema validation, and Lua checks. The Clippy block in `hk.pkl` is disabled; CI runs Clippy separately. Read the current configuration rather than assuming a successful hk run includes every Rust lint. diff --git a/docs/dev-tools/backends/npm.md b/docs/dev-tools/backends/npm.md index 7c54e9ea2bc..92c413b3fe1 100644 --- a/docs/dev-tools/backends/npm.md +++ b/docs/dev-tools/backends/npm.md @@ -296,6 +296,45 @@ still required to approve the first unlocked install. These are reputation signals, not proof that a package is unsafe. Verify the package name and publisher before approving it. This option does not affect `npm`, `pnpm`, or `bun` installs. +### `allow_exotic_deps` + +Packages in the tool's dependency graph that may come from somewhere other than the npm registry — +a `git+` URL, a `file:` path, or a direct tarball URL. aube blocks these by default with +[`blockExoticSubdeps`](https://aube.sh/settings/#setting-blockexoticsubdeps); without the option +the install fails, for example: + +``` +registry error for xlsx: uses exotic specifier "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" +which is blocked by blockExoticSubdeps (declared by @gmickel/gno) +``` + +Name the package the error reports: + +```toml +[tools] +"npm:@gmickel/gno" = { version = "2.3.0", allow_exotic_deps = ["xlsx"] } +``` + +The list is written to the aube install's `.config/aube/config.toml` as `blockExoticSubdepsExclude`. +The gate itself stays on, so every other package in the graph is still checked — including one that +a later version of the tool introduces. + +These are reputation-independent signals: a tarball or git URL is fetched from a host the registry's +own protections never see. Check where the dependency actually comes from before listing it. Where +the upstream package can be fixed instead — by pinning the dependency to a registry release — that +is the better outcome. + +To exempt the entire graph rather than named packages, use `true`. This also covers a dependency +added by a future update, so prefer the list: + +```toml +[tools] +"npm:some-tool" = { version = "latest", allow_exotic_deps = true } +``` + +This option applies to `aube` and `aube_cli` installs only; `npm`, `pnpm`, and `bun` do not enforce +this gate. + ### `aube_args` Additional arguments to pass to `aube add --global` when diff --git a/docs/dotfiles.md b/docs/dotfiles.md index 4fbee1b426c..90440d8efdf 100644 --- a/docs/dotfiles.md +++ b/docs/dotfiles.md @@ -621,8 +621,9 @@ mise dot track ~/.config/app/credentials --encrypt ``` Configure `[history.encryption].recipients` first. See -[encrypted shared files](/history.html#encrypted-shared-files) for key setup -and the limits of encrypting a file that already has plaintext history. +[choose recipients](/history.html#choose-recipients) for generating keys and +[encrypted shared files](/history.html#encrypted-shared-files) for the limits +of encrypting a file that already has plaintext history. Run encrypted enrollment as a standalone command, not from inside `mise dot capture`, so mise can verify its initial encrypted baseline before keeping the declaration. diff --git a/docs/history.md b/docs/history.md index 8cabffc0f05..b88e8a6f0d6 100644 --- a/docs/history.md +++ b/docs/history.md @@ -232,6 +232,15 @@ mise dot origin set https://github.com/you/setup.git --sync sync mise dot status ``` +Any Git URL works, including a self-hosted host: + +```sh +mise dot origin set git@gitea.example.com:you/setup.git --sync sync +``` + +The URL must not contain credentials, a query string, or a fragment. +Authenticate with an SSH agent, or with a Git credential helper for HTTPS. + Review the connection preview before confirming. With `--sync sync`, the watcher pushes saved changes and periodically fetches and applies changes from other machines. To bring another machine into this workflow, follow @@ -307,6 +316,18 @@ mise dot pull --take-remote ~/.zshrc mise dot pull --keep-local ~/.zshrc ``` +To decide every conflict the same way in one command, use the blanket form. +`--keep-local` and `--take-remote` still name the exceptions: + +```sh +mise dot pull --take-remote-all +mise dot pull --take-remote-all --keep-local ~/.zshrc +mise dot pull --keep-local-all +``` + +`--keep-local-all` requires every file it keeps to be saved already, so run +`mise dot save` first if you have unsaved edits. + To combine both sides, use the conflict diff to edit the live file, explicitly save the merged path (also capturing files tracked with `--no-autosave`), then choose the saved local version: @@ -499,9 +520,117 @@ recipients = ["", ""] Replace the placeholders with public recipients for your machines and an independent recovery key. Keep private decryption keys outside tracking. -Configure local identities with `settings.age.identity_files`, -`settings.age.key_file`, or the supported SSH identity settings. Public -recipients travel with the repository. +Public recipients travel with the repository. + +### Choose recipients + +A recipient is a **public** key. mise encrypts each saved version to every +recipient in the list, so every machine that must read the history needs its +own recipient entry. These forms are accepted: + +| Recipient | Example | Notes | +| -------------- | -------------------------- | ------------------------------------------- | +| age x25519 | `age1qyqszq...` | From `age-keygen`. Works unattended. | +| SSH public key | `ssh-ed25519 AAAAC3Nza...` | Your existing key, if it has no passphrase. | +| Tagged age | `age1tag1...` | Works unattended. | +| age plugin | `age1yubikey1...` | Interactive only; see the warning below. | + +The matching **private** key is the identity mise decrypts with. It finds +identities automatically at `~/.config/mise/age.txt` and at `~/.ssh/id_ed25519` +or `~/.ssh/id_rsa`. Point it elsewhere with `settings.age.key_file`, +`settings.age.identity_files`, or `settings.age.ssh_identity_files`. + +#### Use an existing SSH key + +If your SSH private key has no passphrase, its public key works as a recipient +and needs no new tooling. Add the contents of the `.pub` file: + +```sh +cat ~/.ssh/id_ed25519.pub +``` + +```toml +[history.encryption] +recipients = ["ssh-ed25519 AAAAC3Nza... you@desktop"] +``` + +mise then decrypts with `~/.ssh/id_ed25519` automatically. + +::: warning +mise does not prompt for SSH key passphrases. A passphrase-protected private +key cannot decrypt history at all; the failure names the file and the reason +when mise first needs it. Generate a dedicated age key instead. This is +separate from Git authentication, where a passphrase-protected key in an SSH +agent is the recommended choice — an agent does not help age decryption. +::: + +#### Generate a dedicated age key + +Install the age CLI and create an identity: + +```sh +mise use -g age +mkdir -p ~/.config/mise +mise exec -- age-keygen -o ~/.config/mise/age.txt +# Public key: age1qyqszq... +``` + +`age.txt` holds the private identity; mise reads it from that default path. The +printed `age1...` line is the recipient. Keep the file out of tracking, and +restrict it with `chmod 600 ~/.config/mise/age.txt`. + +Repeat this on each machine and add every public key to `recipients`. A machine +whose recipient is missing can still transfer the encrypted history, but it +cannot read those files: a pull that has to inspect or apply one fails with +`cannot unlock ` rather than skipping it. + +#### Add a recovery recipient + +If you lose the only machine holding an identity, the encrypted history becomes +unreadable — re-encrypting requires decrypting first. Generate a second +identity that lives nowhere on your machines: + +```sh +(umask 077 && mise exec -- age-keygen -o ~/recovery-key.txt) +cat ~/recovery-key.txt +``` + +Add its public key to `recipients`, store the file's contents in a password +manager or another offline location, then remove the local copy: + +```sh +rm ~/recovery-key.txt +``` + +Treat it like a backup code: it decrypts everything encrypted after you add it. +Do not leave it in a tracked path, and do not write it somewhere the watcher +saves. + +A complete configuration for one machine plus recovery: + +```toml +[history.encryption] +recipients = [ + "age1qyqszq...", # desktop + "age1ljx8w2...", # laptop + "age1v9zm4f...", # recovery, stored in the password manager +] +``` + +Changing the list re-encrypts each file the next time it is saved. Commits +already in history keep the recipients they were written with, so a machine +added later reads versions saved after the change, not the ones before it. Add +every machine's recipient before saving private contents you expect all of them +to read. + +::: warning +Plugin recipients such as `age1yubikey1...` require an interactive terminal, +and encryption parses the whole list at once. The history watcher runs in the +background, so a list containing **any** plugin recipient stops automatic +saving with `plugin-dependent age recipients require interactive +synchronization` — adding a native recipient alongside it does not help. For +files the watcher saves, use only the age, tagged, and SSH forms above. +::: mise encrypts contents before storing them in Git. Filenames and public metadata remain visible. The files you edit or restore stay unencrypted. diff --git a/e2e/backend/test_npm_aube b/e2e/backend/test_npm_aube index c5ada2f411b..c3c40b04695 100644 --- a/e2e/backend/test_npm_aube +++ b/e2e/backend/test_npm_aube @@ -39,6 +39,18 @@ assert_succeed "test ! -e '$PWD/aube-lock.yaml'" assert_succeed "test -d '$MISE_CACHE_DIR/npm/aube/cache/packuments-full-v1'" assert_succeed "test -d '$MISE_CACHE_DIR/npm/aube/store/v1/files'" +# The in-process installer gets the real process env, so a tool's install_env +# never reaches it. Say so rather than dropping it silently. +mkdir install-env +cat >install-env/mise.toml <<'EOF' +[tools] +"npm:is-number" = { version = "7.0.0", install_env = { AUBE_BLOCK_EXOTIC_SUBDEPS = "false" } } +EOF +install_env_output="$(mise -C install-env install 2>&1)" || + fail "install-env install failed:\n$install_env_output" +assert_contains_text "$install_env_output" "install_env (AUBE_BLOCK_EXOTIC_SUBDEPS) is ignored for npm:is-number" +assert_contains_text "$install_env_output" "allow_exotic_deps" + # Aube warnings flow through mise's event renderer without suggesting an aube # subcommand that mise does not expose. deprecation_output="$(mise install npm:inflight@1.0.6 2>&1)" diff --git a/e2e/cli/test_dotfiles_bootstrap_from_git b/e2e/cli/test_dotfiles_bootstrap_from_git index cf101cff832..746b1a551a2 100644 --- a/e2e/cli/test_dotfiles_bootstrap_from_git +++ b/e2e/cli/test_dotfiles_bootstrap_from_git @@ -158,6 +158,8 @@ assert_fail "$d bootstrap --adopt file://$origin --yes --only dotfiles 2>&1" "ne assert_fail "$d bootstrap --adopt file://$origin --yes --only dotfiles 2>&1" "nothing was bootstrapped" assert "cat $D/.config/mise/config.toml" $'[env]\nD_ONLY = "1"' assert_contains "$d bootstrap dotfiles status" "sharing is paused for the entire setup" +# --keep-local publishes a saved version this fresh machine does not have yet +assert_fail "$d dot pull --keep-local $D/.config/mise/config.toml --yes" "no saved version on this machine yet" assert_succeed "$d bootstrap dotfiles pull --take-remote $D/.config/mise/config.toml --yes" assert "cat $D/.config/hypr/bindings.lua" "bind = SUPER, Q, exec, foot" diff --git a/e2e/cli/test_dotfiles_resolve_all b/e2e/cli/test_dotfiles_resolve_all new file mode 100644 index 00000000000..9b9555b3cdf --- /dev/null +++ b/e2e/cli/test_dotfiles_resolve_all @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Blanket conflict resolution: `--take-remote-all` decides every conflict at +# once, and an explicit `--keep-local` names the exceptions it does not cover. +require_cmd git +export MISE_HISTORY_NOTIFY=false +origin="$PWD/origin.git" +git init -q --bare -b main "$origin" + +echo base-one >~/.one +echo base-two >~/.two +assert_succeed 'mise dot track ~/.one ~/.two' +assert_succeed "mise dot origin set file://$origin --sync manual --yes" + +second="$(dirname "$HOME")/resolve-all-b" +mkdir -p "$second" +b() { + ( + cd "$second" || exit + env HOME="$second" XDG_CONFIG_HOME="$second/.config" MISE_CONFIG_DIR="$second/.config/mise" \ + MISE_STATE_DIR="$second/.local/state/mise" MISE_DATA_DIR="$second/.local/share/mise" \ + MISE_CACHE_DIR="$second/.cache/mise" MISE_TRUSTED_CONFIG_PATHS="$second" mise "$@" + ) +} +export second +export -f b +assert_succeed "b bootstrap --from-git file://$origin --yes" + +# ---- both files conflict at once: one command decides them +echo a-one >~/.one +echo a-two >~/.two +assert_succeed 'mise dot save' +assert_succeed 'mise dot sync' +echo b-one >"$second/.one" +echo b-two >"$second/.two" +assert_succeed 'b dot save' +assert_succeed 'b dot sync --fetch-only' +# without a decision the whole setup stays paused, and the failure names the +# blanket flag rather than only the per-path form +assert_fail 'b dot pull --yes' paused +assert_fail 'b dot pull --yes' '--take-remote-all' +assert "cat $second/.one" b-one +assert "cat $second/.two" b-two +assert_succeed 'b dot pull --take-remote-all --yes' +assert "cat $second/.one" a-one +assert "cat $second/.two" a-two + +# ---- an explicit --keep-local is the exception the blanket choice leaves alone +assert_succeed 'b dot sync' +assert_succeed 'mise dot sync --fetch-only' +assert_succeed 'mise dot pull --yes' +echo a-one-again >~/.one +echo a-two-again >~/.two +assert_succeed 'mise dot save' +assert_succeed 'mise dot sync' +echo b-one-again >"$second/.one" +echo b-two-again >"$second/.two" +assert_succeed 'b dot save' +assert_succeed 'b dot sync --fetch-only' +assert_succeed "b dot pull --take-remote-all --keep-local $second/.one --yes" +assert "cat $second/.one" b-one-again +assert "cat $second/.two" a-two-again + +# ---- the two blanket choices are mutually exclusive +assert_fail 'b dot pull --take-remote-all --keep-local-all --yes' + +# ---- --keep-local-all keeps every saved local version, and publishes them +assert_succeed 'b dot sync' +assert_succeed 'mise dot sync --fetch-only' +assert_succeed 'mise dot pull --yes' +assert 'cat ~/.one' b-one-again +echo a-one-3 >~/.one +echo a-two-3 >~/.two +assert_succeed 'mise dot save' +assert_succeed 'mise dot sync' +echo b-one-3 >"$second/.one" +echo b-two-3 >"$second/.two" +assert_succeed 'b dot save' +assert_succeed 'b dot sync --fetch-only' +assert_succeed 'b dot pull --keep-local-all --yes' +assert "cat $second/.one" b-one-3 +assert "cat $second/.two" b-two-3 +# the kept versions are the ones that reach the origin next +assert_succeed 'b dot sync' +assert_succeed 'mise dot sync --fetch-only' +assert_succeed 'mise dot pull --yes' +assert 'cat ~/.one' b-one-3 +assert 'cat ~/.two' b-two-3 + +# ---- --keep-local-all refuses a conflict whose local version is not saved +echo a-one-4 >~/.one +assert_succeed 'mise dot save' +assert_succeed 'mise dot sync' +assert_succeed 'b dot sync --fetch-only' +echo b-one-4 >"$second/.one" +assert_fail 'b dot pull --keep-local-all --yes' 'mise dot save' +assert_fail 'b dot pull --keep-local-all --yes' 'has unsaved changes' +# naming the path is asking for that path in particular, so the refusal is +# the pass's own failure rather than a held path +assert_fail "b dot pull --keep-local $second/.one --yes" 'it has unsaved changes' +assert "cat $second/.one" b-one-4 +# the same unsaved conflict is still resolvable the other way +assert_succeed 'b dot pull --take-remote-all --yes' +assert "cat $second/.one" a-one-4 + +# ---- a blanket choice with nothing to decide is not an error +assert_succeed 'b dot pull --take-remote-all --yes' +assert_succeed 'b dot pull --keep-local-all --yes' + +# ---- a path whose live side is a directory holds only itself: the blanket +# choice still decides every other conflict, and says what it could not decide +echo a-one-5 >~/.one +echo a-two-5 >~/.two +assert_succeed 'mise dot save' +assert_succeed 'mise dot sync' +echo b-two-5 >"$second/.two" +assert_succeed 'b dot save' +assert_succeed 'b dot sync --fetch-only' +rm -f "$second/.one" +mkdir -p "$second/.one" +# the failure names the path it could not decide and why, rather than the +# blanket flag that just ran, and nothing is half-applied +assert_fail 'b dot pull --take-remote-all --yes' 'not a regular file or symlink' +assert_fail 'b dot pull --take-remote-all --yes' 'Fix each of those paths' +assert "cat $second/.two" b-two-5 +assert_succeed "test -d $second/.one" +# a preview carries on rather than failing, so it says the reason itself +assert_contains "b dot pull --take-remote-all --dry-run 2>&1" 'not a regular file or symlink' +# with the directory gone, deciding only that path finishes the setup: the +# choice those blanket commands recorded for ~/.two is still on file +rm -rf "$second/.one" +echo b-one-5 >"$second/.one" +assert_succeed "b dot pull --take-remote $second/.one --yes" +assert "cat $second/.one" a-one-5 +assert "cat $second/.two" a-two-5 + +# ---- a path with unsaved changes holds only itself: --keep-local-all still +# decides every other conflict rather than discarding the whole pass +echo a-one-6 >~/.one +echo a-two-6 >~/.two +assert_succeed 'mise dot save' +assert_succeed 'mise dot sync' +echo b-one-6 >"$second/.one" +echo b-two-6 >"$second/.two" +assert_succeed 'b dot save' +assert_succeed 'b dot sync --fetch-only' +# only ~/.one drifts from its saved version, so only it cannot be kept +echo b-one-6-edited >"$second/.one" +assert_fail 'b dot pull --keep-local-all --yes' 'has unsaved changes' +assert_fail 'b dot pull --keep-local-all --yes' 'mise dot save' +assert_fail 'b dot pull --keep-local-all --yes' 'Fix each of those paths' +assert "cat $second/.one" b-one-6-edited +assert "cat $second/.two" b-two-6 +# a preview carries on rather than failing, so it says the reason itself +assert_contains "b dot pull --keep-local-all --dry-run 2>&1" 'has unsaved changes' +# deciding only the held path finishes the setup: the choice --keep-local-all +# recorded for ~/.two is still on file, so ~/.two keeps this machine's version +assert_succeed "b dot pull --take-remote $second/.one --yes" +assert "cat $second/.one" a-one-6 +assert "cat $second/.two" b-two-6 + +# ---- the other reason a local version cannot be kept -- this machine has no +# saved version of the path at all -- holds exactly the same way +echo b-three-own >"$second/.three" +echo a-two-7 >~/.two +# ~/.three arrives as a newly tracked path that b already has its own copy of +# and has never saved, so b cannot publish a saved version of it +echo a-three >~/.three +assert_succeed 'mise dot track ~/.three' +assert_succeed 'mise dot save' +assert_succeed 'mise dot sync' +echo b-two-7 >"$second/.two" +assert_succeed 'b dot save' +assert_succeed 'b dot sync --fetch-only' +assert_fail 'b dot pull --keep-local-all --yes' 'no saved version on this machine yet' +assert_fail 'b dot pull --keep-local-all --yes' 'Fix each of those paths' +assert "cat $second/.three" b-three-own +assert "cat $second/.two" b-two-7 +# again only the held path needs deciding, and ~/.two keeps the local version +# --keep-local-all chose for it +assert_succeed "b dot pull --take-remote $second/.three --yes" +assert "cat $second/.three" a-three +assert "cat $second/.two" b-two-7 diff --git a/hk.pkl b/hk.pkl index 8bbd14117fd..433c8cf9580 100644 --- a/hk.pkl +++ b/hk.pkl @@ -1,5 +1,5 @@ -amends "package://github.com/jdx/hk/releases/download/v1.55.0/hk@1.55.0#/Config.pkl" -import "package://github.com/jdx/hk/releases/download/v1.55.0/hk@1.55.0#/Builtins.pkl" +amends "package://github.com/jdx/hk/releases/download/v2.0.0/hk@2.0.0#/Config.pkl" +import "package://github.com/jdx/hk/releases/download/v2.0.0/hk@2.0.0#/Builtins.pkl" local bash_glob = List("*.sh", "xtasks/**", "scripts/**", "e2e/**") local bash_exclude = List("*.ps1", "**/*.fish", "*.ts", "*.js", "*.json", "*.bat", "**/.*", "src/assets/bash_zsh_support/**", "e2e/shell/xonsh_script", "tmp/install.sh", "**/*.py", "**/*.xsh") @@ -84,15 +84,6 @@ fi exclude = bash_exclude } - // uses custom pkl linter config - ["pkl"] { - glob = "**/*.pkl" - check = new CommandSpec { - command = "pkl eval {{files}} >/dev/null" - effect = "write" - } - } - // uses taplo for TOML formatting and validation ["taplo"] { glob = List("**/*.toml") diff --git a/llms.txt b/llms.txt index af4fb82b9be..7712f267b0c 100644 --- a/llms.txt +++ b/llms.txt @@ -580,7 +580,6 @@ hk check --step shellcheck - **clippy**: Rust linting with `cargo clippy` - **shellcheck**: Shell script linting - **shfmt**: Shell script formatting -- **pkl**: Pkl configuration file validation **Using hk in Development:** ```bash diff --git a/man/man1/mise.1 b/man/man1/mise.1 index 78d028fdd56..f69f9074f62 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -2015,6 +2015,16 @@ Resolve a conflict with the repository's version \fB\-\-keep\-local\fR \fI\fR Resolve a conflict by keeping this machine's version (published next) .TP +\fB\-\-take\-remote\-all\fR +Resolve every remaining conflict with the repository's version + +Paths named by \-\-keep\-local keep this machine's version; every other conflict takes the repository's. Useful on a newly adopted machine, where each pre\-existing file that differs is a separate conflict. +.TP +\fB\-\-keep\-local\-all\fR +Resolve every remaining conflict by keeping this machine's version + +Paths named by \-\-take\-remote take the repository's version; every other conflict keeps this machine's. Each kept path must already be saved. +.TP \fB\-h, \-\-help\fR Print help \fBArguments:\fR @@ -4453,6 +4463,16 @@ Resolve a conflict with the repository's version \fB\-\-keep\-local\fR \fI\fR Resolve a conflict by keeping this machine's version (published next) .TP +\fB\-\-take\-remote\-all\fR +Resolve every remaining conflict with the repository's version + +Paths named by \-\-keep\-local keep this machine's version; every other conflict takes the repository's. Useful on a newly adopted machine, where each pre\-existing file that differs is a separate conflict. +.TP +\fB\-\-keep\-local\-all\fR +Resolve every remaining conflict by keeping this machine's version + +Paths named by \-\-take\-remote take the repository's version; every other conflict keeps this machine's. Each kept path must already be saved. +.TP \fB\-h, \-\-help\fR Print help \fBArguments:\fR diff --git a/mise.lock b/mise.lock index ba6005d56aa..e30b726cbf2 100644 --- a/mise.lock +++ b/mise.lock @@ -108,52 +108,52 @@ url = "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9 url_api = "https://api.github.com/repos/nextest-rs/nextest/releases/assets/555805900" [[tools.aube]] -version = "2.2.16" +version = "2.2.17" backend = "packslip:github.com/aubepkg/aube" [tools.aube."platforms.linux-arm64"] -checksum = "sha256:a3b3a44ebff731672741c41d65b806dc8e138252f81e50240e3f8a359deb065d" -url = "https://github.com/aubepkg/aube/releases/download/v2.2.16/aube-v2.2.16-aarch64-unknown-linux-gnu.tar.gz" +checksum = "sha256:933d5e63aafef67baa7ec9b0589674e399b3f0e567837f88404b89f08be0bbc1" +url = "https://github.com/aubepkg/aube/releases/download/v2.2.17/aube-v2.2.17-aarch64-unknown-linux-gnu.tar.gz" signer = "sigstore-oidc:https://github.com/aubepkg/aube/.github/workflows/release-plz.yml" [tools.aube."platforms.linux-arm64-musl"] -checksum = "sha256:2ee2ada278809c180bae121863093158b7dc1f6983aa96db6cee1ce1a49a4a0f" -url = "https://github.com/aubepkg/aube/releases/download/v2.2.16/aube-v2.2.16-aarch64-unknown-linux-musl.tar.gz" +checksum = "sha256:2c564811b7a0ef1c3011282696a9cbaa422cdc65951ab8cc98ac65b298112921" +url = "https://github.com/aubepkg/aube/releases/download/v2.2.17/aube-v2.2.17-aarch64-unknown-linux-musl.tar.gz" signer = "sigstore-oidc:https://github.com/aubepkg/aube/.github/workflows/release-plz.yml" [tools.aube."platforms.linux-x64"] -checksum = "sha256:f459e96c1eba2068ea921875748fe1777e78b919886f2a83058b8c5c50e139ef" -url = "https://github.com/aubepkg/aube/releases/download/v2.2.16/aube-v2.2.16-x86_64-unknown-linux-gnu.tar.gz" +checksum = "sha256:ee4f05a2989e6cbe55b306097cbb53da9f908789acb027e62c430de2f7e22c2f" +url = "https://github.com/aubepkg/aube/releases/download/v2.2.17/aube-v2.2.17-x86_64-unknown-linux-gnu.tar.gz" signer = "sigstore-oidc:https://github.com/aubepkg/aube/.github/workflows/release-plz.yml" [tools.aube."platforms.linux-x64-baseline"] -checksum = "sha256:f459e96c1eba2068ea921875748fe1777e78b919886f2a83058b8c5c50e139ef" -url = "https://github.com/aubepkg/aube/releases/download/v2.2.16/aube-v2.2.16-x86_64-unknown-linux-gnu.tar.gz" +checksum = "sha256:ee4f05a2989e6cbe55b306097cbb53da9f908789acb027e62c430de2f7e22c2f" +url = "https://github.com/aubepkg/aube/releases/download/v2.2.17/aube-v2.2.17-x86_64-unknown-linux-gnu.tar.gz" signer = "sigstore-oidc:https://github.com/aubepkg/aube/.github/workflows/release-plz.yml" [tools.aube."platforms.linux-x64-musl"] -checksum = "sha256:61097c72679196bae2a0ae19dd6cc9113e6a950ae7c84753b4b66526509d230d" -url = "https://github.com/aubepkg/aube/releases/download/v2.2.16/aube-v2.2.16-x86_64-unknown-linux-musl.tar.gz" +checksum = "sha256:6ec5a6bb93ded45db61445bd4b730b63be002cde442af8eecfc02a84a27dd221" +url = "https://github.com/aubepkg/aube/releases/download/v2.2.17/aube-v2.2.17-x86_64-unknown-linux-musl.tar.gz" signer = "sigstore-oidc:https://github.com/aubepkg/aube/.github/workflows/release-plz.yml" [tools.aube."platforms.linux-x64-musl-baseline"] -checksum = "sha256:61097c72679196bae2a0ae19dd6cc9113e6a950ae7c84753b4b66526509d230d" -url = "https://github.com/aubepkg/aube/releases/download/v2.2.16/aube-v2.2.16-x86_64-unknown-linux-musl.tar.gz" +checksum = "sha256:6ec5a6bb93ded45db61445bd4b730b63be002cde442af8eecfc02a84a27dd221" +url = "https://github.com/aubepkg/aube/releases/download/v2.2.17/aube-v2.2.17-x86_64-unknown-linux-musl.tar.gz" signer = "sigstore-oidc:https://github.com/aubepkg/aube/.github/workflows/release-plz.yml" [tools.aube."platforms.macos-arm64"] -checksum = "sha256:325b967ee1f554012e56db3b57473924352302468fc3dcbf37f0336efdc03597" -url = "https://github.com/aubepkg/aube/releases/download/v2.2.16/aube-v2.2.16-aarch64-apple-darwin.tar.gz" +checksum = "sha256:83b2906a998ea62eb4890abb8d45010f0ced1674a2bb6b436c2e352d47bb338f" +url = "https://github.com/aubepkg/aube/releases/download/v2.2.17/aube-v2.2.17-aarch64-apple-darwin.tar.gz" signer = "sigstore-oidc:https://github.com/aubepkg/aube/.github/workflows/release-plz.yml" [tools.aube."platforms.windows-x64"] -checksum = "sha256:e7fba256b8f966e1063099644f765ad23ce713a4fc938ce0c41dcf76378a55d1" -url = "https://github.com/aubepkg/aube/releases/download/v2.2.16/aube-v2.2.16-x86_64-pc-windows-msvc.zip" +checksum = "sha256:03c93095d89f41496436083f45d770b3169dd1f7db91d86577a9e3d004605c0f" +url = "https://github.com/aubepkg/aube/releases/download/v2.2.17/aube-v2.2.17-x86_64-pc-windows-msvc.zip" signer = "sigstore-oidc:https://github.com/aubepkg/aube/.github/workflows/release-plz.yml" [tools.aube."platforms.windows-x64-baseline"] -checksum = "sha256:e7fba256b8f966e1063099644f765ad23ce713a4fc938ce0c41dcf76378a55d1" -url = "https://github.com/aubepkg/aube/releases/download/v2.2.16/aube-v2.2.16-x86_64-pc-windows-msvc.zip" +checksum = "sha256:03c93095d89f41496436083f45d770b3169dd1f7db91d86577a9e3d004605c0f" +url = "https://github.com/aubepkg/aube/releases/download/v2.2.17/aube-v2.2.17-x86_64-pc-windows-msvc.zip" signer = "sigstore-oidc:https://github.com/aubepkg/aube/.github/workflows/release-plz.yml" [[tools.bun]] @@ -533,62 +533,53 @@ url_api = "https://api.github.com/repos/jdx/tak/releases/assets/537992910" provenance = "github-attestations" [[tools.hk]] -version = "1.58.1" +version = "2.0.0" backend = "aqua:jdx/hk" [tools.hk."platforms.linux-arm64"] -checksum = "sha256:0972e05774ea3651fd521676c1bc6411333fe4404beb4a48067e10b54dfd1b4d" -url = "https://github.com/jdx/hk/releases/download/v1.58.1/hk-aarch64-unknown-linux-gnu.tar.gz" -url_api = "https://api.github.com/repos/jdx/hk/releases/assets/546232324" -signer = "sigstore-oidc:https://github.com/jdx/hk/.github/workflows/release.yml" +checksum = "sha256:1034ac9e07d7ffc18b58c72c6fe1013bd1e6ab1d8fa421bc4f4ac68ee08cb011" +url = "https://github.com/jdx/hk/releases/download/v2.0.0/hk-aarch64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/jdx/hk/releases/assets/560743974" [tools.hk."platforms.linux-arm64-musl"] -checksum = "sha256:a49cc7c21cf914c03fbda6289254bb704d424e91458afd91074f16921f9a5e84" -url = "https://github.com/jdx/hk/releases/download/v1.58.1/hk-aarch64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/jdx/hk/releases/assets/546232327" -signer = "sigstore-oidc:https://github.com/jdx/hk/.github/workflows/release.yml" +checksum = "sha256:494b4e1c6be73b44cffcead07a97681ec3f1ea72df05a6fbba5a2bfae3648604" +url = "https://github.com/jdx/hk/releases/download/v2.0.0/hk-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/jdx/hk/releases/assets/560743976" [tools.hk."platforms.linux-x64"] -checksum = "sha256:8139133a70ed7cf1d4a0f2f19c26b1b68d714d81af91b2b1f42c4c524afc0fe5" -url = "https://github.com/jdx/hk/releases/download/v1.58.1/hk-x86_64-unknown-linux-gnu.tar.gz" -url_api = "https://api.github.com/repos/jdx/hk/releases/assets/546232333" -signer = "sigstore-oidc:https://github.com/jdx/hk/.github/workflows/release.yml" +checksum = "sha256:d2cdde7a68dba648b2d25605ceab364074a057fb93618b46ce7ac3e298bfba2c" +url = "https://github.com/jdx/hk/releases/download/v2.0.0/hk-x86_64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/jdx/hk/releases/assets/560743996" [tools.hk."platforms.linux-x64-baseline"] -checksum = "sha256:8139133a70ed7cf1d4a0f2f19c26b1b68d714d81af91b2b1f42c4c524afc0fe5" -url = "https://github.com/jdx/hk/releases/download/v1.58.1/hk-x86_64-unknown-linux-gnu.tar.gz" -url_api = "https://api.github.com/repos/jdx/hk/releases/assets/546232333" -signer = "sigstore-oidc:https://github.com/jdx/hk/.github/workflows/release.yml" +checksum = "sha256:d2cdde7a68dba648b2d25605ceab364074a057fb93618b46ce7ac3e298bfba2c" +url = "https://github.com/jdx/hk/releases/download/v2.0.0/hk-x86_64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/jdx/hk/releases/assets/560743996" [tools.hk."platforms.linux-x64-musl"] -checksum = "sha256:df8a97c000d59756c14096a550d211d7346c41abf2a20d394aaa0d9bc9695ef5" -url = "https://github.com/jdx/hk/releases/download/v1.58.1/hk-x86_64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/jdx/hk/releases/assets/546232335" -signer = "sigstore-oidc:https://github.com/jdx/hk/.github/workflows/release.yml" +checksum = "sha256:8faf654847d8cc7432012d4b1277ae91b4bca78275f87776f0381aee26d7920d" +url = "https://github.com/jdx/hk/releases/download/v2.0.0/hk-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/jdx/hk/releases/assets/560744002" [tools.hk."platforms.linux-x64-musl-baseline"] -checksum = "sha256:df8a97c000d59756c14096a550d211d7346c41abf2a20d394aaa0d9bc9695ef5" -url = "https://github.com/jdx/hk/releases/download/v1.58.1/hk-x86_64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/jdx/hk/releases/assets/546232335" -signer = "sigstore-oidc:https://github.com/jdx/hk/.github/workflows/release.yml" +checksum = "sha256:8faf654847d8cc7432012d4b1277ae91b4bca78275f87776f0381aee26d7920d" +url = "https://github.com/jdx/hk/releases/download/v2.0.0/hk-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/jdx/hk/releases/assets/560744002" [tools.hk."platforms.macos-arm64"] -checksum = "sha256:5529d95c63ea3ba9318f551e19760998a9ddf7ed926a14daf39dc46717ab32bf" -url = "https://github.com/jdx/hk/releases/download/v1.58.1/hk-aarch64-apple-darwin.tar.gz" -url_api = "https://api.github.com/repos/jdx/hk/releases/assets/546232326" -signer = "sigstore-oidc:https://github.com/jdx/hk/.github/workflows/release.yml" +checksum = "sha256:9ee3f4350b321677e5ce291882c6441b153773b32705734d2208f93ac71ce12f" +url = "https://github.com/jdx/hk/releases/download/v2.0.0/hk-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/jdx/hk/releases/assets/560743972" [tools.hk."platforms.windows-x64"] -checksum = "sha256:fb11cf7aa477f3f0546cf66a05ac9a8563322500e5766f63c011ffb97f5c708d" -url = "https://github.com/jdx/hk/releases/download/v1.58.1/hk-x86_64-pc-windows-msvc.zip" -url_api = "https://api.github.com/repos/jdx/hk/releases/assets/546232325" -signer = "sigstore-oidc:https://github.com/jdx/hk/.github/workflows/release.yml" +checksum = "sha256:81fca45c5e7697fdc114080f2fc1ea229bb053b4201a7f4e15e62d1a68820a65" +url = "https://github.com/jdx/hk/releases/download/v2.0.0/hk-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/jdx/hk/releases/assets/560743971" [tools.hk."platforms.windows-x64-baseline"] -checksum = "sha256:fb11cf7aa477f3f0546cf66a05ac9a8563322500e5766f63c011ffb97f5c708d" -url = "https://github.com/jdx/hk/releases/download/v1.58.1/hk-x86_64-pc-windows-msvc.zip" -url_api = "https://api.github.com/repos/jdx/hk/releases/assets/546232325" -signer = "sigstore-oidc:https://github.com/jdx/hk/.github/workflows/release.yml" +checksum = "sha256:81fca45c5e7697fdc114080f2fc1ea229bb053b4201a7f4e15e62d1a68820a65" +url = "https://github.com/jdx/hk/releases/download/v2.0.0/hk-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/jdx/hk/releases/assets/560743971" [[tools.jq]] version = "1.8.2" @@ -804,55 +795,6 @@ backend = "npm:ajv-cli" version = "0.49.1" backend = "npm:markdownlint-cli" -[[tools.pkl]] -version = "0.32.1" -backend = "aqua:apple/pkl" - -[tools.pkl."platforms.linux-arm64"] -checksum = "sha256:a76d2dd47da435a8f911b0347373f47c7e59ea54fb75ff846d20b8df10dba058" -url = "https://github.com/apple/pkl/releases/download/0.32.1/pkl-linux-aarch64" -url_api = "https://api.github.com/repos/apple/pkl/releases/assets/487426568" - -[tools.pkl."platforms.linux-arm64-musl"] -checksum = "sha256:a76d2dd47da435a8f911b0347373f47c7e59ea54fb75ff846d20b8df10dba058" -url = "https://github.com/apple/pkl/releases/download/0.32.1/pkl-linux-aarch64" -url_api = "https://api.github.com/repos/apple/pkl/releases/assets/487426568" - -[tools.pkl."platforms.linux-x64"] -checksum = "sha256:3180b62da95c0cad1d904e9bb6c5f4a8f9032413c21e53194bb91ff1ee5f3211" -url = "https://github.com/apple/pkl/releases/download/0.32.1/pkl-linux-amd64" -url_api = "https://api.github.com/repos/apple/pkl/releases/assets/487426567" - -[tools.pkl."platforms.linux-x64-baseline"] -checksum = "sha256:3180b62da95c0cad1d904e9bb6c5f4a8f9032413c21e53194bb91ff1ee5f3211" -url = "https://github.com/apple/pkl/releases/download/0.32.1/pkl-linux-amd64" -url_api = "https://api.github.com/repos/apple/pkl/releases/assets/487426567" - -[tools.pkl."platforms.linux-x64-musl"] -checksum = "sha256:3180b62da95c0cad1d904e9bb6c5f4a8f9032413c21e53194bb91ff1ee5f3211" -url = "https://github.com/apple/pkl/releases/download/0.32.1/pkl-linux-amd64" -url_api = "https://api.github.com/repos/apple/pkl/releases/assets/487426567" - -[tools.pkl."platforms.linux-x64-musl-baseline"] -checksum = "sha256:3180b62da95c0cad1d904e9bb6c5f4a8f9032413c21e53194bb91ff1ee5f3211" -url = "https://github.com/apple/pkl/releases/download/0.32.1/pkl-linux-amd64" -url_api = "https://api.github.com/repos/apple/pkl/releases/assets/487426567" - -[tools.pkl."platforms.macos-arm64"] -checksum = "sha256:563eb51c9a20b16a3625464ed745c675ed9750381f2126722696a0d7cac1d9d3" -url = "https://github.com/apple/pkl/releases/download/0.32.1/pkl-macos-aarch64" -url_api = "https://api.github.com/repos/apple/pkl/releases/assets/487426570" - -[tools.pkl."platforms.windows-x64"] -checksum = "sha256:8550a00fcf027335e42c5e2cd553b88e98845408cb1880b3e3d1860caf46d22a" -url = "https://github.com/apple/pkl/releases/download/0.32.1/pkl-windows-amd64.exe" -url_api = "https://api.github.com/repos/apple/pkl/releases/assets/487426668" - -[tools.pkl."platforms.windows-x64-baseline"] -checksum = "sha256:8550a00fcf027335e42c5e2cd553b88e98845408cb1880b3e3d1860caf46d22a" -url = "https://github.com/apple/pkl/releases/download/0.32.1/pkl-windows-amd64.exe" -url_api = "https://api.github.com/repos/apple/pkl/releases/assets/487426668" - [[tools.prettier]] version = "3.9.6" backend = "npm:prettier" diff --git a/mise.toml b/mise.toml index 7c7d5859f2d..a3cfe556756 100644 --- a/mise.toml +++ b/mise.toml @@ -27,7 +27,6 @@ stylua = "latest" "npm:markdownlint-cli" = "latest" prettier = "3.9.6" "npm:ajv-cli" = "latest" -pkl = "latest" shellcheck = "latest" shfmt = "latest" taplo = "latest" diff --git a/mise.usage.kdl b/mise.usage.kdl index 41998be0373..3b1b8c7b90f 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -939,7 +939,7 @@ own; this command writes what is pending right now and decides conflicts. When an incoming configuration declares more tracked files, their shared versions follow in the same run. """# - after_long_help "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n $ \u{1b}[1mmise dot pull --dry-run\u{1b}[22m\n $ \u{1b}[1mmise dot pull --yes\u{1b}[22m\n $ \u{1b}[1mmise dot pull --take-remote ~/.zshrc\u{1b}[22m\n $ \u{1b}[1mmise dot pull --keep-local ~/.zshrc\u{1b}[22m\n" + after_long_help "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n $ \u{1b}[1mmise dot pull --dry-run\u{1b}[22m\n $ \u{1b}[1mmise dot pull --yes\u{1b}[22m\n $ \u{1b}[1mmise dot pull --take-remote ~/.zshrc\u{1b}[22m\n $ \u{1b}[1mmise dot pull --keep-local ~/.zshrc\u{1b}[22m\n $ \u{1b}[1mmise dot pull --take-remote-all\u{1b}[22m\n $ \u{1b}[1mmise dot pull --take-remote-all --keep-local ~/.zshrc\u{1b}[22m\n" flag "-n --dry-run" help="Show the plan without changing anything" flag "-y --yes" help="Pull without prompting" flag --take-remote help="Resolve a conflict with the repository's version" var=#true { @@ -948,6 +948,20 @@ their shared versions follow in the same run. flag --keep-local help="Resolve a conflict by keeping this machine's version (published next)" var=#true { arg } + flag --take-remote-all help="Resolve every remaining conflict with the repository's version" conflicts=--keep-local-all { + long_help #""" +Resolve every remaining conflict with the repository's version + +Paths named by --keep-local keep this machine's version; every other conflict takes the repository's. Useful on a newly adopted machine, where each pre-existing file that differs is a separate conflict. +"""# + } + flag --keep-local-all help="Resolve every remaining conflict by keeping this machine's version" conflicts=--take-remote-all { + long_help #""" +Resolve every remaining conflict by keeping this machine's version + +Paths named by --take-remote take the repository's version; every other conflict keeps this machine's. Each kept path must already be saved. +"""# + } flag "-h --help" help="Print help" action=help builtin=#true arg "[PATH]…" help="Partial pulls are unsupported; omit PATH and apply the complete setup" required=#false var=#true } @@ -2567,7 +2581,7 @@ own; this command writes what is pending right now and decides conflicts. When an incoming configuration declares more tracked files, their shared versions follow in the same run. """# - after_long_help "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n $ \u{1b}[1mmise dot pull --dry-run\u{1b}[22m\n $ \u{1b}[1mmise dot pull --yes\u{1b}[22m\n $ \u{1b}[1mmise dot pull --take-remote ~/.zshrc\u{1b}[22m\n $ \u{1b}[1mmise dot pull --keep-local ~/.zshrc\u{1b}[22m\n" + after_long_help "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n $ \u{1b}[1mmise dot pull --dry-run\u{1b}[22m\n $ \u{1b}[1mmise dot pull --yes\u{1b}[22m\n $ \u{1b}[1mmise dot pull --take-remote ~/.zshrc\u{1b}[22m\n $ \u{1b}[1mmise dot pull --keep-local ~/.zshrc\u{1b}[22m\n $ \u{1b}[1mmise dot pull --take-remote-all\u{1b}[22m\n $ \u{1b}[1mmise dot pull --take-remote-all --keep-local ~/.zshrc\u{1b}[22m\n" flag "-n --dry-run" help="Show the plan without changing anything" flag "-y --yes" help="Pull without prompting" flag --take-remote help="Resolve a conflict with the repository's version" var=#true { @@ -2576,6 +2590,20 @@ their shared versions follow in the same run. flag --keep-local help="Resolve a conflict by keeping this machine's version (published next)" var=#true { arg } + flag --take-remote-all help="Resolve every remaining conflict with the repository's version" conflicts=--keep-local-all { + long_help #""" +Resolve every remaining conflict with the repository's version + +Paths named by --keep-local keep this machine's version; every other conflict takes the repository's. Useful on a newly adopted machine, where each pre-existing file that differs is a separate conflict. +"""# + } + flag --keep-local-all help="Resolve every remaining conflict by keeping this machine's version" conflicts=--take-remote-all { + long_help #""" +Resolve every remaining conflict by keeping this machine's version + +Paths named by --take-remote take the repository's version; every other conflict keeps this machine's. Each kept path must already be saved. +"""# + } flag "-h --help" help="Print help" action=help builtin=#true arg "[PATH]…" help="Partial pulls are unsupported; omit PATH and apply the complete setup" required=#false var=#true } diff --git a/src/backend/mod.rs b/src/backend/mod.rs index b68c6d4b1d4..162564a3a37 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -138,6 +138,19 @@ pub(crate) fn backend_arg_matches_registry_backend(ba: &BackendArg) -> bool { .is_some_and(|rt| rt.backends().iter().any(|b| *b == full)) } +/// mise-versions publishes one version list per registry short name, generated from the +/// entry's first backend. Every other backend of the same entry lists different versions: +/// a `min_version` boundary routes older requests to a later backend, and platform or +/// settings filtering can select one too. Compare against the declared first backend +/// rather than the filtered `RegistryTool::backends()` list, so a client-side filter +/// promoting another backend does not make it inherit the published list. +pub(crate) fn backend_arg_is_preferred_registry_backend(ba: &BackendArg) -> bool { + let full = ba.full_without_opts(); + REGISTRY + .get(ba.short.as_str()) + .is_some_and(|rt| rt.backends.first().is_some_and(|b| b.full == full)) +} + pub(crate) fn toolset_semver_version(ts: &Toolset, tool: &str) -> Option { let tvl = ts .versions @@ -1434,6 +1447,31 @@ mod tests { assert!(backend_arg_matches_registry_backend(&ba)); } + #[test] + fn test_only_the_preferred_registry_backend_may_use_the_versions_host() { + // mise-versions publishes one list per short name, built from the preferred + // backend. A `min_version` boundary routes older requests to a later backend + // whose versions that list does not describe. + let preferred = BackendArg::new( + "hk".to_string(), + Some("packslip:github.com/jdx/hk".to_string()), + ); + let fallback = BackendArg::new("hk".to_string(), Some("aqua:jdx/hk".to_string())); + + assert!(backend_arg_matches_registry_backend(&preferred)); + assert!(backend_arg_matches_registry_backend(&fallback)); + + assert!(backend_arg_is_preferred_registry_backend(&preferred)); + assert!(!backend_arg_is_preferred_registry_backend(&fallback)); + + // Inline options still describe the preferred backend. + let with_opts = BackendArg::new( + "hk".to_string(), + Some("packslip:github.com/jdx/hk[bin=hk]".to_string()), + ); + assert!(backend_arg_is_preferred_registry_backend(&with_opts)); + } + #[test] fn test_runtime_path_for_install_path_remaps_install_subpath() -> Result<()> { let temp_dir = tempfile::tempdir()?; @@ -2399,19 +2437,21 @@ pub(crate) trait Backend: Debug + Send + Sync { } matches } else { - // For non-plugin backends (e.g. github:, cargo:), check if the backend matches - // the registry's default. When a user aliases a tool to a different backend - // (e.g. `php = "github:verzly/php"`), the versions host would return versions - // from the registry's default backend which may not match the aliased backend. + // For non-plugin backends (e.g. github:, cargo:), check if the backend is the + // registry's preferred one. When a user aliases a tool to a different backend + // (e.g. `php = "github:verzly/php"`), or a `min_version` boundary routes an + // older request to a later backend, the versions host would return the + // preferred backend's versions, which do not describe the resolved backend. if REGISTRY.contains_key(ba.short.as_str()) { - if !backend_arg_matches_registry_backend(&ba) { + let is_preferred = backend_arg_is_preferred_registry_backend(&ba); + if !is_preferred { trace!( "Skipping versions host for {} because backend {} is not the registry default", ba.short, ba.full() ); } - backend_arg_matches_registry_backend(&ba) + is_preferred } else { trace!( "Skipping versions host for {} because it is not in the registry", diff --git a/src/backend/npm.rs b/src/backend/npm.rs index 31260f392f0..e5df2cc1961 100644 --- a/src/backend/npm.rs +++ b/src/backend/npm.rs @@ -62,6 +62,18 @@ enum AllowBuilds { Packages(Vec), } +/// How far a tool opts out of aube's non-registry-source gate. +/// `Packages` is the scoped form and the one to prefer: it names the +/// dependencies allowed an exotic source and leaves the rest of the graph +/// gated. `All` drops the gate for everything the tool pulls in, now and +/// after any future update. +#[derive(Debug, Clone, PartialEq, Eq)] +enum AllowExoticDeps { + None, + All, + Packages(Vec), +} + impl<'a> NpmOptions<'a> { fn new(raw: &'a ToolVersionOptions) -> Self { Self { @@ -100,16 +112,76 @@ impl<'a> NpmOptions<'a> { /// Scoped to the requested package only — transitive dependencies stay /// gated, and the malicious-package advisory check still runs. fn allow_low_downloads(&self) -> eyre::Result { - let Some(value) = self.values.raw().opts.get("allow_low_downloads") else { + self.bool_option("allow_low_downloads") + } + + /// Which dependencies in this tool's graph may come from non-registry + /// sources (`git+`, `file:`, `exec:`, or a direct tarball URL). aube + /// blocks them by default via `blockExoticSubdeps`; some packages + /// legitimately depend on one, e.g. a package the registry no longer + /// carries a safe release of. + /// + /// A list names the exempt packages and is what the docs steer people + /// to; `true` is the blunt form that exempts the whole graph. + fn allow_exotic_deps(&self) -> eyre::Result { + let Some(value) = self.values.raw().opts.get("allow_exotic_deps") else { + return Ok(AllowExoticDeps::None); + }; + match value { + toml::Value::Boolean(true) => Ok(AllowExoticDeps::All), + toml::Value::Boolean(false) => Ok(AllowExoticDeps::None), + toml::Value::String(value) if value.eq_ignore_ascii_case("true") => { + Ok(AllowExoticDeps::All) + } + toml::Value::String(value) if value.eq_ignore_ascii_case("false") => { + Ok(AllowExoticDeps::None) + } + toml::Value::String(value) => Ok(Self::canonical_exotic_packages(vec![value.clone()])), + toml::Value::Array(values) => { + let packages = values + .iter() + .map(|value| { + value.as_str().map(str::to_string).ok_or_else(|| { + eyre::eyre!("allow_exotic_deps array must contain only strings") + }) + }) + .collect::>>()?; + Ok(Self::canonical_exotic_packages(packages)) + } + value => Err(eyre::eyre!( + "allow_exotic_deps must be true, false, a string, or array, got {value}" + )), + } + } + + fn canonical_exotic_packages(mut packages: Vec) -> AllowExoticDeps { + Self::canonicalize_string_list(&mut packages); + if packages.is_empty() { + AllowExoticDeps::None + } else { + AllowExoticDeps::Packages(packages) + } + } + + fn canonical_allow_exotic_deps_lockfile_value(&self) -> eyre::Result> { + Ok(match self.allow_exotic_deps()? { + AllowExoticDeps::None => None, + AllowExoticDeps::All => Some("true".into()), + AllowExoticDeps::Packages(packages) => Some(format!("{packages:?}")), + }) + } + + /// Parse a boolean tool option, accepting the TOML boolean and the string + /// spellings `mise use npm:x[key=true]` produces. Absent reads as `false`. + fn bool_option(&self, key: &str) -> eyre::Result { + let Some(value) = self.values.raw().opts.get(key) else { return Ok(false); }; match value { toml::Value::Boolean(value) => Ok(*value), toml::Value::String(value) if value.eq_ignore_ascii_case("true") => Ok(true), toml::Value::String(value) if value.eq_ignore_ascii_case("false") => Ok(false), - value => Err(eyre::eyre!( - "allow_low_downloads must be a boolean, got {value}" - )), + value => Err(eyre::eyre!("{key} must be a boolean, got {value}")), } } @@ -248,6 +320,10 @@ impl<'a> NpmOptions<'a> { self.canonical_trust_policy_excludes_lockfile_value() .ok() .flatten() + } else if key == "allow_exotic_deps" { + self.canonical_allow_exotic_deps_lockfile_value() + .ok() + .flatten() } else { self.values.raw().opts.get(&key).map(|value| match value { toml::Value::String(value) => value.clone(), @@ -1171,6 +1247,7 @@ impl NPMBackend { let install_path = tv.install_path(); crate::file::create_dir_all(&install_path)?; + Self::warn_if_install_env_ignored(tv); let allow_builds = options.allow_builds()?; self.write_aube_embed_project( &install_path, @@ -1278,6 +1355,24 @@ impl NPMBackend { Ok(()) } + /// The embedded installer runs in-process, so nothing hands it a + /// per-tool environment: aube snapshots the real process env when the + /// install starts. `install_env` is therefore silently inert here, which + /// otherwise looks like the setting simply not working — notably for + /// someone reaching for `AUBE_*` to relax an install-scoped gate. Point at + /// the tool options that do reach aube instead. + fn warn_if_install_env_ignored(tv: &ToolVersion) { + let keys = tv.install_env().keys().cloned().collect::>(); + if keys.is_empty() { + return; + } + warn!( + "install_env ({}) is ignored for {}: mise installs through the embedded aube package manager, which runs in-process rather than as a subprocess. Install-scoped aube settings have npm backend tool options (`allow_builds`, `allow_exotic_deps`, `allow_low_downloads`, `trust_policy_excludes`); anything else has to be set in mise's own environment.", + keys.join(", "), + tv.ba().full(), + ); + } + /// Install through a standalone aube executable while keeping version /// metadata on mise's built-in registry client. Calling `aube` directly /// avoids depending on the npm compatibility shim installed by @@ -1527,6 +1622,7 @@ impl NPMBackend { ) -> Result { let trust_policy_excludes = options.trust_policy_excludes()?; let allow_low_downloads = options.allow_low_downloads()?; + let allow_exotic_deps = options.allow_exotic_deps()?; let mut config = toml::Table::new(); if let Some(before_date) = before_date { let minutes = Self::build_aube_minimum_release_age(elapsed_seconds_ceil( @@ -1558,6 +1654,25 @@ impl NPMBackend { toml::Value::Array(vec![toml::Value::String(self.tool_name())]), ); } + match allow_exotic_deps { + // Nothing written: aube's own default stays in charge, so a + // stricter org-managed config still wins. + AllowExoticDeps::None => {} + // Keep the gate on and name the exceptions, so a dependency + // added by a later update is still stopped. + AllowExoticDeps::Packages(packages) => { + config.insert( + "blockExoticSubdepsExclude".to_string(), + toml::Value::Array(packages.into_iter().map(toml::Value::String).collect()), + ); + } + AllowExoticDeps::All => { + config.insert( + "blockExoticSubdeps".to_string(), + toml::Value::Boolean(false), + ); + } + } Ok(config) } @@ -2094,6 +2209,24 @@ fn build_aube_install_error_message(err: &miette::Report, tool_full: &str) -> St msg.push_str(&format!( "\n help: after verifying the package, set `allow_low_downloads = true` on `{tool_full}` to approve it" )); + } else if msg.contains("blockExoticSubdeps") { + // aube's own help points at `.npmrc` / `settings.toml`, which mise + // generates and overwrites on every install. Name the mise option + // instead. Matched on the message because the blocked-specifier + // failure reaches mise as a plain registry error on some paths, with + // `ERR_AUBE_BLOCKED_EXOTIC_SUBDEP` only on others. + msg.push_str(&format!( + "\n\n A dependency in this package's graph is fetched from a git, file, or direct \ + tarball URL rather than the npm registry, which aube blocks by default.\n\n \ + Review where that dependency actually comes from before allowing it — an \ + attacker-controlled URL in a transitive dependency is a supply-chain foothold the \ + registry's own protections never see. Then list it in `allow_exotic_deps` — the \ + package name is in the error above:\n \ + \"{tool_full}\" = {{ version = \"latest\", allow_exotic_deps = [\"\"] }}\n\n \ + Every other package in the graph stays gated, including one a later update adds. \ + `allow_exotic_deps = true` exempts the whole graph instead and should be a last \ + resort." + )); } else if let Some(help) = err.help() { msg.push_str(&format!("\n help: {help}")); } @@ -2110,6 +2243,7 @@ pub(crate) fn install_time_option_keys() -> Vec { "allow_builds".into(), "trust_policy_excludes".into(), "allow_low_downloads".into(), + "allow_exotic_deps".into(), ] } @@ -2382,6 +2516,39 @@ mod tests { assert!(msg.contains("https://aube.jdx.dev/security#trust-policy")); } + #[test] + fn test_build_aube_install_error_message_points_at_allow_exotic_deps() { + use miette::Diagnostic; + use thiserror::Error; + + // The shape a blocked non-registry subdep actually reaches mise in: a + // plain registry error nested under the resolver's summary, with + // aube's own `.npmrc` / `settings.toml` help attached. + #[derive(Debug, Error, Diagnostic)] + #[error( + "registry error for xlsx: uses exotic specifier \"https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz\" which is blocked by blockExoticSubdeps (declared by @gmickel/gno)" + )] + #[diagnostic(help("set blockExoticSubdeps=false in .npmrc / settings.toml"))] + struct Cause; + + #[derive(Debug, Error, Diagnostic)] + #[error("failed to resolve dependencies")] + struct TopErr { + #[source] + source: Cause, + } + + let report = miette::Report::new(TopErr { source: Cause }); + let msg = build_aube_install_error_message(&report, "npm:@gmickel/gno"); + assert!(msg.contains("caused by: registry error for xlsx")); + // Steers to the scoped list, not the whole-graph switch. + assert!(msg.contains("allow_exotic_deps = [\"\"]")); + assert!(msg.contains("\"npm:@gmickel/gno\"")); + // aube's help names the config files mise generates and overwrites, so + // it must not be the remedy the user is told to follow. + assert!(!msg.contains("help: set blockExoticSubdeps=false")); + } + #[test] fn test_build_aube_install_error_message_other_error_keeps_aube_help() { use miette::Diagnostic; @@ -3257,6 +3424,146 @@ pkg@1.2.0 '1.2.0' assert!(!install_path.join(".config/aube/config.toml").exists()); } + fn write_gno_project(raw_options: &ToolVersionOptions) -> toml::Table { + let backend = create_npm_backend("@gmickel/gno"); + let tmp = tempfile::tempdir().unwrap(); + let install_path = tmp.path().join("npm-gno").join("1.0.0"); + crate::file::create_dir_all(&install_path).unwrap(); + let options = NpmOptions::new(raw_options); + let allow_builds = options.allow_builds().unwrap(); + backend + .write_aube_embed_project(&install_path, None, &options, &allow_builds, false) + .unwrap(); + toml::from_str( + &std::fs::read_to_string(install_path.join(".config/aube/config.toml")).unwrap(), + ) + .unwrap() + } + + #[test] + fn test_write_aube_embed_project_scopes_exotic_deps_to_listed_packages() { + let mut raw_options = ToolVersionOptions::default(); + raw_options.opts.insert( + "allow_exotic_deps".to_string(), + toml::Value::Array(vec![toml::Value::String("xlsx".into())]), + ); + + let config = write_gno_project(&raw_options); + + // The gate itself stays on: only the named package is exempt, so a + // non-registry dependency a later update adds is still blocked. + assert_eq!( + config["blockExoticSubdepsExclude"], + toml::Value::Array(vec![toml::Value::String("xlsx".to_string())]) + ); + assert!(!config.contains_key("blockExoticSubdeps")); + } + + #[test] + fn test_write_aube_embed_project_accepts_a_bare_exotic_package_string() { + let mut raw_options = ToolVersionOptions::default(); + raw_options.opts.insert( + "allow_exotic_deps".to_string(), + toml::Value::String("xlsx".into()), + ); + + let config = write_gno_project(&raw_options); + + // A bare string is the one-package list, matching `allow_builds`. + // `"true"`/`"false"` keep their boolean meaning and are covered by + // `test_allow_exotic_deps_parses_every_form`. + assert_eq!( + config["blockExoticSubdepsExclude"], + toml::Value::Array(vec![toml::Value::String("xlsx".to_string())]) + ); + } + + #[test] + fn test_write_aube_embed_project_unblocks_every_exotic_dep_when_asked() { + let mut raw_options = ToolVersionOptions::default(); + raw_options + .opts + .insert("allow_exotic_deps".to_string(), toml::Value::Boolean(true)); + + let config = write_gno_project(&raw_options); + + assert_eq!(config["blockExoticSubdeps"], toml::Value::Boolean(false)); + assert!(!config.contains_key("blockExoticSubdepsExclude")); + } + + #[test] + fn test_write_aube_embed_project_leaves_exotic_deps_blocked_by_default() { + let config = write_gno_project(&ToolVersionOptions::default()); + // Neither key written: aube's own default stays in charge, and a + // stricter org-managed config can still win. + assert!(!config.contains_key("blockExoticSubdeps")); + assert!(!config.contains_key("blockExoticSubdepsExclude")); + } + + #[test] + fn test_allow_exotic_deps_parses_every_form() { + fn parse(value: toml::Value) -> eyre::Result { + let mut opts = ToolVersionOptions::default(); + opts.opts.insert("allow_exotic_deps".to_string(), value); + NpmOptions::new(&opts).allow_exotic_deps() + } + + assert_eq!( + NpmOptions::new(&ToolVersionOptions::default()) + .allow_exotic_deps() + .unwrap(), + AllowExoticDeps::None + ); + assert_eq!( + parse(toml::Value::Boolean(true)).unwrap(), + AllowExoticDeps::All + ); + assert_eq!( + parse(toml::Value::String("true".into())).unwrap(), + AllowExoticDeps::All + ); + assert_eq!( + parse(toml::Value::String("false".into())).unwrap(), + AllowExoticDeps::None + ); + // Duplicates collapse and order is canonical, so the value written to + // `mise.lock` doesn't churn on a reordered config. + assert_eq!( + parse(toml::Value::Array(vec![ + toml::Value::String("xlsx".into()), + toml::Value::String("canvas".into()), + toml::Value::String("xlsx".into()), + ])) + .unwrap(), + AllowExoticDeps::Packages(vec!["canvas".into(), "xlsx".into()]) + ); + // An empty list is not a silent "allow everything". + assert_eq!( + parse(toml::Value::Array(vec![])).unwrap(), + AllowExoticDeps::None + ); + assert!(parse(toml::Value::Integer(1)).is_err()); + assert!(parse(toml::Value::Array(vec![toml::Value::Integer(1)])).is_err()); + } + + #[test] + fn test_allow_exotic_deps_lockfile_value_is_canonical() { + let mut opts = ToolVersionOptions::default(); + opts.opts.insert( + "allow_exotic_deps".to_string(), + toml::Value::Array(vec![ + toml::Value::String("xlsx".into()), + toml::Value::String("canvas".into()), + ]), + ); + assert_eq!( + NpmOptions::new(&opts) + .lockfile_options() + .get("allow_exotic_deps"), + Some(&"[\"canvas\", \"xlsx\"]".to_string()) + ); + } + #[test] fn test_allow_low_downloads_defaults_off_and_rejects_non_bool() { let empty = ToolVersionOptions::default(); diff --git a/src/cli/bootstrap.rs b/src/cli/bootstrap.rs index 41c57ccdb12..a16bccff19d 100644 --- a/src/cli/bootstrap.rs +++ b/src/cli/bootstrap.rs @@ -1877,7 +1877,7 @@ impl Bootstrap { // installations are not what was asked for if outcome.setup_held { bail!( - "the setup from {url} is paused; nothing was bootstrapped. `mise dot status` lists the paths that need attention; resolve them with `mise dot pull`, then run `mise bootstrap`" + "the setup from {url} is paused; nothing was bootstrapped. `mise dot status` lists the paths that need attention. Existing files that differ are kept until you decide: `mise dot pull --take-remote-all` chooses the repository's version for every conflict, or `mise dot pull --take-remote ` and `mise dot pull --keep-local ` decide one at a time. A path held for another reason, such as a directory where the repository has a file, says so in `mise dot status` and needs that fix instead. Then run `mise bootstrap`" ); } let config_dir = system::history::tracked::global_config_dir(); diff --git a/src/cli/dotfiles/pull.rs b/src/cli/dotfiles/pull.rs index d9c5ee62373..a3051f596bd 100644 --- a/src/cli/dotfiles/pull.rs +++ b/src/cli/dotfiles/pull.rs @@ -46,6 +46,22 @@ pub(crate) struct DotfilesPull { /// Resolve a conflict by keeping this machine's version (published next) #[usage(long, value_name = "PATH")] keep_local: Vec, + + /// Resolve every remaining conflict with the repository's version + /// + /// Paths named by --keep-local keep this machine's version; every other + /// conflict takes the repository's. Useful on a newly adopted machine, + /// where each pre-existing file that differs is a separate conflict. + #[usage(long, conflicts = "keep_local_all")] + take_remote_all: bool, + + /// Resolve every remaining conflict by keeping this machine's version + /// + /// Paths named by --take-remote take the repository's version; every + /// other conflict keeps this machine's. Each kept path must already be + /// saved. + #[usage(long, conflicts = "take_remote_all")] + keep_local_all: bool, } impl DotfilesPull { @@ -66,6 +82,8 @@ impl DotfilesPull { yes: self.yes, take_remote: self.take_remote.clone(), keep_local: self.keep_local.clone(), + take_remote_all: self.take_remote_all, + keep_local_all: self.keep_local_all, automatic: false, plan_only: false, }, @@ -82,5 +100,7 @@ static AFTER_LONG_HELP: &str = color_print::cstr!( $ mise dot pull --yes $ mise dot pull --take-remote ~/.zshrc $ mise dot pull --keep-local ~/.zshrc + $ mise dot pull --take-remote-all + $ mise dot pull --take-remote-all --keep-local ~/.zshrc "# ); diff --git a/src/system/history/sync/apply.rs b/src/system/history/sync/apply.rs index 404d8d2c32d..0cef26e4f96 100644 --- a/src/system/history/sync/apply.rs +++ b/src/system/history/sync/apply.rs @@ -35,6 +35,10 @@ pub(crate) struct ApplyRequest { pub take_remote: Vec, /// Resolve these conflicts by publishing the local version next. pub keep_local: Vec, + /// Resolve every conflict not named above with the upstream version. + pub take_remote_all: bool, + /// Resolve every conflict not named above by keeping the local version. + pub keep_local_all: bool, /// The watcher applying in the background: no prompt, no plan on /// stdout, and held paths are a count, not a failure. pub automatic: bool, @@ -51,6 +55,8 @@ impl ApplyRequest { yes: true, take_remote: vec![], keep_local: vec![], + take_remote_all: false, + keep_local_all: false, automatic: true, plan_only: false, } @@ -138,16 +144,43 @@ pub(crate) async fn apply_locked_with_scope( !req.automatic && console::user_attended_stderr(), )?; let roots = Roots::current(); - let take_remote: BTreeSet = req + let mut take_remote: BTreeSet = req .take_remote .iter() .map(|path| normalize_target(path)) .collect(); - let keep_local: BTreeSet = req + let mut keep_local: BTreeSet = req .keep_local .iter() .map(|path| normalize_target(path)) .collect(); + // Which conflicts a blanket choice spoke for, and why it could not speak + // for the rest. + let mut blanket_chosen: BTreeSet = BTreeSet::new(); + let mut blanket_held: Vec = vec![]; + // A blanket choice covers the conflicts nothing else decided, so + // `--take-remote-all --keep-local ` keeps that one exception. + if req.take_remote_all || req.keep_local_all { + let decided: BTreeSet = req + .take_remote + .iter() + .chain(req.keep_local.iter()) + .map(|path| normalize_target(path)) + .collect(); + for conflict in &status.conflicts { + if let Some(path) = roots.locate(&conflict.branch_path).path() + && !decided.contains(path) + { + blanket_chosen.insert(path.to_path_buf()); + } + } + let blanket = if req.take_remote_all { + &mut take_remote + } else { + &mut keep_local + }; + blanket.extend(blanket_chosen.iter().cloned()); + } // Store choices without publishing or applying any part of the setup. let mut sync_state = state::load(repo)?; @@ -165,7 +198,28 @@ pub(crate) async fn apply_locked_with_scope( if take_remote.contains(&local) && keep_local.contains(&local) { bail!("choose only one resolution for {}", display_path(&local)); } - let live = live_object(repo, &local)?; + // A blanket choice cannot speak for a path whose live side is not + // a file or symlink, and failing here would discard every other + // decision this pass makes. Hold that one path and keep going; a + // path named on the command line still reports the failure. The + // check belongs on this read, so a path that changed since the + // blanket choice was made is held too rather than aborting it. + // Only the path's own fault is held: a repository failure on an + // otherwise sound file is nobody's to fix by hand, so it stops + // the pass with its own error. + let live = match live_object(repo, &local) { + Ok(live) => live, + Err(err) + if blanket_chosen.contains(&local) + && err.downcast_ref::().is_some() => + { + blanket_held.push(err.to_string()); + take_remote.remove(&local); + keep_local.remove(&local); + continue; + } + Err(err) => return Err(err), + }; let saved = shared.get(&conflict.branch_path).cloned(); let saved_mode = permissions_at( repo, @@ -176,7 +230,32 @@ pub(crate) async fn apply_locked_with_scope( if keep_local.contains(&local) && (live != saved || live_permissions(&local)? != saved_mode) { - bail!("save {} before choosing --keep-local", display_path(&local)); + // Keeping the local side resolves the conflict by publishing + // this machine's *saved* version, so there must be one, and it + // must be the file as it stands now. A freshly adopted machine + // has no baseline at all; an edited file has a stale one. + let reason = if saved.is_none() { + "has no saved version on this machine yet" + } else { + "has unsaved changes" + }; + // The refusal stands either way, but as with an unusable live + // side a blanket choice holds just this path rather than + // discarding every decision it made alongside it. A path named + // on the command line is the user's own instruction, so it + // still fails the pass. + if blanket_chosen.contains(&local) { + blanket_held.push(format!( + "{path} {reason}, so run `mise dot save {path}` first", + path = display_path(&local) + )); + keep_local.remove(&local); + continue; + } + bail!( + "run `mise dot save {path}` first: --keep-local publishes this machine's saved version of {path}, and it {reason}", + path = display_path(&local) + ); } let remote = match status.upstream_commit.as_deref() { Some(head) => repo @@ -252,9 +331,27 @@ pub(crate) async fn apply_locked_with_scope( } table.print()?; } + // A blanket choice promises to decide every conflict, so it has not + // succeeded while one is still held -- whatever it decided alongside. + // Those decisions are recorded above; naming the rest is what is left. + if !blanket_held.is_empty() { + if !req.dry_run && !req.automatic { + bail!( + "sync paused: resolve all {count} conflict(s) before sharing resumes; a blanket choice cannot decide them all: {held}. Fix each of those paths, then pull again", + count = status.conflicts.len(), + held = blanket_held.join("; "), + ); + } + // A preview and a background pass both carry on, so this is the + // only chance either gets to say why a path went undecided. + warn!( + "a blanket choice cannot decide {held}", + held = blanket_held.join("; "), + ); + } if take_remote.is_empty() && keep_local.is_empty() && !req.dry_run && !req.automatic { bail!( - "sync paused: resolve all {} conflict(s) before sharing resumes", + "sync paused: resolve all {} conflict(s) before sharing resumes; `mise dot pull --take-remote-all` chooses the repository's version for every conflict at once, and `mise dot status` says whether any path still needs a different fix", status.conflicts.len() ); } @@ -817,6 +914,26 @@ pub(super) fn live_permissions(path: &Path) -> Result> { } } +/// The live side of a path cannot stand in for a shared object. The file is +/// the problem, not the machinery, so a blanket resolution may hold just this +/// path; every other failure is the repository's and must stop the pass. +#[derive(Debug)] +pub(super) struct UnusableLive(String); + +impl std::fmt::Display for UnusableLive { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for UnusableLive {} + +/// Each of these names the path, because a blanket resolution repeats the +/// reason it could not decide one and by then the path is all it still has. +fn unusable(path: &Path, what: impl std::fmt::Display) -> eyre::Report { + eyre::Report::new(UnusableLive(format!("{} {what}", display_path(path)))) +} + pub(super) fn live_object( repo: &crate::system::history::shadow::HistoryRepo, path: &Path, @@ -824,16 +941,18 @@ pub(super) fn live_object( let meta = match std::fs::symlink_metadata(path) { Ok(meta) => meta, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(err.into()), + Err(err) => return Err(unusable(path, format_args!("cannot be read: {err}"))), }; if meta.file_type().is_symlink() { + let target = std::fs::read_link(path) + .map_err(|err| unusable(path, format_args!("cannot be read: {err}")))?; return Ok(Some(( "120000".into(), - repo.transient_blob_id(std::fs::read_link(path)?.to_string_lossy().as_bytes())?, + repo.transient_blob_id(crate::system::history::shadow::path_bytes(&target).as_ref())?, ))); } if !meta.is_file() { - bail!("{} is not a regular file or symlink", display_path(path)); + return Err(unusable(path, "is not a regular file or symlink")); } #[cfg(unix)] let executable = { @@ -842,9 +961,11 @@ pub(super) fn live_object( }; #[cfg(not(unix))] let executable = false; + let contents = + std::fs::read(path).map_err(|err| unusable(path, format_args!("cannot be read: {err}")))?; Ok(Some(( if executable { "100755" } else { "100644" }.into(), - repo.transient_blob_id(&std::fs::read(path)?)?, + repo.transient_blob_id(&contents)?, ))) } @@ -954,7 +1075,7 @@ pub(crate) fn resolution_advice(path: &str, reason: &str) -> String { .into() } else { format!( - "inspect with `mise dot conflicts {path}`; resolve with `mise dot pull --take-remote|--keep-local {path}`" + "inspect with `mise dot conflicts {path}`; resolve with `mise dot pull --take-remote {path}` or `mise dot pull --keep-local {path}`" ) } } diff --git a/src/system/history/sync/onboard.rs b/src/system/history/sync/onboard.rs index 8fbe39b7c64..7c65a7f2bee 100644 --- a/src/system/history/sync/onboard.rs +++ b/src/system/history/sync/onboard.rs @@ -238,6 +238,31 @@ fn probe(store: &Store, fetch_from: &str, branch: &str) -> Result { Ok(state) } +/// How to clear the paths an adoption left undecided. +/// +/// A blanket choice records a decision for every conflict, which is all it +/// can promise. Whether each one then applies depends on preconditions checked +/// later — incoming TOML that parses, no directory where the repository has a +/// file, no staged git changes — and some of those blocked paths are conflicts +/// themselves (`InvalidIncoming`, `StagedEdits`). Their number is unknowable +/// here: when conflicts exist the apply stops before computing the other +/// holds, reporting the conflict count as its held count. So the wording +/// describes the decision it makes, and sends the reader to `mise dot status` +/// for whatever still needs a different fix. +fn undecided_advice(undecided: usize, conflicts: usize) -> String { + if undecided == 0 { + return String::new(); + } + let blanket = if conflicts > 0 { + ", `mise dot pull --take-remote-all` chooses the repository's version for every conflict at once" + } else { + "" + }; + format!( + "; {undecided} path(s) need a decision (`mise dot status` lists them and why, `mise dot pull --take-remote ` or `mise dot pull --keep-local ` decides one{blanket})" + ) +} + /// Sets this machine up from a repository already found to be /// history-managed: says what will happen, shows the plan, confirms, records /// the connection, and pulls (the configuration first, then what it @@ -396,10 +421,8 @@ pub(crate) async fn run(store: &Store, onboarding: &Onboarding) -> Result 0 @@ -411,13 +434,7 @@ pub(crate) async fn run(store: &Store, onboarding: &Onboarding) -> Result 0 { - format!( - "; {undecided} path(s) need a decision (`mise dot status` lists them, `mise dot pull --take-remote|--keep-local ` decides)" - ) - } else { - String::new() - } + undecided_advice(undecided, conflicts) ); let durable_access = durable_access(&onboarding.origin, &onboarding.branch).await; if !durable_access { @@ -548,6 +565,37 @@ mod preview_tests { use super::*; use crate::system::history::tracked::TrackedEntry; + #[test] + fn blanket_resolution_is_scoped_to_conflicts_and_never_promises_the_rest() { + // With conflicts present, the blanket command is worth offering, but + // only ever as covering the conflicting files. A path held for another + // reason is not cleared by it, and when conflicts exist the apply has + // not yet counted those holds, so the message must not imply a total. + for (undecided, conflicts) in [(3, 3), (3, 1)] { + let advice = undecided_advice(undecided, conflicts); + assert!(advice.contains(&format!("{undecided} path(s) need a decision"))); + assert!(advice.contains("--take-remote-all")); + // It promises a decision, never that every path then applies: + // `InvalidIncoming` and `StagedEdits` are conflicts a choice + // records but the apply still blocks. + assert!(advice.contains("chooses the repository's version for every conflict")); + assert!(!advice.contains("takes the repository's version")); + // the per-path commands stay, each separately runnable + assert!(advice.contains("`mise dot pull --take-remote `")); + assert!(advice.contains("`mise dot pull --keep-local `")); + // and status is what explains why each path is held + assert!(advice.contains("lists them and why")); + } + + // Nothing is a conflict: the blanket command would decide nothing. + let holds_only = undecided_advice(2, 0); + assert!(holds_only.contains("2 path(s) need a decision")); + assert!(!holds_only.contains("--take-remote-all")); + + // Nothing undecided: no advice to give. + assert_eq!(undecided_advice(0, 0), ""); + } + #[test] fn confirmed_fetch_reuses_objects_without_replacing_local_head_or_status() -> Result<()> { use crate::system::history::shadow::HistoryRepo; diff --git a/src/system/history/sync/origin.rs b/src/system/history/sync/origin.rs index 043778e3452..5b45c34f7a3 100644 --- a/src/system/history/sync/origin.rs +++ b/src/system/history/sync/origin.rs @@ -170,7 +170,7 @@ async fn set_inner( } } miseprintln!( - "Against the repository: {present} identical, {} differing (decided with `mise dot pull --take-remote|--keep-local`), {incoming} incoming to apply.", + "Against the repository: {present} identical, {} differing (decided with `mise dot pull --take-remote` or `--keep-local`), {incoming} incoming to apply.", differing.len() ); for path in differing.iter().take(10) { diff --git a/src/system/packages/brew/tap.rs b/src/system/packages/brew/tap.rs index 1f04c92a7fb..09368696520 100644 --- a/src/system/packages/brew/tap.rs +++ b/src/system/packages/brew/tap.rs @@ -625,6 +625,9 @@ class Widget < Formula depends_on "libfoo" depends_on "cmake" => :build depends_on(**{"ninja" => :build}) + depends_on :macos + depends_on :xcode => :build + depends_on macos: :sequoia on_sequoia :or_older do depends_on "release-boundary" end @@ -659,6 +662,9 @@ end formula.urls["stable"].url, "https://example.com/café/widget-1.2.3.tar.gz" ); + // Requirement symbols (`depends_on :macos`, `:xcode`, `macos: :sequoia`) name + // platform constraints, not formulae. Recording them would make the resolver + // fetch a formula called "macos" and fail the run on a 404. assert_eq!( formula.dependencies, ["libfoo", "release-boundary", "system-release-boundary"] diff --git a/src/system/packages/brew/tap_formula_metadata.rb b/src/system/packages/brew/tap_formula_metadata.rb index 7afe7ba851d..c6ee0f05cb1 100644 --- a/src/system/packages/brew/tap_formula_metadata.rb +++ b/src/system/packages/brew/tap_formula_metadata.rb @@ -130,6 +130,13 @@ def depends_on(spec = nil, **options) spec = options if spec.nil? && options.keys.any? { |key| key.is_a?(String) } name, kind = spec.is_a?(Hash) ? spec.first : [spec, nil] return if name.nil? + # A Symbol names a Homebrew REQUIREMENT, not an installable formula: + # `depends_on :macos`, `depends_on :linux`, `depends_on :xcode`, `depends_on :arm`. + # Recording one as a dependency makes the resolver look up a formula called + # "macos", which does not exist, and the 404 aborts the whole packages run. + # (`depends_on macos: :tahoe` already falls out at the `name.nil?` guard above, + # because its lone key is a Symbol rather than a String.) + return if name.is_a?(Symbol) kinds = Array(kind) return if kind == :test || (!kinds.empty? && kinds.all? { |value| value == :test }) target = kind == :build || kinds.include?(:build) ? @build_dependencies : @runtime_dependencies