diff --git a/.gitignore b/.gitignore index 1578d8a..e288043 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,4 @@ rust-project.json # End of https://www.toptal.com/developers/gitignore/api/linux,rust,rust-analyzer,jetbrains+all .php-version +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..53a75cd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,89 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Critical Safety Rule + +**Never use backticks (`` ` ``) inside `git commit -m` messages.** Backticks are evaluated as command substitution by the user's shell before git sees them, executing arbitrary code under their privileges. Use single or double quotes to wrap code/file references in commit bodies. This applies to all commits, PRs, and any string passed to a shell. + +## Commands + +Toolchain is pinned in `rust-toolchain.toml` (currently Rust 1.97.0, edition 2024) with `clippy` and `rustfmt`; Renovate bumps it, so check the file rather than assuming. + +- Build release binary: `cargo build --release` (size-optimized: `opt-level = "z"`, LTO, `panic = "abort"`, stripped) +- Run from source: `cargo run -- ` +- All tests: `cargo test` +- Single test: `cargo test test_use_silent_export` (or any function name in `tests/cli.rs` / `src/**/tests`) +- Lint (CI gate): `cargo clippy -- -D warnings` +- Format check (CI gate): `cargo fmt -- --check` +- Local install from source: `./build.sh` (compiles release, copies to `$PVM_DIR/bin/pvm`) + +CI lives in `.github/workflows/release.yml` as a chained pipeline: `tests` (fmt + clippy + `cargo test`) → `e2e tests - linux` (`tests/e2e/run.sh`) → `release`; failures block merge. New CI gates belong inside `release.yml` in that chain, not as standalone workflow files. Releases are driven by `semantic-release` from Conventional Commits on `main` — `Cargo.toml` version and `CHANGELOG.md` are bumped automatically, so do not hand-edit them. + +## Architecture + +PVM is a single-binary Rust CLI that downloads pre-built static PHP binaries from `dl.static-php.dev` and switches `PATH` per-shell. Three concerns drive the design: + +### 1. Command dispatch (`src/cli.rs` → `src/commands/*`) + +`Cli` is a `clap` `Parser` with an `Option` subcommand. If absent, `main.rs` falls through to `interactive::run_root_menu()` (a `dialoguer` TUI). Every subcommand is its own struct in `src/commands/.rs` with `#[derive(Parser, Debug)]` and an async `call(self) -> Result<()>` method. To add a subcommand: create the module, add it to `src/commands/mod.rs`, register the variant in the `Commands` enum in `src/cli.rs`, add the dispatch arm in `Commands::call`, and (if menu-worthy) extend `interactive.rs` — its match arms are index-based, so renumber everything after the insertion point. + +Non-interactive behavior is a hard requirement: every command must work without a terminal. Yes/no questions go through `prompt::confirm(prompt, default, assume_yes)` (`src/prompt.rs`), which auto-answers with `default` on a non-TTY stdin and short-circuits on `--yes`. Pickers (`dialoguer::Select`) are guarded by `std::io::IsTerminal` checks that bail with a usage hint or fall back to plain output (`ls`, `ls-remote`). The patch-update offer in `use` runs only on a TTY so scripts never trigger downloads. + +### 2. Filesystem layout (`src/fs.rs`, `src/constants.rs`) + +Rooted at `$PVM_DIR` (defaults to `dirs::data_local_dir()/pvm`, i.e. `~/.local/share/pvm` on Linux). Layout: + +- `$PVM_DIR/bin/pvm` — the binary itself +- `$PVM_DIR/versions//bin/{php,php-fpm,micro.sfx}` — installed PHP binaries; presence of each file determines which packages are "installed" (`fs::get_installed_packages`) +- `$PVM_DIR/remote_cache-.json` — 24h cache of the remote version index (per target triple), file-locked on read/write; `pvm cache clear` deletes it +- `$PVM_DIR/default` — version activated by `pvm env` in every new shell (`pvm default`); `prune` re-points it when it removes the referenced patch +- `$PVM_DIR/.update_check_guard` — 24h throttle for the "newer patch available" check (`src/update.rs`); its presence suppresses update prompts, which matters when testing update flows +- `$PVM_DIR/.env_update__` — short-lived file the shell wrapper evals to mutate the parent shell + +Version resolution is two-staged: `network::resolve_version` matches a user input like `8.4` against the remote list (latest patch wins), `fs::resolve_local_version` does the same against installed versions. `latest` is a special alias on both sides. + +### 3. Shell integration (`src/shell.rs`, `src/commands/env.rs`) + +The hard part: a child process cannot mutate its parent shell's environment. PVM solves this with `pvm env` + a wrapper function: + +1. The user evaluates `pvm env` in their rc file, which prints a `pvm()` shell function plus a `cd` hook. +2. When the user runs `pvm use 8.4`, the wrapper exports `PVM_ENV_UPDATE_PATH=` before invoking the real `pvm` binary. +3. The Rust binary writes `export PATH=...; export PVM_MULTISHELL_PATH=...` to that tmpfile (with an exclusive file lock — see `fs::write_env_file_locked`). +4. The wrapper `eval`s the tmpfile and removes it. + +The `Shell` trait (`Bash`, `Zsh`, `Fish`) emits the syntax for each shell; `detect_shell()` reads `$SHELL`. Bash and Zsh share the `posix_*` free functions; only `use_on_cd` differs. The `cd` hook reads `.php-version` and calls `pvm use --silent` automatically. `deactivate()` (used by `pvm use system`) writes an env-file snippet that clears `PVM_MULTISHELL_PATH` and filters `$PVM_DIR/versions` entries out of PATH. + +The activation tail (patch-update offer, `.php-version` prompts, env-file write) lives in `use_cmd::activate(version, ActivateOpts)`; both `pvm use` and the interactive `pvm ls` picker call it. `ActivateOpts.offer_save` distinguishes picker flows (offer to write `.php-version`) from explicit-argument flows (offer to update an existing differing file). + +Concurrency: every shell session uses its own tmpfile keyed on PID, and the cache + env files are taken under OS file locks (std `File::lock`/`lock_shared` — the `fs4` crate was dropped) so parallel `pvm` invocations don't corrupt state. + +### 4. Networking (`src/network.rs`) + +Single endpoint: `https://dl.static-php.dev/static-php-cli/bulk/?format=json` returns the file index. `get_target_triple()` filters by `{linux,macos}-{x86_64,aarch64}`; package suffixes `cli`/`fpm`/`micro` are parsed from filenames like `php-8.4.18-cli-linux-x86_64.tar.gz`. Downloads stream via `reqwest` with an `indicatif` progress bar, then untar into `$PVM_DIR/versions//bin/` and `chmod 0755` on Unix. Other platforms/architectures are unsupported and will bail. + +## Conventions + +- All fallible command-level functions return `anyhow::Result`. +- User-visible status uses `colored` icons: `✓` (green = success), `✗` (red = error), `↻` (blue = in-progress), `💡` (yellow = hint). +- Async runtime is `tokio` with `features = ["full"]`; all network I/O uses `reqwest`. +- Interactive prompts are `dialoguer` (`Select`, `MultiSelect`, `Confirm`) with `ColorfulTheme::default()`. +- File writes that other processes may read concurrently MUST take an OS file lock via std `File::lock` — follow the pattern in `fs::write_env_file_locked` (cache reads use `lock_shared`, see `src/network.rs`). +- Commits follow Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, …); the `feat:`/`fix:` prefixes determine version bumps via semantic-release. + +## Testing + +- Integration tests live in `tests/cli.rs` and use `assert_cmd` + `predicates` to invoke the compiled binary. +- Every test that touches the filesystem MUST call `tempfile::tempdir()` and pass its path via `cmd.env("PVM_DIR", ...)`. Never assume the host's real `~/.local/share/pvm` is empty or writable. +- Network-dependent paths are tested offline via the helpers at the top of `tests/cli.rs`: `seed_remote_cache()` pre-fills `remote_cache-.json` with fake versions, `seed_installed_version()` fakes `versions//bin` layouts. Tests that must not see a pvm-active shell use `cmd.env_remove("PVM_MULTISHELL_PATH")`. +- Unit tests inside `src/**` that mutate `std::env` use a `static Mutex<()>` guard (see `src/fs.rs::tests::ENV_LOCK`) — `cargo test` runs in parallel by default and concurrent `set_var` is unsound. +- The build script (`build.rs`) embeds version + git commit + build time into `PVM_VERSION`; it reruns when `.git/HEAD`, refs, or `Cargo.toml` change. Tests that assert on `--version` output should not hardcode the value. + +### E2E suite (`tests/e2e/`) + +Bash-based Linux end-to-end tests: `run.sh` is the driver, one case per file in `cases/NN_*.sh` (install → use → FPM config/run/FastCGI → uninstall), shared helpers in `_lib.sh`. Each case runs as a fresh bash subprocess so state can't mask bugs (e.g. `.update_check_guard` suppressing later prompts). Key points: + +- The driver mutates `$HOME/.local/share/pvm` and refuses to run outside a sandbox (container or `GITHUB_ACTIONS=true`); `PVM_E2E_FORCE=1` overrides — don't use it on a dev machine. +- Run locally via Docker: `docker build -t pvm-e2e tests/e2e`, then mount `tests/e2e` + `target/release/pvm` and set `PVM_BIN` (full commands in `tests/e2e/README.md`). +- `PVM_E2E_ONLY="07_patch_update.sh"` runs a single case; cases 09–13 depend on state from 08. +- `PVM_VERSION_MAJOR_MINOR` (default `8.5`) picks the PHP line; both latest and previous patches must exist upstream. diff --git a/Cargo.lock b/Cargo.lock index e5c5328..5d69351 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,15 +17,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -78,9 +69,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "assert_cmd" @@ -111,9 +102,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" dependencies = [ "aws-lc-sys", "zeroize", @@ -121,14 +112,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -139,9 +131,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -154,13 +146,13 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] @@ -171,15 +163,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.64" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -195,28 +187,26 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] -name = "chrono" -version = "0.4.45" +name = "chacha20" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", + "cfg-if", + "cpufeatures", + "rand_core", ] [[package]] name = "clap" -version = "4.6.1" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", "clap_derive", @@ -224,9 +214,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -288,9 +278,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -439,29 +429,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "env_filter" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -525,12 +492,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -621,36 +582,23 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.4" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 5.3.0", - "wasip2", + "r-efi", + "rand_core", "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -665,15 +613,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -698,9 +637,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -708,9 +647,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -727,9 +666,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -795,30 +734,6 @@ dependencies = [ "windows-registry", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.2.0" @@ -901,12 +816,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "idna" version = "1.1.0" @@ -935,16 +844,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", + "hashbrown", ] [[package]] name = "indicatif" -version = "0.18.4" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ "console", "portable-atomic", @@ -971,30 +878,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jiff" -version = "0.2.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" -dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-static" -version = "0.2.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "jni" version = "0.22.4" @@ -1046,31 +929,25 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.100" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", "wasm-bindgen", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -1079,9 +956,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -1098,20 +975,11 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru-slab" @@ -1121,9 +989,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -1143,9 +1011,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -1191,29 +1059,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1226,16 +1071,12 @@ version = "1.3.2" dependencies = [ "anyhow", "assert_cmd", - "chrono", "clap", "colored", "dialoguer", "dirs", - "env_logger", "flate2", - "futures-util", "indicatif", - "log", "predicates", "reqwest", "semver", @@ -1254,19 +1095,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "portable-atomic" -version = "1.13.1" +name = "pkg-config" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] -name = "portable-atomic-util" -version = "0.2.7" +name = "portable-atomic" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" -dependencies = [ - "portable-atomic", -] +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "potential_utf" @@ -1277,15 +1115,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "predicates" version = "3.1.4" @@ -1316,16 +1145,6 @@ dependencies = [ "termtree", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -1337,9 +1156,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -1357,15 +1176,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", "rand", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -1379,33 +1199,27 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1414,40 +1228,28 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "ppv-lite86", + "chacha20", + "getrandom 0.4.3", "rand_core", ] [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "bitflags", + "rand_core", ] [[package]] @@ -1463,9 +1265,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1475,9 +1277,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -1549,9 +1351,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -1577,9 +1379,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "aws-lc-rs", "once_cell", @@ -1603,9 +1405,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -1652,9 +1454,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -1674,12 +1476,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "security-framework" version = "3.7.0" @@ -1775,27 +1571,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -1821,9 +1607,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1849,9 +1635,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -1917,7 +1703,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1961,9 +1747,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -1976,16 +1762,14 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -1993,9 +1777,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", @@ -2113,12 +1897,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unit-prefix" version = "0.5.2" @@ -2189,29 +1967,11 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - [[package]] name = "wasm-bindgen" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -2222,9 +1982,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.73" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -2232,9 +1992,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2242,9 +2002,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -2255,35 +2015,13 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -2297,23 +2035,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.100" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -2331,9 +2057,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -2347,41 +2073,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" @@ -2423,16 +2114,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2450,31 +2132,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -2483,190 +2148,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -2706,26 +2229,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -2788,6 +2291,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 404766b..81b3665 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,11 +16,8 @@ clap = { version = "4.6.1", features = ["derive"] } colored = "3.1.1" dialoguer = "0.12.0" dirs = "6.0.0" -env_logger = "0.11.10" flate2 = "1.1.9" -futures-util = "0.3.32" indicatif = "0.18.4" -log = "0.4.29" reqwest = { version = "0.13.3", features = ["stream", "rustls", "json"] } semver = "1.0.28" serde = { version = "1.0.228", features = ["derive"] } @@ -28,16 +25,14 @@ sha2 = "0.11.0" serde_json = "1.0.149" tar = "0.4.45" tempfile = "3.27.0" -tokio = { version = "1.52.1", features = ["full"] } +tokio = { version = "1.52.1", features = ["macros", "rt-multi-thread"] } [dev-dependencies] assert_cmd = "2.2.1" predicates = "3.1.4" +serde_json = "1.0.149" tempfile = "3.27.0" -[build-dependencies] -chrono = "0.4.44" - # Optimize for binary size and performance [profile.release] opt-level = "z" # Optimize for size diff --git a/README.md b/README.md index 97614cb..5618165 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,15 @@ PVM uses pre-compiled static PHP CLI binaries from [Static PHP CLI (SPC)](https: - 📦 **Static Binaries**: No compilation needed. The `pvm install` command instantly downloads self-contained executables with most common extensions pre-baked. - 🧩 **Multi-Package Selection**: Pick which packages to install per version — `cli`, `fpm`, and/or `micro` (`micro.sfx`) — via an interactive MultiSelect prompt. `cli` is the default. - 🐘 **Native Composer Support**: Works out of the box with your system's global Composer without any explicit proxy or configuration. -- 🖱️ **Interactive TUI Menus**: Run `pvm` without arguments to launch a master selection menu. Or run commands like `pvm use` / `pvm ls-remote` / `pvm uninstall` without parameters to select actions via a visual UI. +- 🖱️ **Interactive TUI Menus**: Run `pvm` without arguments to launch a master selection menu. Or run commands like `pvm use` / `pvm ls` / `pvm install` / `pvm uninstall` without parameters to select actions via a visual UI. +- 📌 **Persistent Default**: `pvm default 8.4` makes a version survive new terminals — every fresh shell starts on it via `pvm env`. `pvm use system` switches back to the system PHP anytime. - 🏷️ **Smart Aliasing**: Install and use patches cleanly by saying `pvm install 8.4`. PVM dynamically figures out the highest patch (`8.4.18`) underneath the hood. -- 🔄 **Patch Update Check**: `pvm use 8.4` notices when a newer patch (e.g. `8.4.19`) is available and offers to install and switch in one step. -- 📝 **`.php-version` Bootstrap**: `pvm init` interactively picks a major.minor and writes `.php-version` for the current directory. +- 🔄 **Patch Update Check**: `pvm use 8.4` notices when a newer patch (e.g. `8.4.19`) is available and offers to install and switch in one step (interactive shells only — scripts never trigger surprise downloads). `pvm prune` cleans up superseded patches afterwards. +- 🤖 **Script-Friendly**: every command works without a terminal — `pvm install 8.4 --packages cli,fpm -y` installs non-interactively, `pvm ls` / `pvm ls-remote` print plain lists when piped, prompts fall back to sane defaults. +- 📝 **`.php-version` Bootstrap**: `pvm init` interactively picks a major.minor and writes `.php-version` for the current directory; picker-driven switches offer to save your choice there too. - 🗑️ **Clean Uninstall**: `pvm uninstall` (alias `rm` / `remove`) removes a version's binaries; warns when removing the active one. -- 🐚 **Multi-Shell**: Bash, Zsh, and Fish wrappers generated by `pvm env`, with concurrency-safe per-PID env files locked via `fs4`. -- ⚡ **Cached Cloud Resolution**: Quickly check for new versions on `dl.static-php.dev` under lightning-fast 24-hour JSON caching. +- 🐚 **Multi-Shell**: Bash, Zsh, and Fish wrappers generated by `pvm env`, with concurrency-safe per-PID env files under OS file locks. +- ⚡ **Cached Cloud Resolution**: Quickly check for new versions on `dl.static-php.dev` under lightning-fast 24-hour JSON caching (`pvm cache clear` forces a refresh). ## Installation @@ -50,8 +52,12 @@ pvm # Install a specific PHP version (by minor alias or fully-qualified). # Opens a MultiSelect to pick packages: cli (default), fpm, micro. +# Without a version argument, opens an interactive remote-version picker. pvm install 8.4 # alias: pvm i 8.4 +# Non-interactive install for scripts/CI: pick packages up front, skip prompts. +pvm install 8.4 --packages cli,fpm -y + # Install the absolute latest version available pvm install latest @@ -59,21 +65,43 @@ pvm install latest # Auto-prompts to install + switch if a newer patch exists upstream. pvm use 8.4 -# List all local installed versions alongside their specific aliases +# Switch this shell back to the system PHP +pvm use system + +# Make a version the default for every new shell (cleared via: pvm default system) +pvm default 8.4 + +# List installed versions. On a terminal this is a picker: Enter switches +# (and offers to save the choice to .php-version), Esc just exits. +# When piped it prints a plain list. pvm ls # alias for: pvm list -# Interactively view and install available cloud versions +# List available cloud versions (plain list, script-friendly) pvm ls-remote # alias: pvm list-remote # Print the currently active PHP version pvm current +# Print the path of the active (or a given) php binary +pvm which +pvm which 8.3 + +# Run a single command under a specific version without switching the shell +pvm exec 8.3 php -v +pvm exec 8.3 composer install + # Remove an installed version (interactive picker if no arg). pvm uninstall 8.3 # aliases: pvm rm 8.3 / pvm remove 8.3 +# Remove every patch that is no longer the newest of its minor line +pvm prune # add -y to skip the confirmation + # Write a .php-version file for this directory (interactive picker) pvm init +# Drop the 24h remote-version cache (e.g. right after an upstream release) +pvm cache clear + # Check for and apply updates to pvm itself pvm self-update # optional: pvm self-update --apply to apply automatically ``` diff --git a/build.rs b/build.rs index 2e57a8b..e38aa33 100644 --- a/build.rs +++ b/build.rs @@ -1,5 +1,16 @@ use std::process::Command; +fn cmd_stdout(cmd: &str, args: &[&str]) -> Option { + Command::new(cmd) + .args(args) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + fn main() { println!("cargo:rerun-if-changed=.git/HEAD"); if let Ok(head) = std::fs::read_to_string(".git/HEAD") @@ -12,32 +23,19 @@ fn main() { println!("cargo:rerun-if-env-changed=CI"); println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION"); - let commit_hash = Command::new("git") - .args(["rev-parse", "--short", "HEAD"]) - .output() - .ok() - .filter(|output| output.status.success()) - .and_then(|output| String::from_utf8(output.stdout).ok()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "unknown".to_string()); - - let build_time = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + // Only Linux/macOS are supported targets, so `date -u` is always available. + let build_time = + cmd_stdout("date", &["-u", "+%Y-%m-%dT%H:%M:%SZ"]).unwrap_or_else(|| "unknown".to_string()); let is_ci = std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok(); let version = if is_ci { - let tag = Command::new("git") - .args(["describe", "--tags", "--always"]) - .output() - .ok() - .filter(|output| output.status.success()) - .and_then(|output| String::from_utf8(output.stdout).ok()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) + let tag = cmd_stdout("git", &["describe", "--tags", "--always"]) .unwrap_or_else(|| "unknown".to_string()); format!("{} (built at: {})", tag, build_time) } else { + let commit_hash = cmd_stdout("git", &["rev-parse", "--short", "HEAD"]) + .unwrap_or_else(|| "unknown".to_string()); let pkg_version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string()); format!( diff --git a/src/cli.rs b/src/cli.rs index 77f84a8..43a5529 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -33,6 +33,10 @@ pub enum Commands { #[clap(name = "env")] Env(commands::env::Env), + /// Run a command under a specific PHP version + #[clap(name = "exec")] + Exec(commands::exec_cmd::Exec), + /// List all locally installed PHP versions #[clap(name = "list", visible_aliases = &["ls"])] Ls(commands::ls::Ls), @@ -45,10 +49,18 @@ pub enum Commands { #[clap(name = "current")] Current(commands::current::Current), + /// Set the default PHP version for new shells + #[clap(name = "default")] + Default(commands::default_cmd::DefaultCmd), + /// Uninstall a PHP version #[clap(name = "uninstall", visible_aliases = &["rm", "remove"])] Uninstall(commands::uninstall::Uninstall), + /// Remove superseded patch versions + #[clap(name = "prune")] + Prune(commands::prune::Prune), + /// Initialize a .php-version file in the current directory #[clap(name = "init")] Init(commands::init::Init), @@ -56,6 +68,14 @@ pub enum Commands { /// Check for and apply updates to pvm itself #[clap(name = "self-update")] SelfUpdate(commands::self_update::SelfUpdate), + + /// Manage the remote version cache + #[clap(name = "cache")] + Cache(commands::cache::Cache), + + /// Print the path of the active or given PHP binary + #[clap(name = "which")] + Which(commands::which_cmd::Which), } impl Commands { @@ -64,12 +84,17 @@ impl Commands { Self::Install(cmd) => cmd.call().await, Self::Use(cmd) => cmd.call().await, Self::Env(cmd) => cmd.call().await, + Self::Exec(cmd) => cmd.call().await, Self::Ls(cmd) => cmd.call().await, Self::LsRemote(cmd) => cmd.call().await, Self::Current(cmd) => cmd.call().await, + Self::Default(cmd) => cmd.call().await, Self::Uninstall(cmd) => cmd.call().await, + Self::Prune(cmd) => cmd.call().await, Self::Init(cmd) => cmd.call().await, Self::SelfUpdate(cmd) => cmd.call().await, + Self::Cache(cmd) => cmd.call().await, + Self::Which(cmd) => cmd.call().await, } } } diff --git a/src/commands/cache.rs b/src/commands/cache.rs new file mode 100644 index 0000000..fd344ca --- /dev/null +++ b/src/commands/cache.rs @@ -0,0 +1,65 @@ +use crate::fs; +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use colored::Colorize; + +/// Manage the remote version cache +#[derive(Parser, Debug)] +pub struct Cache { + #[command(subcommand)] + action: CacheAction, +} + +#[derive(Subcommand, Debug)] +enum CacheAction { + /// Delete the cached remote version index + Clear, +} + +impl Cache { + pub async fn call(self) -> Result<()> { + match self.action { + CacheAction::Clear => clear(), + } + } +} + +fn clear() -> Result<()> { + let pvm_dir = fs::get_pvm_dir()?; + let mut removed: usize = 0; + + match std::fs::read_dir(&pvm_dir) { + Ok(entries) => { + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if name.starts_with("remote_cache") + && name.ends_with(".json") + && entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) + { + std::fs::remove_file(entry.path()) + .with_context(|| format!("Failed to remove cache file '{}'", name))?; + removed += 1; + } + } + } + // No PVM dir yet means there is nothing cached — that is fine. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(e).context(format!( + "Failed to read PVM directory '{}'", + pvm_dir.display() + )); + } + } + + println!( + "{} Removed {} cached remote version index file{}", + "✓".green(), + removed, + if removed == 1 { "" } else { "s" } + ); + Ok(()) +} diff --git a/src/commands/default_cmd.rs b/src/commands/default_cmd.rs new file mode 100644 index 0000000..dc3789d --- /dev/null +++ b/src/commands/default_cmd.rs @@ -0,0 +1,78 @@ +use crate::fs; +use anyhow::Result; +use clap::Parser; +use colored::Colorize; +use dialoguer::{Select, theme::ColorfulTheme}; +use std::io::IsTerminal; + +/// Set the default PHP version for new shells (evaluated by 'pvm env') +#[derive(Parser, Debug)] +pub struct DefaultCmd { + /// The version to use as default, "system" to clear (omit for interactive list) + pub version: Option, +} + +impl DefaultCmd { + pub async fn call(self) -> Result<()> { + let version = match self.version { + Some(ref v) if v == "system" => { + fs::clear_default_version()?; + println!( + "{} Default cleared. New shells start on the system PHP.", + "✓".green() + ); + return Ok(()); + } + Some(ref v) => fs::resolve_local_version(v)?, + None => { + if !std::io::stdin().is_terminal() { + match fs::get_default_version()? { + Some(v) => println!("{}", v), + None => println!("none"), + } + return Ok(()); + } + + let items = fs::get_aliased_versions()?; + if items.is_empty() { + eprintln!("{} No PHP versions are currently installed.", "💡".yellow()); + return Ok(()); + } + + let current_default = fs::get_default_version()?.unwrap_or_default(); + let displays: Vec = items + .iter() + .map(|i| { + if i.version == current_default { + format!("{} {}", i.display, "(default)".cyan()) + } else { + i.display.clone() + } + }) + .collect(); + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Select the default PHP version for new shells") + .default(0) + .items(&displays) + .interact_opt()?; + + match selection { + Some(idx) => items[idx].version.clone(), + None => { + eprintln!("{} Operation cancelled.", "✗".red()); + return Ok(()); + } + } + } + }; + + fs::set_default_version(&version)?; + println!( + "{} Default version set to {}. New shells pick it up via 'pvm env'.", + "✓".green(), + version.bold() + ); + Ok(()) + } +} diff --git a/src/commands/env.rs b/src/commands/env.rs index 913d72d..bad1104 100644 --- a/src/commands/env.rs +++ b/src/commands/env.rs @@ -1,4 +1,4 @@ -use crate::constants::PVM_DIR_VAR; +use crate::constants::{MULTISHELL_PATH_VAR, PVM_DIR_VAR}; use crate::shell; use anyhow::Result; use clap::Parser; @@ -31,6 +31,25 @@ impl Env { println!("{}", s.wrapper_fn()); println!("{}", s.use_on_cd()); + // Activate the persisted default version ('pvm default') so every new + // shell starts on it instead of the system PHP. + if let Some(version) = crate::fs::get_default_version()? { + if crate::fs::is_version_installed(&version)? { + let bin_dir = crate::fs::get_version_bin_dir(&version)?; + println!( + "{}", + s.set_env_var(MULTISHELL_PATH_VAR, &bin_dir.to_string_lossy()) + ); + println!("{}", s.path(&bin_dir)); + } else { + // stderr so the eval'd stdout stays clean. + eprintln!( + "pvm: default version {} is not installed; run 'pvm default' to fix", + version + ); + } + } + Ok(()) } } diff --git a/src/commands/exec_cmd.rs b/src/commands/exec_cmd.rs new file mode 100644 index 0000000..788bfa5 --- /dev/null +++ b/src/commands/exec_cmd.rs @@ -0,0 +1,45 @@ +use crate::fs; +use anyhow::{Context, Result}; +use clap::Parser; + +/// Run a command under a specific PHP version +#[derive(Parser, Debug)] +pub struct Exec { + /// The PHP version to run under + version: String, + + /// The command and arguments to execute + #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)] + command: Vec, +} + +impl Exec { + pub async fn call(self) -> Result<()> { + let version = fs::resolve_local_version(&self.version)?; + let bin_dir = fs::get_version_bin_dir(&version)?; + + // Prepend the version's bin dir so its binaries shadow any other PHP + // already on PATH, for this single child process only. + let existing = std::env::var_os("PATH").unwrap_or_default(); + let paths = std::iter::once(bin_dir).chain(std::env::split_paths(&existing)); + let new_path = std::env::join_paths(paths).context("Failed to construct PATH")?; + + let status = std::process::Command::new(&self.command[0]) + .args(&self.command[1..]) + .env("PATH", new_path) + .status() + .with_context(|| format!("Failed to execute '{}'", self.command[0]))?; + + #[cfg(unix)] + let code = { + use std::os::unix::process::ExitStatusExt; + // Mirror the shell convention for signal-terminated children. + status + .code() + .unwrap_or_else(|| 128 + status.signal().unwrap_or(1)) + }; + #[cfg(not(unix))] + let code = status.code().unwrap_or(1); + std::process::exit(code); + } +} diff --git a/src/commands/init.rs b/src/commands/init.rs index 984511a..ca43962 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -1,9 +1,12 @@ -use crate::network; +use crate::constants::PHP_VERSION_FILE; +use crate::{fs, network, prompt}; use anyhow::Result; use clap::Parser; use colored::Colorize; -use dialoguer::{Confirm, Select, theme::ColorfulTheme}; +use dialoguer::{Select, theme::ColorfulTheme}; use std::collections::HashSet; +use std::io::IsTerminal; +use std::path::Path; /// Initialize a .php-version file in the current directory #[derive(Parser, Debug)] @@ -11,22 +14,63 @@ pub struct Init; impl Init { pub async fn call(self) -> Result<()> { - println!("{} Fetching remotely available PHP versions...", "↻".blue()); - let all_versions = network::get_available_versions().await?; + // The whole flow is interactive (overwrite confirm + picker), so + // fail early with a hint instead of a cryptic dialoguer error. + if !std::io::stdin().is_terminal() { + anyhow::bail!( + "pvm init requires a terminal; write {} yourself instead.", + PHP_VERSION_FILE + ); + } + + if Path::new(PHP_VERSION_FILE).exists() { + let existing = std::fs::read_to_string(PHP_VERSION_FILE)?; + let question = format!( + "A {} file already exists ({}). Overwrite?", + PHP_VERSION_FILE, + existing.trim().yellow() + ); + if !prompt::confirm(&question, false, false)? { + println!("{} Operation cancelled.", "✗".red()); + return Ok(()); + } + } + + // Distinct major.minor lines: locally installed first (newest first, + // marked), then the remote-only ones (newest first). + let mut seen = HashSet::new(); + let mut options = Vec::new(); // what gets written to .php-version + let mut displays = Vec::new(); // what the picker shows - // Extract just the major.minor (e.g., "8.4" or "7.4") - let mut major_minors = HashSet::new(); - let mut options = Vec::new(); + for v in fs::list_installed_versions()?.iter().rev() { + if let Some(mm) = fs::minor_of(v) + && seen.insert(mm.clone()) + { + displays.push(format!("{} {}", mm, "(installed)".green())); + options.push(mm); + } + } - for (v, _) in all_versions.iter().rev() { - // Start from newest - let parts: Vec<&str> = v.split('.').collect(); - if parts.len() >= 2 { - let mm = format!("{}.{}", parts[0], parts[1]); - if major_minors.insert(mm.clone()) { - options.push(mm); + println!("{} Fetching remotely available PHP versions...", "↻".blue()); + match network::get_available_versions().await { + Ok(remote) => { + for (v, _) in remote.iter().rev() { + // Start from newest + if let Some(mm) = fs::minor_of(v) + && seen.insert(mm.clone()) + { + displays.push(mm.clone()); + options.push(mm); + } } } + Err(e) => { + println!( + "{} Could not fetch remote versions ({}). Showing installed versions only.", + "💡".yellow(), + e + ); + } } if options.is_empty() { @@ -36,7 +80,7 @@ impl Init { let selection = Select::with_theme(&ColorfulTheme::default()) .with_prompt("Select a PHP version for this directory") .default(0) - .items(&options) + .items(&displays) .interact_opt()?; let selected = match selection { @@ -47,19 +91,21 @@ impl Init { } }; - std::fs::write(".php-version", selected)?; - println!("{} Wrote {} to .php-version", "✓".green(), selected.bold()); + std::fs::write(PHP_VERSION_FILE, selected)?; + println!( + "{} Wrote {} to {}", + "✓".green(), + selected.bold(), + PHP_VERSION_FILE + ); - if Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(format!("Do you want to run `pvm use {}` now?", selected)) - .default(true) - .interact_opt()? - .unwrap_or(false) - { + let question = format!("Do you want to run 'pvm use {}' now?", selected); + if prompt::confirm(&question, true, false)? { // Call use programmatically let use_cmd = crate::commands::use_cmd::Use { version: Some(selected.clone()), silent: false, + yes: false, }; use_cmd.call().await?; } diff --git a/src/commands/install.rs b/src/commands/install.rs index dda3152..aef3c37 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -3,24 +3,33 @@ use crate::{fs, network}; use anyhow::Result; use clap::Parser; use colored::Colorize; +use dialoguer::{Select, theme::ColorfulTheme}; +use std::io::IsTerminal; /// Install a specific PHP version #[derive(Parser, Debug)] pub struct Install { /// The version to install, or "latest" pub version: Option, -} -pub async fn execute_install(version: &str) -> Result<()> { - execute_install_with(version, true).await.map(|_| ()) + /// Packages to install without prompting (comma-separated: cli,fpm,micro) + #[arg(long, value_delimiter = ',')] + pub packages: Vec, + + /// Auto-approve prompts + #[arg(short = 'y', long = "yes")] + pub yes: bool, } /// `prompt_activation` controls the trailing "Do you want to use PHP X now?" prompt and the /// resulting env-file write. Callers like `pvm use` set it to `false` because they will fall /// through to their own activation path with the returned resolved version. +/// `packages` skips the interactive package selection; `assume_yes` auto-approves prompts. pub async fn execute_install_with( version: &str, prompt_activation: bool, + packages: &[String], + assume_yes: bool, ) -> Result> { let versions_dir = fs::get_versions_dir()?; std::fs::create_dir_all(&versions_dir)?; @@ -43,30 +52,35 @@ pub async fn execute_install_with( anyhow::bail!("No packages found for PHP {}", resolved_version); } - let theme = dialoguer::theme::ColorfulTheme::default(); - let selections = dialoguer::MultiSelect::with_theme(&theme) - .with_prompt(format!( - "Select packages to install for PHP {}", - resolved_version - )) - .items(&available_packages) - .defaults( - &available_packages - .iter() - .map(|p| p == "cli") - .collect::>(), - ) - .interact()?; + let already_installed = fs::get_installed_packages(&resolved_version); + if !already_installed.is_empty() { + println!( + "{} PHP {} is already installed [{}]", + "💡".yellow(), + resolved_version.bold(), + already_installed.join(", ") + ); + // Idempotent no-op for scripts: nothing was explicitly requested and + // the default cli package is already present. + if packages.is_empty() + && !std::io::stdin().is_terminal() + && already_installed.iter().any(|p| p == "cli") + { + return Ok(Some(resolved_version)); + } + } - if selections.is_empty() { + let selected_packages = select_packages( + &resolved_version, + &available_packages, + packages, + &already_installed, + assume_yes, + )?; + let Some(selected_packages) = selected_packages else { println!("{} No packages selected. Operation cancelled.", "✗".red()); return Ok(None); - } - - let selected_packages: Vec = selections - .into_iter() - .map(|i| available_packages[i].clone()) - .collect(); + }; let dest = versions_dir.join(&resolved_version); let dest_existed = dest.exists(); @@ -116,17 +130,17 @@ pub async fn execute_install_with( return Ok(Some(resolved_version)); } + // Without a terminal (and without -y) do not auto-activate: nothing evals + // the env file, so a "Switched" message would lie. let use_now = cli_selected - && dialoguer::Confirm::with_theme(&theme) - .with_prompt( - format!("Do you want to use PHP {} now?", resolved_version) - .bold() - .to_string(), - ) - .default(true) - .interact_opt() - .unwrap_or(Some(false)) - .unwrap_or(false); + && (assume_yes || std::io::stdin().is_terminal()) + && crate::prompt::confirm( + &format!("Do you want to use PHP {} now?", resolved_version) + .bold() + .to_string(), + true, + assume_yes, + )?; if use_now { let v = crate::fs::resolve_local_version(&resolved_version)?; @@ -135,7 +149,7 @@ pub async fn execute_install_with( let export_str1 = s.set_env_var(MULTISHELL_PATH_VAR, &bin_dir.to_string_lossy()); let export_str2 = s.path(&bin_dir); - let env_file = crate::fs::get_env_update_path(None)?; + let env_file = crate::fs::get_env_update_path()?; crate::fs::write_env_file_locked(&env_file, &format!("{}\n{}", export_str1, export_str2))?; // Note: process-global env is intentionally NOT mutated here. std::env::set_var @@ -159,16 +173,186 @@ pub async fn execute_install_with( } } +/// Decide which packages to install: an explicit list is validated against +/// what the remote offers (and may reinstall); without one, a non-interactive +/// stdin defaults to "cli" and a terminal gets the MultiSelect, preselecting +/// whatever is not yet installed. Returns None on empty selection. +fn select_packages( + resolved_version: &str, + available_packages: &[String], + requested: &[String], + already_installed: &[String], + assume_yes: bool, +) -> Result>> { + if !requested.is_empty() { + for pkg in requested { + if !available_packages.contains(pkg) { + anyhow::bail!( + "Package '{}' is not available for PHP {} (available: {})", + pkg, + resolved_version, + available_packages.join(", ") + ); + } + } + return Ok(Some(requested.to_vec())); + } + + // -y skips the MultiSelect exactly like a missing terminal does. + if assume_yes || !std::io::stdin().is_terminal() { + let default_pkg = "cli".to_string(); + if !available_packages.contains(&default_pkg) { + anyhow::bail!( + "Cannot auto-select packages: the default 'cli' package is not available for PHP {} (available: {}). Use --packages.", + resolved_version, + available_packages.join(", ") + ); + } + return Ok(Some(vec![default_pkg])); + } + + let theme = dialoguer::theme::ColorfulTheme::default(); + let selections = dialoguer::MultiSelect::with_theme(&theme) + .with_prompt(format!( + "Select packages to install for PHP {}", + resolved_version + )) + .items(available_packages) + .defaults( + &available_packages + .iter() + .map(|p| { + if already_installed.is_empty() { + p == "cli" + } else { + // Preselect what is missing so "add fpm later" is one Enter away. + !already_installed.contains(p) + } + }) + .collect::>(), + ) + .interact()?; + + if selections.is_empty() { + return Ok(None); + } + Ok(Some( + selections + .into_iter() + .map(|i| available_packages[i].clone()) + .collect(), + )) +} + +/// Interactive remote-version picker used by `pvm install` without a version: +/// quick-select aliases (latest, newest patch per minor) above the full list. +async fn pick_and_install(packages: &[String], assume_yes: bool) -> Result<()> { + if !std::io::stdin().is_terminal() { + anyhow::bail!("No version given. Usage: pvm install "); + } + + let versions_info = network::get_available_versions().await?; + if versions_info.is_empty() { + println!("{} No remote versions found.", "💡".yellow()); + return Ok(()); + } + + let installed = fs::list_installed_versions().unwrap_or_default(); + + let mut display_items = Vec::new(); + let mut target_versions = Vec::new(); // Parallel array tying display index to actual version string + + // Build "Quick Select" aliases + let mut minors = std::collections::BTreeMap::new(); + let mut highest_overall = None; + + for (v, _) in &versions_info { + highest_overall = Some(v.clone()); + if let Some(minor) = fs::minor_of(v) { + minors.insert(minor, v.clone()); // BTreeMap iterates ascending, keeps latest + } + } + + if let Some(highest) = highest_overall { + display_items.push(format!("latest ({})", highest).bold().to_string()); + target_versions.push(highest); + } + + // Add them in reverse order (newest minor first) for the quick select + for (minor, highest_patch) in minors.iter().rev() { + display_items.push( + format!("{} ({})", minor, highest_patch) + .bold() + .cyan() + .to_string(), + ); + target_versions.push(highest_patch.clone()); + } + + display_items.push("---".dimmed().to_string()); + target_versions.push("".to_string()); // Unselectable divider + + // Build the rest of the flat list + for (v, pkgs) in versions_info.iter().rev() { + let pkgs_str = pkgs.join(", "); + if installed.contains(v) { + display_items.push(format!( + "{} {} {} [{}]", + "✓".green(), + v, + "(installed)".dimmed(), + pkgs_str.cyan() + )); + } else { + display_items.push(format!(" {} [{}]", v, pkgs_str.cyan())); + } + target_versions.push(v.clone()); + } + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Select a PHP version to install") + .default(0) + .items(&display_items) + .interact_opt()?; + + let selected = match selection { + Some(idx) => { + let target = &target_versions[idx]; + if target.is_empty() { + // They clicked the divider + println!("{} Invalid selection.", "✗".red()); + return Ok(()); + } + target + } + None => { + println!("{} Operation cancelled.", "✗".red()); + return Ok(()); + } + }; + + // Explicit --packages must not be swallowed by the short-circuit: it is + // the way to add packages to an already-installed version. + if !installed.contains(selected) || !packages.is_empty() { + execute_install_with(selected, true, packages, assume_yes).await?; + } else { + println!( + "{} PHP {} is already installed.", + "✓".green(), + selected.bold() + ); + } + + Ok(()) +} + impl Install { pub async fn call(self) -> Result<()> { match self.version { - Some(v) => execute_install(&v).await, - None => { - let ls_cmd = crate::commands::ls_remote::LsRemote { - version_prefix: None, - }; - ls_cmd.call().await - } + Some(v) => execute_install_with(&v, true, &self.packages, self.yes) + .await + .map(|_| ()), + None => pick_and_install(&self.packages, self.yes).await, } } } diff --git a/src/commands/ls.rs b/src/commands/ls.rs index 0db9b20..8b90d3c 100644 --- a/src/commands/ls.rs +++ b/src/commands/ls.rs @@ -1,20 +1,26 @@ +use crate::commands::use_cmd::{ActivateOpts, activate, pick_installed_version}; use crate::fs; use anyhow::Result; use clap::Parser; use colored::Colorize; +use std::io::IsTerminal; -/// List all locally installed PHP versions +/// List all locally installed PHP versions (interactively switch on a terminal) #[derive(Parser, Debug)] pub struct Ls; impl Ls { pub async fn call(self) -> Result<()> { let items = fs::get_aliased_versions()?; - let current = fs::get_current_version(); - if items.is_empty() { println!("No PHP versions installed."); - } else { + return Ok(()); + } + + // Scripts and pipes get the plain list; a terminal gets a picker that + // switches the shell to the selected version (Esc just leaves). + if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) { + let current = fs::get_current_version(); for item in items { let pkgs_str = item.packages.join(", "); if item.version == current { @@ -28,8 +34,17 @@ impl Ls { println!(" {} [{}]", item.display, pkgs_str.cyan()); } } + return Ok(()); } - Ok(()) + let current = fs::get_current_version(); + match pick_installed_version("Installed PHP versions — Enter switches, Esc exits")? { + Some(version) if version == current => { + eprintln!("{} PHP {} is already active.", "✓".green(), version.bold()); + Ok(()) + } + Some(version) => activate(version, ActivateOpts::picker(false)).await, + None => Ok(()), + } } } diff --git a/src/commands/ls_remote.rs b/src/commands/ls_remote.rs index 6b0bc5e..f7d888d 100644 --- a/src/commands/ls_remote.rs +++ b/src/commands/ls_remote.rs @@ -2,7 +2,6 @@ use crate::{fs, network}; use anyhow::Result; use clap::Parser; use colored::Colorize; -use dialoguer::{Select, theme::ColorfulTheme}; /// List all remote available PHP versions from static-php-cli #[derive(Parser, Debug)] @@ -27,88 +26,20 @@ impl LsRemote { let installed = fs::list_installed_versions().unwrap_or_default(); - let mut display_items = Vec::new(); - let mut target_versions = Vec::new(); // Parallel array tying display index to actual version string - - // Build "Quick Select" aliases - let mut minors = std::collections::BTreeMap::new(); - let mut highest_overall = None; - - for (v, _) in &versions_info { - highest_overall = Some(v.clone()); - let parts: Vec<&str> = v.split('.').collect(); - if parts.len() >= 2 { - let minor = format!("{}.{}", parts[0], parts[1]); - minors.insert(minor, v.clone()); // BTreeMap iterates ascending, keeps latest - } - } - - if let Some(highest) = highest_overall { - display_items.push(format!("latest ({})", highest).bold().to_string()); - target_versions.push(highest); - } - - // Add them in reverse order (newest minor first) for the quick select - for (minor, highest_patch) in minors.iter().rev() { - display_items.push( - format!("{} ({})", minor, highest_patch) - .bold() - .cyan() - .to_string(), - ); - target_versions.push(highest_patch.clone()); - } - - display_items.push("---".dimmed().to_string()); - target_versions.push("".to_string()); // Unselectable divider - - // Build the rest of the flat list - for (v, pkgs) in versions_info.iter().rev() { + // Ascending order: the newest versions print last, right above the prompt. + for (v, pkgs) in &versions_info { let pkgs_str = pkgs.join(", "); if installed.contains(v) { - display_items.push(format!( + println!( "{} {} {} [{}]", "✓".green(), v, "(installed)".dimmed(), pkgs_str.cyan() - )); + ); } else { - display_items.push(format!(" {} [{}]", v, pkgs_str.cyan())); - } - target_versions.push(v.clone()); - } - - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Select a PHP version to install") - .default(0) - .items(&display_items) - .interact_opt()?; - - let selected = match selection { - Some(idx) => { - let target = &target_versions[idx]; - if target.is_empty() { - // They clicked the divider - println!("{} Invalid selection.", "✗".red()); - return Ok(()); - } - target + println!(" {} [{}]", v, pkgs_str.cyan()); } - None => { - println!("{} Operation cancelled.", "✗".red()); - return Ok(()); - } - }; - - if !installed.contains(selected) { - crate::commands::install::execute_install(selected).await?; - } else { - println!( - "{} PHP {} is already installed.", - "✓".green(), - selected.bold() - ); } Ok(()) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 317902b..6acde34 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,9 +1,14 @@ +pub mod cache; pub mod current; +pub mod default_cmd; pub mod env; +pub mod exec_cmd; pub mod init; pub mod install; pub mod ls; pub mod ls_remote; +pub mod prune; pub mod self_update; pub mod uninstall; pub mod use_cmd; +pub mod which_cmd; diff --git a/src/commands/prune.rs b/src/commands/prune.rs new file mode 100644 index 0000000..efe448e --- /dev/null +++ b/src/commands/prune.rs @@ -0,0 +1,90 @@ +use crate::fs; +use anyhow::Result; +use clap::Parser; +use colored::Colorize; +use std::collections::BTreeMap; +use std::io::IsTerminal; + +/// Remove superseded patch versions +#[derive(Parser, Debug)] +pub struct Prune { + /// Auto-approve the removal without prompting + #[arg(short = 'y', long = "yes")] + pub yes: bool, +} + +impl Prune { + pub async fn call(self) -> Result<()> { + let installed = fs::list_installed_versions()?; + let current = fs::get_current_version(); + + // Keeper per major.minor line: the newest patch. The installed list + // is semver-sorted ascending, so the last insert per minor wins. + let mut keepers: BTreeMap = BTreeMap::new(); + for version in &installed { + if let Some(minor) = fs::minor_of(version) { + keepers.insert(minor, version.clone()); + } + } + + // Candidates: every installed patch that is not its minor's keeper, + // except the currently active version. + let candidates: Vec<(String, String)> = installed + .iter() + .filter_map(|version| { + let keeper = keepers.get(&fs::minor_of(version)?)?; + (version != keeper && *version != current) + .then(|| (version.clone(), keeper.clone())) + }) + .collect(); + + if candidates.is_empty() { + println!("{} Nothing to prune.", "✓".green()); + return Ok(()); + } + + println!("The following superseded version(s) will be removed:"); + for (version, keeper) in &candidates { + println!(" {} (superseded by {})", version, keeper.bold()); + } + + // Mass deletion needs explicit consent in scripts, like the other + // non-TTY guards in this codebase (use/install). + if !std::io::stdin().is_terminal() && !self.yes { + anyhow::bail!( + "Refusing to prune {} version(s) without a terminal; pass --yes to confirm.", + candidates.len() + ); + } + + let question = format!("Remove {} superseded version(s)?", candidates.len()); + if !crate::prompt::confirm(&question.bold().to_string(), true, self.yes)? { + println!("{} Operation cancelled.", "✗".red()); + return Ok(()); + } + + let versions_dir = fs::get_versions_dir()?; + for (version, _) in &candidates { + let dest = versions_dir.join(version); + std::fs::remove_dir_all(&dest) + .map_err(|e| anyhow::anyhow!("Failed to remove PHP {}: {}", version, e))?; + println!("{} Removed PHP {}", "✓".green(), version); + } + + // If the persisted default version was pruned, re-point it to the + // keeper of the same minor line. + if let Some(default) = fs::get_default_version()? + && let Some((_, keeper)) = candidates.iter().find(|(v, _)| *v == default) + { + fs::set_default_version(keeper)?; + println!( + "{} Default version {} was pruned; re-pointed to {}.", + "💡".yellow(), + default, + keeper.bold() + ); + } + + Ok(()) + } +} diff --git a/src/commands/uninstall.rs b/src/commands/uninstall.rs index 3a0095c..5d49c9e 100644 --- a/src/commands/uninstall.rs +++ b/src/commands/uninstall.rs @@ -2,8 +2,6 @@ use crate::fs; use anyhow::Result; use clap::Parser; use colored::Colorize; -use std::io::IsTerminal; - use dialoguer::{Select, theme::ColorfulTheme}; /// Uninstall a specific PHP version @@ -59,19 +57,10 @@ impl Uninstall { ); } - let is_tty = std::io::stdin().is_terminal(); - if !self.yes && is_tty { - let prompt = format!("Are you sure you want to uninstall PHP {}?", version); - let confirmed = dialoguer::Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(prompt.bold().to_string()) - .default(true) - .interact_opt()? - .unwrap_or(false); - - if !confirmed { - println!("{} Operation cancelled.", "✗".red()); - return Ok(()); - } + let question = format!("Are you sure you want to uninstall PHP {}?", version); + if !crate::prompt::confirm(&question.bold().to_string(), true, self.yes)? { + println!("{} Operation cancelled.", "✗".red()); + return Ok(()); } println!("{} Removing PHP {}...", "↻".blue(), version); @@ -84,6 +73,17 @@ impl Uninstall { } } + // Do not leave a default pointing at a removed version: 'pvm env' + // would silently skip it and new shells would fall back to system PHP. + if fs::get_default_version()?.as_deref() == Some(version.as_str()) { + fs::clear_default_version()?; + println!( + "{} PHP {} was the default version; default cleared. Set a new one with 'pvm default '.", + "💡".yellow(), + version + ); + } + Ok(()) } } diff --git a/src/commands/use_cmd.rs b/src/commands/use_cmd.rs index 9247db4..e132d0c 100644 --- a/src/commands/use_cmd.rs +++ b/src/commands/use_cmd.rs @@ -1,10 +1,10 @@ use crate::constants::{MULTISHELL_PATH_VAR, PHP_VERSION_FILE}; -use crate::{fs, shell, update}; +use crate::{fs, prompt, shell, update}; use anyhow::{Context, Result}; use clap::Parser; use colored::Colorize; -use dialoguer::{Confirm, Select, theme::ColorfulTheme}; -use std::path::Path; +use dialoguer::{Select, theme::ColorfulTheme}; +use std::io::IsTerminal; /// Change PHP version #[derive(Parser, Debug)] @@ -15,11 +15,20 @@ pub struct Use { /// Skip interactive prompts when the requested version is missing (used by shell hooks). #[arg(long, hide = true)] pub silent: bool, + + /// Auto-approve prompts (install missing versions, patch updates) + #[arg(short = 'y', long = "yes")] + pub yes: bool, } impl Use { pub async fn call(self) -> Result<()> { - let mut version = match self.version { + // "system" is not a real version: deactivate pvm for this shell instead. + if self.version.as_deref() == Some("system") { + return switch_to_system(self.silent); + } + + let version = match self.version { Some(ref v) => match fs::try_resolve_local_version(v)? { Some(resolved) => resolved, None => { @@ -27,24 +36,30 @@ impl Use { return Ok(()); } - let prompt = format!( + // Scripts must not trigger surprise network installs: without + // a terminal, installing needs an explicit --yes. + if !std::io::stdin().is_terminal() && !self.yes { + anyhow::bail!( + "PHP {} is not installed. Run 'pvm install {}' first (or pass --yes).", + v, + v + ); + } + + let question = format!( "PHP {} is not installed locally. Do you want to install it now?", v.bold() ); - let install_now = Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(&prompt) - .default(true) - .interact_opt()? - .unwrap_or(false); - - if !install_now { + if !prompt::confirm(&question, true, self.yes)? { eprintln!("{} Operation cancelled.", "✗".red()); return Ok(()); } // Skip install's own "use now?" prompt — we fall through to // the activation path below with the freshly installed version. - match crate::commands::install::execute_install_with(v, false).await? { + match crate::commands::install::execute_install_with(v, false, &[], self.yes) + .await? + { Some(installed) => installed, None => return Ok(()), } @@ -54,6 +69,9 @@ impl Use { let mut resolved_version = None; if let Ok(content) = std::fs::read_to_string(PHP_VERSION_FILE) { let trimmed = content.trim().to_string(); + if trimmed == "system" { + return switch_to_system(self.silent); + } if !trimmed.is_empty() { match fs::try_resolve_local_version(&trimmed)? { Some(resolved) => { @@ -61,22 +79,26 @@ impl Use { } None => { if !self.silent { - let prompt = format!( + if !std::io::stdin().is_terminal() && !self.yes { + anyhow::bail!( + "PHP {} (from {}) is not installed. Run 'pvm install {}' first (or pass --yes).", + trimmed, + PHP_VERSION_FILE, + trimmed + ); + } + let question = format!( "PHP {} (from {}) is not installed locally. Do you want to install it now?", trimmed.bold(), PHP_VERSION_FILE.bold() ); - let install_now = - Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(&prompt) - .default(true) - .interact_opt()? - .unwrap_or(false); - - if install_now { + if prompt::confirm(&question, true, self.yes)? { if let Some(installed) = crate::commands::install::execute_install_with( - &trimmed, false, + &trimmed, + false, + &[], + self.yes, ) .await? { @@ -97,101 +119,197 @@ impl Use { if let Some(resolved) = resolved_version { resolved } else { - let items = fs::get_aliased_versions()?; - if items.is_empty() { - eprintln!("{} No PHP versions are currently installed.", "💡".yellow()); - return Ok(()); - } - - let displays: Vec = items.iter().map(|i| i.display.clone()).collect(); - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Select a locally installed PHP version to use") - .default(0) - .items(&displays) - .interact_opt()?; - - match selection { - Some(idx) => items[idx].version.clone(), - None => { - eprintln!("{} Operation cancelled.", "✗".red()); - return Ok(()); - } + match pick_installed_version("Select a locally installed PHP version to use")? { + Some(v) => return activate(v, ActivateOpts::picker(self.yes)).await, + None => return Ok(()), } } } }; - if let Ok(Some(newer_version)) = update::check_for_updates(&version).await { - let prompt = format!( - "{} A new patch version is available: {} ➜ {}. Do you want to install and use it now?", - "💡".yellow(), - version.dimmed(), - newer_version.green().bold() - ); + activate( + version, + ActivateOpts { + offer_save: false, + assume_yes: self.yes, + quiet: self.silent, + }, + ) + .await + } +} + +/// Show the installed-version picker. Ok(None) means empty list or cancel +/// (both already reported to the user). +pub(crate) fn pick_installed_version(prompt_text: &str) -> Result> { + if !std::io::stdin().is_terminal() { + anyhow::bail!("No version given. Usage: pvm use "); + } - if Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(&prompt) - .default(true) - .interact_opt()? - .unwrap_or(false) + let items = fs::get_aliased_versions()?; + if items.is_empty() { + eprintln!("{} No PHP versions are currently installed.", "💡".yellow()); + return Ok(None); + } + + let current = fs::get_current_version(); + let displays: Vec = items + .iter() + .map(|i| { + let pkgs = i.packages.join(", "); + if i.version == current { + format!("{} {} [{}]", i.display, "(current)".cyan(), pkgs.cyan()) + } else { + format!("{} [{}]", i.display, pkgs.cyan()) + } + }) + .collect(); + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt(prompt_text) + .default(0) + .items(&displays) + .interact_opt()?; + + // Esc/cancel is a quiet no-op: the ls picker promises "Esc exits". + Ok(selection.map(|idx| items[idx].version.clone())) +} + +/// The .php-version question for this activation, or None when there is +/// nothing to ask: picker flows offer to save (or update) the choice, +/// explicit-argument flows only offer to update an existing differing file. +fn save_question(version: &str, file_version: &Option, offer_save: bool) -> Option { + if file_version.as_deref() == Some(version) { + return None; + } + match (offer_save, file_version) { + (true, Some(old)) => Some(format!( + "Save {} to {} (currently {})?", + version.bold(), + PHP_VERSION_FILE, + old.yellow() + )), + (true, None) => Some(format!("Save {} to {}?", version.bold(), PHP_VERSION_FILE)), + (false, Some(old)) => Some(format!( + "A {} file is present ({}). Do you want to apply this change to the directory?", + PHP_VERSION_FILE, + old.yellow() + )), + (false, None) => None, + } +} + +/// Write the deactivation snippet for this shell session ('pvm use system', +/// or a .php-version file containing "system"). +fn switch_to_system(quiet: bool) -> Result<()> { + let s = shell::detect_shell(); + let env_file = fs::get_env_update_path()?; + fs::write_env_file_locked(&env_file, &s.deactivate(&fs::get_versions_dir()?))?; + if !quiet { + eprintln!("{} Switched to system PHP", "✓".green()); + } + Ok(()) +} + +pub(crate) struct ActivateOpts { + /// Version came from an interactive picker: offer to persist it in .php-version. + pub offer_save: bool, + pub assume_yes: bool, + /// Suppress the success message (shell cd-hook runs on every prompt). + pub quiet: bool, +} + +impl ActivateOpts { + pub(crate) fn picker(assume_yes: bool) -> Self { + Self { + offer_save: true, + assume_yes, + quiet: false, + } + } +} + +/// Shared activation tail used by 'pvm use' and the interactive 'pvm ls': +/// patch-update offer, .php-version handling and the env-file write. +pub(crate) async fn activate(mut version: String, opts: ActivateOpts) -> Result<()> { + // Patch-update offers are an interactive-only feature: scripts must not + // trigger surprise downloads, so the check is skipped without a terminal. + if std::io::stdin().is_terminal() + && let Ok(Some(newer_version)) = update::check_for_updates(&version).await + { + let question = format!( + "{} A new patch version is available: {} ➜ {}. Do you want to install and use it now?", + "💡".yellow(), + version.dimmed(), + newer_version.green().bold() + ); + + if prompt::confirm(&question, true, opts.assume_yes)? { + // Carry over the packages of the version being replaced so the + // upgrade does not re-ask what is already known. + let installed_pkgs = fs::get_installed_packages(&version); + if crate::commands::install::execute_install_with( + &newer_version, + false, + &installed_pkgs, + opts.assume_yes, + ) + .await? + .is_some() { - let install_cmd = crate::commands::install::Install { - version: Some(newer_version.clone()), - }; - install_cmd.call().await?; version = newer_version; } } + } - if !fs::is_version_installed(&version)? { - anyhow::bail!( - "PHP {} is not installed. Run 'pvm install {}' first.", - version, - version - ); - } + if !fs::is_version_installed(&version)? { + anyhow::bail!( + "PHP {} is not installed. Run 'pvm install {}' first.", + version, + version + ); + } + + let file_version = std::fs::read_to_string(PHP_VERSION_FILE) + .ok() + .map(|c| c.trim().to_string()); - // Smart prompt logic - if Path::new(PHP_VERSION_FILE).exists() - && let Ok(current_file_ver) = std::fs::read_to_string(PHP_VERSION_FILE) - && current_file_ver.trim() != version - { - let prompt = format!( - "A {} file is present ({}). Do you want to apply this change to the directory?", + // quiet = shell cd-hook: never prompt there, or a .php-version holding a + // partial version like "8.3" would ask on every single cd. + if !opts.quiet + && let Some(question) = save_question(&version, &file_version, opts.offer_save) + { + // Deliberately not covered by --yes: writing .php-version is a + // side effect the user should opt into explicitly. + if prompt::confirm(&question, false, false)? { + std::fs::write(PHP_VERSION_FILE, &version) + .with_context(|| format!("Failed to update {}", PHP_VERSION_FILE))?; + eprintln!( + "{} Updated {} to {}", + "✓".green(), PHP_VERSION_FILE, - current_file_ver.trim().yellow() + version.bold() ); - if Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(&prompt) - .default(false) - .interact_opt()? - .unwrap_or(false) - { - std::fs::write(PHP_VERSION_FILE, &version) - .with_context(|| format!("Failed to update {}", PHP_VERSION_FILE))?; - eprintln!( - "{} Updated {} to {}", - "✓".green(), - PHP_VERSION_FILE, - version.bold() - ); - } } + } - let bin_dir = fs::get_version_bin_dir(&version)?; - let s = shell::detect_shell(); + let bin_dir = fs::get_version_bin_dir(&version)?; + let s = shell::detect_shell(); - // These evaluate in the user's shell hook via wrapper - let export_str1 = s.set_env_var(MULTISHELL_PATH_VAR, &bin_dir.to_string_lossy()); - let export_str2 = s.path(&bin_dir); + // These evaluate in the user's shell hook via wrapper + let export_str1 = s.set_env_var(MULTISHELL_PATH_VAR, &bin_dir.to_string_lossy()); + let export_str2 = s.path(&bin_dir); - let env_file = fs::get_env_update_path(None)?; - fs::write_env_file_locked(&env_file, &format!("{}\n{}", export_str1, export_str2))?; + let env_file = fs::get_env_update_path()?; + fs::write_env_file_locked(&env_file, &format!("{}\n{}", export_str1, export_str2))?; - // Note: process-global env is intentionally NOT mutated here. std::env::set_var - // is unsound in a multi-threaded tokio runtime, and the wrapper sources env_file - // into the parent shell on exit, so subsequent pvm invocations see the new PATH. + // Note: process-global env is intentionally NOT mutated here. std::env::set_var + // is unsound in a multi-threaded tokio runtime, and the wrapper sources env_file + // into the parent shell on exit, so subsequent pvm invocations see the new PATH. - Ok(()) + if !opts.quiet { + eprintln!("{} Now using PHP {}", "✓".green(), version.bold()); } + + Ok(()) } diff --git a/src/commands/which_cmd.rs b/src/commands/which_cmd.rs new file mode 100644 index 0000000..c0a8697 --- /dev/null +++ b/src/commands/which_cmd.rs @@ -0,0 +1,34 @@ +use crate::fs; +use anyhow::Result; +use clap::Parser; + +/// Print the path of the active or given PHP binary +#[derive(Parser, Debug)] +pub struct Which { + /// The version to inspect (defaults to the active one) + pub version: Option, +} + +impl Which { + pub async fn call(self) -> Result<()> { + let version = match self.version { + Some(ref v) => fs::resolve_local_version(v)?, + None => { + let current = fs::get_current_version(); + if current == "system" { + anyhow::bail!("no pvm-managed PHP is active (try: pvm which )"); + } + current + } + }; + + let php_path = fs::get_version_bin_dir(&version)?.join("php"); + if !php_path.exists() { + anyhow::bail!("PHP {} has no cli package installed", version); + } + + // Plain output (no icons) so scripts can consume the path directly. + println!("{}", php_path.display()); + Ok(()) + } +} diff --git a/src/constants.rs b/src/constants.rs index b178630..7ecb197 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -13,6 +13,8 @@ pub const REMOTE_CACHE_FILE: &str = "remote_cache.json"; /// The name of the file used as a guard for the update check. pub const UPDATE_CHECK_GUARD_FILE: &str = ".update_check_guard"; +pub const DEFAULT_VERSION_FILE: &str = "default"; + /// The name of the file used to store the PHP version for a directory. pub const PHP_VERSION_FILE: &str = ".php-version"; diff --git a/src/fs.rs b/src/fs.rs index 499bfd7..7ca7408 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -13,10 +13,11 @@ pub struct VersionItem { pub fn get_installed_packages(version: &str) -> Vec { let mut pkgs = Vec::new(); if let Ok(bin_dir) = get_version_bin_dir(version) { - if bin_dir.join("php").exists() || bin_dir.join("php.exe").exists() { + // No .exe variants: Windows is unsupported (get_target_triple bails). + if bin_dir.join("php").exists() { pkgs.push("cli".to_string()); } - if bin_dir.join("php-fpm").exists() || bin_dir.join("php-fpm.exe").exists() { + if bin_dir.join("php-fpm").exists() { pkgs.push("fpm".to_string()); } if bin_dir.join("micro.sfx").exists() { @@ -38,6 +39,12 @@ pub fn get_versions_dir() -> Result { Ok(get_pvm_dir()?.join("versions")) } +/// "8.3.1" -> "8.3"; None for names without a major.minor prefix. +pub fn minor_of(version: &str) -> Option { + let mut parts = version.split('.'); + Some(format!("{}.{}", parts.next()?, parts.next()?)) +} + pub fn get_version_bin_dir(version: &str) -> Result { Ok(get_versions_dir()?.join(version).join("bin")) } @@ -63,7 +70,8 @@ pub fn list_installed_versions() -> Result> { } } - crate::utils::sort_versions(&mut versions); + // Directory names are always full semver ("8.3.1"); unparsable ones sort first. + versions.sort_by_cached_key(|v| semver::Version::parse(v).ok()); Ok(versions) } @@ -79,21 +87,41 @@ pub fn get_current_version() -> String { "system".to_string() } -pub fn get_env_update_path(override_path: Option) -> Result { - if let Some(path) = override_path { - return Ok(path); - } +pub fn get_env_update_path() -> Result { if let Ok(env_path) = std::env::var("PVM_ENV_UPDATE_PATH") { return Ok(PathBuf::from(env_path)); } + Ok(get_pvm_dir()?.join(crate::constants::ENV_UPDATE_FILE)) +} + +/// The version new shells start on, persisted via 'pvm default'. +pub fn get_default_version() -> Result> { + let path = get_pvm_dir()?.join(crate::constants::DEFAULT_VERSION_FILE); + match std::fs::read_to_string(path) { + Ok(content) => { + let trimmed = content.trim().to_string(); + Ok((!trimmed.is_empty()).then_some(trimmed)) + } + Err(_) => Ok(None), + } +} + +pub fn set_default_version(version: &str) -> Result<()> { let pvm_dir = get_pvm_dir()?; - let shell_pid = std::env::var("PVM_SHELL_PID").unwrap_or_default(); - let filename = if shell_pid.is_empty() { - crate::constants::ENV_UPDATE_FILE.to_string() - } else { - format!("{}_{}", crate::constants::ENV_UPDATE_FILE, shell_pid) - }; - Ok(pvm_dir.join(filename)) + std::fs::create_dir_all(&pvm_dir)?; + std::fs::write( + pvm_dir.join(crate::constants::DEFAULT_VERSION_FILE), + version, + ) + .context("Failed to write default version file") +} + +pub fn clear_default_version() -> Result<()> { + let path = get_pvm_dir()?.join(crate::constants::DEFAULT_VERSION_FILE); + if path.exists() { + std::fs::remove_file(path).context("Failed to remove default version file")?; + } + Ok(()) } /// Safely writes content to the environment update file with an exclusive lock. @@ -115,18 +143,11 @@ pub fn write_env_file_locked(path: &PathBuf, content: &str) -> Result<()> { } pub fn get_aliased_versions() -> Result> { - let mut installed = list_installed_versions()?; + let installed = list_installed_versions()?; if installed.is_empty() { return Ok(Vec::new()); } - // Sort semantic versions cleanly - installed.sort_by(|a, b| { - let a_parts: Vec = a.split('.').filter_map(|s| s.parse().ok()).collect(); - let b_parts: Vec = b.split('.').filter_map(|s| s.parse().ok()).collect(); - a_parts.cmp(&b_parts) - }); - let mut items = Vec::new(); // Latest alias @@ -141,9 +162,7 @@ pub fn get_aliased_versions() -> Result> { // Minor version aliases let mut minors = std::collections::BTreeMap::new(); for v in &installed { - let parts: Vec<&str> = v.split('.').collect(); - if parts.len() >= 2 { - let minor = format!("{}.{}", parts[0], parts[1]); + if let Some(minor) = minor_of(v) { // BTreeMap keeps the latest because we iterate in ascending order, overriding previous values minors.insert(minor, v.clone()); } diff --git a/src/interactive.rs b/src/interactive.rs index 1a0b7d6..b7859c0 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -10,9 +10,11 @@ pub async fn run_root_menu() -> Result<()> { "Use (Switch active PHP version)", "Install (Install a PHP version)", "Uninstall (Remove a PHP version)", + "Prune (Remove superseded patch versions)", "List (View locally installed versions)", "List-Remote (View all available cloud versions)", "Current (Print the currently active PHP version)", + "Default (Set the default PHP version for new shells)", "Init (Initialize a .php-version file)", "Exit", ]; @@ -28,47 +30,53 @@ pub async fn run_root_menu() -> Result<()> { None => break, // Esc/Q exits the menu entirely }; - if choice == 7 { + if choice == options.len() - 1 { break; } let res = match choice { 0 => { - let cmd = commands::use_cmd::Use { + commands::use_cmd::Use { version: None, silent: false, - }; - cmd.call().await + yes: false, + } + .call() + .await } 1 => { - let cmd = commands::install::Install { version: None }; - cmd.call().await + commands::install::Install { + version: None, + packages: vec![], + yes: false, + } + .call() + .await } 2 => { - let cmd = commands::uninstall::Uninstall { + commands::uninstall::Uninstall { version: None, yes: false, - }; - cmd.call().await - } - 3 => { - let cmd = commands::ls::Ls; - cmd.call().await - } - 4 => { - let cmd = commands::ls_remote::LsRemote { - version_prefix: None, - }; - cmd.call().await + } + .call() + .await } + 3 => commands::prune::Prune { yes: false }.call().await, + 4 => commands::ls::Ls.call().await, 5 => { - let cmd = commands::current::Current {}; - cmd.call().await + commands::ls_remote::LsRemote { + version_prefix: None, + } + .call() + .await } - 6 => { - let cmd = commands::init::Init {}; - cmd.call().await + 6 => commands::current::Current {}.call().await, + 7 => { + commands::default_cmd::DefaultCmd { version: None } + .call() + .await } + 8 => commands::init::Init {}.call().await, _ => break, }; diff --git a/src/main.rs b/src/main.rs index 6724f55..380bc90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,9 +4,9 @@ mod constants; mod fs; mod interactive; mod network; +mod prompt; mod shell; mod update; -mod utils; use anyhow::Result; use clap::Parser; @@ -15,9 +15,6 @@ use colored::Colorize; #[tokio::main] async fn main() -> Result<()> { - env_logger::init(); - - let _pvm_dir = fs::get_pvm_dir()?; let versions_dir = fs::get_versions_dir()?; std::fs::create_dir_all(&versions_dir)?; diff --git a/src/network.rs b/src/network.rs index d6f72db..351446b 100644 --- a/src/network.rs +++ b/src/network.rs @@ -2,7 +2,6 @@ use crate::constants::{BASE_URL, REMOTE_CACHE_FILE}; use anyhow::{Context, Result}; use flate2::read::GzDecoder; -use futures_util::StreamExt; use reqwest::Client; use serde::Deserialize; use std::path::Path; @@ -65,14 +64,16 @@ pub(crate) fn build_download_progress_bar(total_size: Option) -> Result Result { let mut tmp = tempfile::tempfile().context("Failed to create temporary archive file")?; let mut downloaded: u64 = 0; - let mut stream = response.bytes_stream(); - while let Some(item) = stream.next().await { - let chunk = item.context("Error while downloading chunk")?; + while let Some(chunk) = response + .chunk() + .await + .context("Error while downloading chunk")? + { tmp.write_all(&chunk) .context("Failed to write archive chunk to temp file")?; downloaded += chunk.len() as u64; diff --git a/src/prompt.rs b/src/prompt.rs new file mode 100644 index 0000000..6ef172b --- /dev/null +++ b/src/prompt.rs @@ -0,0 +1,21 @@ +use anyhow::{Context, Result}; +use dialoguer::{Confirm, theme::ColorfulTheme}; +use std::io::IsTerminal; + +/// Ask a yes/no question. `assume_yes` short-circuits to true; a +/// non-interactive stdin returns `default` so scripts never hang on a +/// hidden prompt. Esc/q counts as "no". +pub fn confirm(prompt: &str, default: bool, assume_yes: bool) -> Result { + if assume_yes { + return Ok(true); + } + if !std::io::stdin().is_terminal() { + return Ok(default); + } + Ok(Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(prompt) + .default(default) + .interact_opt() + .context("Failed to read confirmation from terminal")? + .unwrap_or(false)) +} diff --git a/src/shell.rs b/src/shell.rs index 9a269b7..5a05eaa 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -6,6 +6,9 @@ pub trait Shell { fn set_env_var(&self, name: &str, value: &str) -> String; fn use_on_cd(&self) -> String; fn wrapper_fn(&self) -> String; + /// Emit commands that drop every pvm-managed entry from PATH and clear + /// the multishell marker, returning the shell to the system PHP. + fn deactivate(&self, versions_dir: &Path) -> String; } /// Quote a string for POSIX shells (bash/zsh) by wrapping it in single quotes @@ -39,18 +42,63 @@ fn fish_single_quote(value: &str) -> String { out } +fn posix_path(path: &Path) -> String { + format!( + "export PATH={}:\"$PATH\"", + posix_single_quote(&path.display().to_string()) + ) +} + +fn posix_set_env_var(name: &str, value: &str) -> String { + format!("export {}={}", name, posix_single_quote(value)) +} + +fn posix_wrapper_fn() -> String { + format!( + " +export PATH=\"${{{}}}/bin:$PATH\" + +pvm() {{ + local command=$1 + if [[ \"$command\" == \"env\" ]]; then + command pvm \"$@\" + else + if [[ -n \"${{{}}}\" && -d \"${{{}}}\" ]]; then + local env_file=\"${{{}}}/{}_$$_${{RANDOM}}${{RANDOM}}_$(date +%s)\" + [[ -f \"$env_file\" ]] && command rm -f \"$env_file\" 2>/dev/null + PVM_ENV_UPDATE_PATH=\"$env_file\" command pvm \"$@\" + local exit_code=$? + if [[ -f \"$env_file\" ]]; then + eval \"$(cat \"$env_file\")\" + command rm -f \"$env_file\" 2>/dev/null + fi + return $exit_code + else + command pvm \"$@\" + fi + fi +}} +", + PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, ENV_UPDATE_FILE + ) +} + +fn posix_deactivate(versions_dir: &Path) -> String { + format!( + "export PVM_MULTISHELL_PATH=''\nexport PATH=\"$(printf '%s' \"$PATH\" | tr ':' '\\n' | grep -vF {} | paste -sd : -)\"", + posix_single_quote(&versions_dir.display().to_string()) + ) +} + pub struct Bash; impl Shell for Bash { fn path(&self, path: &Path) -> String { - format!( - "export PATH={}:\"$PATH\"", - posix_single_quote(&path.display().to_string()) - ) + posix_path(path) } fn set_env_var(&self, name: &str, value: &str) -> String { - format!("export {}={}", name, posix_single_quote(value)) + posix_set_env_var(name, value) } fn use_on_cd(&self) -> String { @@ -70,33 +118,11 @@ fi } fn wrapper_fn(&self) -> String { - format!( - " -export PATH=\"${{{}}}/bin:$PATH\" + posix_wrapper_fn() + } -pvm() {{ - local command=$1 - if [[ \"$command\" == \"env\" ]]; then - command pvm \"$@\" - else - if [[ -n \"${{{}}}\" && -d \"${{{}}}\" ]]; then - local env_file=\"${{{}}}/{}_$$_${{RANDOM}}${{RANDOM}}_$(date +%s)\" - [[ -f \"$env_file\" ]] && command rm -f \"$env_file\" 2>/dev/null - PVM_ENV_UPDATE_PATH=\"$env_file\" command pvm \"$@\" - local exit_code=$? - if [[ -f \"$env_file\" ]]; then - eval \"$(cat \"$env_file\")\" - command rm -f \"$env_file\" 2>/dev/null - fi - return $exit_code - else - command pvm \"$@\" - fi - fi -}} -", - PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, ENV_UPDATE_FILE - ) + fn deactivate(&self, versions_dir: &Path) -> String { + posix_deactivate(versions_dir) } } @@ -104,14 +130,11 @@ pub struct Zsh; impl Shell for Zsh { fn path(&self, path: &Path) -> String { - format!( - "export PATH={}:\"$PATH\"", - posix_single_quote(&path.display().to_string()) - ) + posix_path(path) } fn set_env_var(&self, name: &str, value: &str) -> String { - format!("export {}={}", name, posix_single_quote(value)) + posix_set_env_var(name, value) } fn use_on_cd(&self) -> String { @@ -128,33 +151,11 @@ add-zsh-hook chpwd _pvm_cd_hook } fn wrapper_fn(&self) -> String { - format!( - " -export PATH=\"${{{}}}/bin:$PATH\" + posix_wrapper_fn() + } -pvm() {{ - local command=$1 - if [[ \"$command\" == \"env\" ]]; then - command pvm \"$@\" - else - if [[ -n \"${{{}}}\" && -d \"${{{}}}\" ]]; then - local env_file=\"${{{}}}/{}_$$_${{RANDOM}}${{RANDOM}}_$(date +%s)\" - [[ -f \"$env_file\" ]] && command rm -f \"$env_file\" 2>/dev/null - PVM_ENV_UPDATE_PATH=\"$env_file\" command pvm \"$@\" - local exit_code=$? - if [[ -f \"$env_file\" ]]; then - eval \"$(cat \"$env_file\")\" - command rm -f \"$env_file\" 2>/dev/null - fi - return $exit_code - else - command pvm \"$@\" - fi - fi -}} -", - PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, ENV_UPDATE_FILE - ) + fn deactivate(&self, versions_dir: &Path) -> String { + posix_deactivate(versions_dir) } } @@ -183,6 +184,13 @@ end .to_string() } + fn deactivate(&self, versions_dir: &Path) -> String { + format!( + "set -gx PVM_MULTISHELL_PATH ''\nset -gx PATH (string match -v -- {} $PATH)", + fish_single_quote(&format!("{}*", versions_dir.display())) + ) + } + fn wrapper_fn(&self) -> String { format!( " @@ -284,4 +292,21 @@ mod tests { let fish = Fish; assert_eq!(fish.set_env_var("X", "a'b\\c"), "set -gx X 'a\\'b\\\\c'"); } + + #[test] + fn test_bash_deactivate_filters_versions_dir() { + let bash = Bash; + let out = bash.deactivate(std::path::Path::new("/data/pvm/versions")); + assert!(out.contains("export PVM_MULTISHELL_PATH=''")); + assert!(out.contains("grep -vF '/data/pvm/versions'")); + assert!(out.contains("paste -sd : -")); + } + + #[test] + fn test_fish_deactivate_filters_versions_dir() { + let fish = Fish; + let out = fish.deactivate(std::path::Path::new("/data/pvm/versions")); + assert!(out.contains("set -gx PVM_MULTISHELL_PATH ''")); + assert!(out.contains("string match -v -- '/data/pvm/versions*' $PATH")); + } } diff --git a/src/update.rs b/src/update.rs index d3f70bb..8fb0e9f 100644 --- a/src/update.rs +++ b/src/update.rs @@ -49,27 +49,16 @@ pub async fn check_for_updates(target_version: &str) -> Result> { } // Parse out the minor version (e.g., "8.4.1" -> "8.4") - let parts: Vec<&str> = target_version.split('.').collect(); - if parts.len() < 2 { + let Some(minor_prefix) = fs::minor_of(target_version) else { return Ok(None); - } - let minor_prefix = format!("{}.{}", parts[0], parts[1]); + }; - // Fetch remotes and resolve the newest patch for that minor line - match network::resolve_version(&minor_prefix).await { - Ok(latest_matching) => { - if latest_matching != target_version { - return Ok(Some(latest_matching)); - } - } - Err(e) => { - log::debug!( - "Failed to resolve version for update check (minor_prefix: {}, target: {}): {}", - minor_prefix, - target_version, - e - ); - } + // Fetch remotes and resolve the newest patch for that minor line. + // Resolution failures are ignored: the update check is best-effort. + if let Ok(latest_matching) = network::resolve_version(&minor_prefix).await + && latest_matching != target_version + { + return Ok(Some(latest_matching)); } Ok(None) diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index 3f31718..0000000 --- a/src/utils.rs +++ /dev/null @@ -1,54 +0,0 @@ -use semver::Version; - -/// Sorts a list of version strings using semantic versioning. -/// If a version string is not valid semver, it falls back to a simple string-based numeric sort. -pub fn sort_versions(versions: &mut [String]) { - let mut parsed: Vec<(String, Result)> = versions - .iter() - .map(|v| (v.clone(), Version::parse(v))) - .collect(); - - parsed.sort_by(|a, b| { - match (&a.1, &b.1) { - (Ok(av), Ok(bv)) => av.cmp(bv), - (Ok(av), Err(_)) => { - let b_parts: Vec = b.0.split('.').filter_map(|s| s.parse().ok()).collect(); - let a_parts = vec![av.major, av.minor, av.patch]; - match a_parts.cmp(&b_parts) { - std::cmp::Ordering::Equal => { - if !av.pre.is_empty() { - std::cmp::Ordering::Less - } else { - std::cmp::Ordering::Equal - } - } - ord => ord, - } - } - (Err(_), Ok(bv)) => { - let a_parts: Vec = a.0.split('.').filter_map(|s| s.parse().ok()).collect(); - let b_parts = vec![bv.major, bv.minor, bv.patch]; - match a_parts.cmp(&b_parts) { - std::cmp::Ordering::Equal => { - if !bv.pre.is_empty() { - std::cmp::Ordering::Greater - } else { - std::cmp::Ordering::Equal - } - } - ord => ord, - } - } - _ => { - // Fallback for non-semver strings (e.g., "8.2") - let a_parts: Vec = a.0.split('.').filter_map(|s| s.parse().ok()).collect(); - let b_parts: Vec = b.0.split('.').filter_map(|s| s.parse().ok()).collect(); - a_parts.cmp(&b_parts) - } - } - }); - - for (i, (orig, _)) in parsed.into_iter().enumerate() { - versions[i] = orig; - } -} diff --git a/tests/cli.rs b/tests/cli.rs index 4067cde..9705d32 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,5 +1,291 @@ use predicates::prelude::*; +/// Seed the remote version cache so commands that hit the network resolve +/// entirely offline. Mirrors the cache filename scheme in network.rs. +fn seed_remote_cache(pvm_dir: &std::path::Path, versions: &[(&str, &[&str])]) { + let data: Vec<(String, Vec)> = versions + .iter() + .map(|(v, pkgs)| (v.to_string(), pkgs.iter().map(|p| p.to_string()).collect())) + .collect(); + let target = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH); + std::fs::create_dir_all(pvm_dir).unwrap(); + std::fs::write( + pvm_dir.join(format!("remote_cache-{}.json", target)), + serde_json::to_string(&data).unwrap(), + ) + .unwrap(); +} + +/// Create a fake installed version with the given package binaries. +fn seed_installed_version(pvm_dir: &std::path::Path, version: &str, packages: &[&str]) { + let bin_dir = pvm_dir.join("versions").join(version).join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + for pkg in packages { + let file = match *pkg { + "cli" => "php", + "fpm" => "php-fpm", + "micro" => "micro.sfx", + other => panic!("unknown package {}", other), + }; + std::fs::write(bin_dir.join(file), "").unwrap(); + } +} + +#[test] +fn test_install_resolve_failure_uses_cache_offline() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.9.7", &["cli"])]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("install").arg("7.0"); + cmd.assert().failure().stderr(predicate::str::contains( + "Could not resolve a remotely available version matching '7.0'", + )); +} + +#[test] +fn test_ls_remote_plain_listing() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache( + temp_dir.path(), + &[("8.9.6", &["cli", "fpm"]), ("8.9.7", &["cli"])], + ); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("ls-remote"); + cmd.assert() + .success() + .stdout(predicate::str::contains("8.9.7 [cli]")) + .stdout(predicate::str::contains("8.9.6")) + .stdout(predicate::str::contains("(installed)")); +} + +#[test] +fn test_ls_remote_prefix_filter() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.8.1", &["cli"]), ("8.9.7", &["cli"])]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("ls-remote").arg("8.9"); + cmd.assert() + .success() + .stdout(predicate::str::contains("8.9.7")) + .stdout(predicate::str::contains("8.8.1").not()); +} + +#[test] +fn test_install_no_version_non_tty_fails() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.9.7", &["cli"])]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("install"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("No version given")); +} + +#[test] +fn test_install_packages_flag_rejects_unavailable() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.9.7", &["cli"])]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("install").arg("8.9.7").arg("--packages").arg("fpm"); + cmd.assert().failure().stderr(predicate::str::contains( + "Package 'fpm' is not available for PHP 8.9.7", + )); +} + +#[test] +fn test_install_non_tty_without_cli_package_fails() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.9.7", &["fpm"])]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("install").arg("8.9.7"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("Use --packages")); +} + +#[test] +fn test_install_already_installed_is_idempotent_non_tty() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.9.7", &["cli", "fpm"])]); + seed_installed_version(temp_dir.path(), "8.9.7", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("install").arg("8.9.7"); + // Succeeds without any download attempt (the fake version would 404). + cmd.assert() + .success() + .stdout(predicate::str::contains("already installed [cli]")); + assert!(temp_dir.path().join("versions/8.9.7/bin/php").exists()); +} + +#[test] +fn test_default_set_show_and_env_activation() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + // Set the default + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("default").arg("8.9.6"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Default version set to 8.9.6")); + + // Non-TTY 'pvm default' prints it + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("default"); + cmd.assert() + .success() + .stdout(predicate::str::contains("8.9.6")); + + // 'pvm env' activates it for new shells + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("env").arg("--shell=bash"); + cmd.assert() + .success() + .stdout(predicate::str::contains("export PVM_MULTISHELL_PATH=")) + .stdout(predicate::str::contains("versions/8.9.6/bin")); + + // 'pvm default system' clears it again + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("default").arg("system"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Default cleared")); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("env").arg("--shell=bash"); + cmd.assert() + .success() + .stdout(predicate::str::contains("PVM_MULTISHELL_PATH").not()); +} + +#[test] +fn test_default_rejects_missing_version() { + let temp_dir = tempfile::tempdir().unwrap(); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("default").arg("9.9.9"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("not installed locally")); +} + +#[test] +fn test_uninstall_clears_stale_default() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + std::fs::write(temp_dir.path().join("default"), "8.9.6").unwrap(); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("uninstall").arg("8.9.6").arg("--yes"); + cmd.assert() + .success() + .stdout(predicate::str::contains("default cleared")); + + assert!( + !temp_dir.path().join("default").exists(), + "stale default file must be removed with its version" + ); +} + +#[test] +fn test_use_missing_version_non_tty_fails_without_yes() { + let temp_dir = tempfile::tempdir().unwrap(); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env("PVM_UPDATE_MODE", "disabled"); + cmd.current_dir(temp_dir.path()); + cmd.arg("use").arg("8.9"); + // No terminal, no --yes: must not silently download and install. + cmd.assert() + .failure() + .stderr(predicate::str::contains("or pass --yes")); +} + +#[test] +fn test_env_warns_about_stale_default() { + let temp_dir = tempfile::tempdir().unwrap(); + std::fs::write(temp_dir.path().join("default"), "8.9.6").unwrap(); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("env").arg("--shell=bash"); + cmd.assert() + .success() + .stdout(predicate::str::contains("PVM_MULTISHELL_PATH").not()) + .stderr(predicate::str::contains("is not installed")); +} + +#[test] +fn test_prune_non_tty_requires_yes() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.1", &["cli"]); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + // Without a terminal and without -y, mass deletion must refuse. + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env_remove("PVM_MULTISHELL_PATH"); + cmd.arg("prune"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("pass --yes")); + assert!(temp_dir.path().join("versions/8.9.1").exists()); + + // With -y it removes the superseded patch and keeps the keeper. + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env_remove("PVM_MULTISHELL_PATH"); + cmd.arg("prune").arg("-y"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Removed PHP 8.9.1")); + assert!(!temp_dir.path().join("versions/8.9.1").exists()); + assert!(temp_dir.path().join("versions/8.9.6").exists()); +} + +#[test] +fn test_use_system_writes_deactivation_env_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let env_file = temp_dir.path().join("custom_env_update"); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env("PVM_ENV_UPDATE_PATH", &env_file); + cmd.env("SHELL", "/bin/bash"); + cmd.arg("use").arg("system"); + cmd.assert() + .success() + .stderr(predicate::str::contains("Switched to system PHP")); + + let content = std::fs::read_to_string(env_file).unwrap(); + assert!(content.contains("export PVM_MULTISHELL_PATH=''")); + assert!(content.contains("grep -vF")); + assert!(content.contains("versions")); +} + #[test] fn test_help() { let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); @@ -49,6 +335,50 @@ fn test_help_lists_self_update() { .stdout(predicate::str::contains("self-update")); } +#[test] +fn test_ls_non_tty_prints_plain_list() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli", "fpm"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("ls"); + cmd.assert() + .success() + .stdout(predicate::str::contains("latest (8.9.6)")) + .stdout(predicate::str::contains("cli, fpm")); +} + +#[test] +fn test_use_no_version_non_tty_fails_with_hint() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env("PVM_UPDATE_MODE", "disabled"); + cmd.current_dir(temp_dir.path()); + cmd.arg("use"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("No version given")); +} + +#[test] +fn test_init_non_tty_fails_with_hint() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.9.7", &["cli"])]); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.current_dir(temp_dir.path()); + cmd.arg("init"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("requires a terminal")); +} + #[test] fn test_ls_empty() { let temp_dir = tempfile::tempdir().unwrap(); @@ -169,6 +499,225 @@ fn test_uninstall_fpm_only_success() { assert!(!temp_dir.path().join("versions").join("8.3.1").exists()); } +#[cfg(unix)] +#[test] +fn test_exec_runs_command_and_propagates_exit_code() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + // Replace the empty fake php binary with a script that prints a marker + // and exits with a distinctive code. + let php_path = temp_dir.path().join("versions/8.9.6/bin/php"); + std::fs::write(&php_path, "#!/bin/sh\necho FAKE-PHP-8.9.6\nexit 7\n").unwrap(); + std::fs::set_permissions(&php_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("exec").arg("8.9.6").arg("php"); + cmd.assert() + .code(7) + .stdout(predicate::str::contains("FAKE-PHP-8.9.6")); +} + +#[cfg(unix)] +#[test] +fn test_exec_unknown_version_fails() { + let temp_dir = tempfile::tempdir().unwrap(); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("exec").arg("9.9.9").arg("php").arg("-v"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("not installed locally")); +} + +#[test] +fn test_cache_clear_removes_cache_file_and_is_idempotent() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.9.7", &["cli"])]); + + let target = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH); + let cache_file = temp_dir + .path() + .join(format!("remote_cache-{}.json", target)); + assert!(cache_file.exists()); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("cache").arg("clear"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Removed 1")); + + assert!( + !cache_file.exists(), + "cache file must be deleted from PVM_DIR" + ); + + // Second run: nothing left to remove, still succeeds. + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("cache").arg("clear"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Removed 0")); +} + +#[test] +fn test_cache_clear_tolerates_missing_pvm_dir() { + let temp_dir = tempfile::tempdir().unwrap(); + let missing_dir = temp_dir.path().join("does-not-exist"); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", &missing_dir); + cmd.arg("cache").arg("clear"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Removed 0")); +} + +#[test] +fn test_which_prints_path_of_active_version() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + let multishell_path = temp_dir.path().join("versions").join("8.9.6").join("bin"); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env("PVM_MULTISHELL_PATH", &multishell_path); + cmd.arg("which"); + cmd.assert().success().stdout(predicate::str::contains( + multishell_path.join("php").to_string_lossy().into_owned(), + )); +} + +#[test] +fn test_which_resolves_explicit_minor_version() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env_remove("PVM_MULTISHELL_PATH"); + cmd.arg("which").arg("8.9"); + cmd.assert().success().stdout(predicate::str::contains( + temp_dir + .path() + .join("versions") + .join("8.9.6") + .join("bin") + .join("php") + .to_string_lossy() + .into_owned(), + )); +} + +#[test] +fn test_which_no_active_version_fails_with_hint() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env_remove("PVM_MULTISHELL_PATH"); + cmd.arg("which"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("no pvm-managed PHP is active")); +} + +#[test] +fn test_which_fpm_only_version_fails() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["fpm"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env_remove("PVM_MULTISHELL_PATH"); + cmd.arg("which").arg("8.9.6"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("has no cli package")); +} + +#[test] +fn test_prune_removes_superseded_versions() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.3.1", &["cli"]); + seed_installed_version(temp_dir.path(), "8.3.5", &["cli"]); + seed_installed_version(temp_dir.path(), "8.4.2", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("prune").arg("-y"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Removed PHP 8.3.1")); + + assert!(!temp_dir.path().join("versions/8.3.1").exists()); + assert!(temp_dir.path().join("versions/8.3.5").exists()); + assert!(temp_dir.path().join("versions/8.4.2").exists()); +} + +#[test] +fn test_prune_keeps_active_version() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.3.1", &["cli"]); + seed_installed_version(temp_dir.path(), "8.3.5", &["cli"]); + seed_installed_version(temp_dir.path(), "8.4.2", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env( + "PVM_MULTISHELL_PATH", + temp_dir.path().join("versions/8.3.1/bin"), + ); + cmd.arg("prune").arg("-y"); + cmd.assert().success(); + + assert!(temp_dir.path().join("versions/8.3.1").exists()); + assert!(temp_dir.path().join("versions/8.3.5").exists()); + assert!(temp_dir.path().join("versions/8.4.2").exists()); +} + +#[test] +fn test_prune_repoints_default_version() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.3.1", &["cli"]); + seed_installed_version(temp_dir.path(), "8.3.5", &["cli"]); + seed_installed_version(temp_dir.path(), "8.4.2", &["cli"]); + std::fs::write(temp_dir.path().join("default"), "8.3.1").unwrap(); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("prune").arg("-y"); + cmd.assert().success(); + + let default = std::fs::read_to_string(temp_dir.path().join("default")).unwrap(); + assert_eq!(default.trim(), "8.3.5"); +} + +#[test] +fn test_prune_nothing_to_prune() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.3.5", &["cli"]); + seed_installed_version(temp_dir.path(), "8.4.2", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("prune").arg("-y"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Nothing to prune.")); + + assert!(temp_dir.path().join("versions/8.3.5").exists()); + assert!(temp_dir.path().join("versions/8.4.2").exists()); +} + #[test] fn test_use_php_version_file() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/tests/e2e/cases/15_default.sh b/tests/e2e/cases/15_default.sh new file mode 100644 index 0000000..4e86d9d --- /dev/null +++ b/tests/e2e/cases/15_default.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# `pvm default ` persists the version and `pvm env` activates it in new shells. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +PVM_DIR="${PVM_DIR:-$HOME/.local/share/pvm}" + +step "pvm default $PREVIOUS — persist + env activation" + +"$PVM_BIN" default "$PREVIOUS" +[[ "$(cat "$PVM_DIR/default")" == "$PREVIOUS" ]] \ + || fail "default file does not contain $PREVIOUS" +ok "default file written" + +# Non-TTY `pvm default` prints the stored version. +OUT=$("$PVM_BIN" default) +echo "$OUT" | grep -q "$PREVIOUS" || fail "pvm default did not print $PREVIOUS" +ok "pvm default prints $PREVIOUS" + +# A fresh shell that evals `pvm env` starts on the default version. +OUT=$(bash --norc --noprofile -c "eval \"\$('$PVM_BIN' env)\"; php -v" 2>&1) +echo "$OUT" | grep -q "$PREVIOUS" \ + || fail "new shell did not start on PHP $PREVIOUS (got: $OUT)" +ok "new shell starts on PHP $PREVIOUS via pvm env" + +# `pvm default system` clears it again. +"$PVM_BIN" default system +[[ ! -f "$PVM_DIR/default" ]] || fail "default file still present after clearing" +ok "pvm default system cleared the default" diff --git a/tests/e2e/cases/15_uninstall.sh b/tests/e2e/cases/15_uninstall.sh deleted file mode 100755 index f66d90a..0000000 --- a/tests/e2e/cases/15_uninstall.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# `pvm uninstall ` removes the version dir and pvm ls drops the entry. -# Driver runs this AFTER fpm has been shut down. -set -euo pipefail -source "$(dirname "$0")/../_lib.sh" - -step "pvm uninstall $PREVIOUS" - -"$PVM_BIN" uninstall "$PREVIOUS" - -if [[ ! -d "${PVM_DIR:-$HOME/.local/share/pvm}/versions/$PREVIOUS" ]]; then - ok "versions/$PREVIOUS directory removed" -else - fail "uninstall left versions/$PREVIOUS in place" -fi - -if "$PVM_BIN" ls 2>&1 | grep -q "$PREVIOUS"; then - fail "pvm ls still shows $PREVIOUS after uninstall" -fi -ok "pvm ls no longer lists $PREVIOUS" diff --git a/tests/e2e/cases/16_use_system.sh b/tests/e2e/cases/16_use_system.sh new file mode 100644 index 0000000..b1c50b2 --- /dev/null +++ b/tests/e2e/cases/16_use_system.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# `pvm use system` strips pvm-managed entries from PATH via the wrapper. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "pvm use system — back to system PHP" + +OUT=$(bash --norc --noprofile -c " + eval \"\$('$PVM_BIN' env)\" + pvm use $VFILTER >/dev/null 2>&1 + command -v php || true + pvm use system >/dev/null 2>&1 + echo '---AFTER---' + command -v php || true + pvm current +" 2>&1) + +BEFORE=$(echo "$OUT" | sed -n '1p') +echo "$BEFORE" | grep -q "/pvm/versions/" \ + || fail "pvm use $VFILTER did not put a pvm php on PATH (got: $BEFORE)" +ok "pvm use put php on PATH: $BEFORE" + +AFTER=$(echo "$OUT" | sed -n '/---AFTER---/,$p') +if echo "$AFTER" | grep -q "/pvm/versions/"; then + fail "pvm use system left a pvm versions entry on PATH: $AFTER" +fi +ok "no pvm versions entry on PATH after pvm use system" + +echo "$AFTER" | grep -q "system" || fail "pvm current is not 'system' after deactivation" +ok "pvm current reports system" diff --git a/tests/e2e/cases/17_install_packages.sh b/tests/e2e/cases/17_install_packages.sh new file mode 100644 index 0000000..2e48054 --- /dev/null +++ b/tests/e2e/cases/17_install_packages.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Non-interactive install: --packages skips the MultiSelect, rerun is idempotent. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +PVM_DIR="${PVM_DIR:-$HOME/.local/share/pvm}" + +step "pvm install $LATEST --packages cli — non-interactive" + +"$PVM_BIN" install "$LATEST" --packages cli --yes /dev/null))" +ok "default re-pointed to $LATEST" + +# Cleanup for the following cases. +"$PVM_BIN" default system diff --git a/tests/e2e/cases/19_uninstall.sh b/tests/e2e/cases/19_uninstall.sh new file mode 100755 index 0000000..ad339de --- /dev/null +++ b/tests/e2e/cases/19_uninstall.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# `pvm uninstall ` removes the version dir and pvm ls drops the entry. +# Runs last: 18_prune keeps only $LATEST, so that is what gets removed here. +# Driver runs this AFTER fpm has been shut down. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "pvm uninstall $LATEST" + +"$PVM_BIN" uninstall "$LATEST" + +if [[ ! -d "${PVM_DIR:-$HOME/.local/share/pvm}/versions/$LATEST" ]]; then + ok "versions/$LATEST directory removed" +else + fail "uninstall left versions/$LATEST in place" +fi + +if "$PVM_BIN" ls 2>&1 | grep -q "$LATEST"; then + fail "pvm ls still shows $LATEST after uninstall" +fi +ok "pvm ls no longer lists $LATEST"