From a1eb2ec28f8a29f2451cc0b34e8aae8536ab27cf Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 18 Sep 2026 16:07:17 +0800 Subject: [PATCH 01/31] docs: propose a Vite+-managed Homebrew tap --- rfcs/official-homebrew-tap.md | 300 ++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 rfcs/official-homebrew-tap.md diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md new file mode 100644 index 0000000000..fe33d318f0 --- /dev/null +++ b/rfcs/official-homebrew-tap.md @@ -0,0 +1,300 @@ +# RFC: Vite+-managed Homebrew tap + +- Status: Draft for discussion. This RFC does not create a tap or change installation behavior. +- Related: [Homebrew first-run failure (#2719)](https://github.com/voidzero-dev/vite-plus/issues/2719), [external installation support (#2729)](https://github.com/voidzero-dev/vite-plus/pull/2729). + +## Proposal + +Maintain a public Homebrew tap under the VoidZero organization. The proposed +repository is `voidzero-dev/homebrew-tap`, with the formula +`voidzero-dev/tap/vite-plus`. + +The formula installs a complete, prebuilt CLI bundle from a versioned GitHub +release. Homebrew owns the binary and bundled JavaScript in its Cellar. The +first `vp` invocation uses the existing Rust setup code to configure the user's +shell, shims, and management preferences. + +This shares release outputs and setup logic with the script installer. It keeps +package ownership with Homebrew: `brew upgrade` updates the package, and +`brew uninstall` removes it. The formula does not run `install.sh` or install a +second CLI into the user's data directory. + +The recommended starting point is a complete bundle, rather than a binary that +downloads its JavaScript on first use. The alternatives below remain open for +discussion. + +## Motivation and current behavior + +The [core formula](https://github.com/Homebrew/homebrew-core/blob/main/Formula/v/vite-plus.rb) +builds from source when a bottle is unavailable or a user requests a source +build. It stages upstream repositories, adjusts package-manager configuration, +runs `just build` and `cargo install`, and deploys production JavaScript with +`pnpm`. Normal bottle installs already avoid this build work. + +A project-owned tap could reuse the release build instead of maintaining a +second build recipe. It would let the project test and publish packaging changes +with the corresponding CLI release. The project would also take responsibility +for that packaging and its support. + +The canonical [script installer](../packages/cli/install.sh) downloads a native +CLI package and delegates installation to that binary's self-setup code. Old +binaries use the legacy installer. This is simpler than duplicating setup in +Ruby, but the standalone path still installs JavaScript dependencies from an +npm registry. + +Current [external setup](../crates/vp_global_cli/src/self_setup.rs) already +recognizes a Unix binary with `node_modules/vite-plus` beside its `bin` +directory. It reuses that payload and records setup in the user's state +directory. A tap can use this behavior without taking ownership of the user's +Vite+ directories. + +The existing [GitHub release archives](../.github/workflows/reusable-release-build.yml) +contain the native CLI, the version synchronization script, and the toolchain +manifest. They do not contain the complete JavaScript dependency tree. Installing +one of these archives alone would still enter standalone bootstrap. A complete +bundle is new release work, not just a new formula URL. + +## Goals and boundaries + +- Install the released CLI without a local Rust, CMake, or pnpm build. +- Obtain the complete CLI payload without contacting an npm registry on the user's machine. +- Reuse existing setup, directory resolution, runtime management, and tool dispatch. +- Keep upgrades, package removal, and package files under Homebrew's control. +- Preserve user settings and working shims across package replacement. + +The first version supports stable releases. Preview builds, historical formulae, +Windows, and musl Linux remain outside this tap's initial scope. The script +installer retains its existing platforms and version-selection behavior. + +This proposal does not remove the core formula or automatically migrate its +users. It also does not make project dependency installation or runtime downloads +work without network access. + +## Installation and ownership + +After the tap is available, a new user would run: + +```sh +brew install voidzero-dev/tap/vite-plus +vp +``` + +Use the fully qualified formula name in tap instructions to distinguish it from +`homebrew/core/vite-plus`. Homebrew can add a tap during direct installation. +See [Homebrew's tap guide](https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap#installing). + +The formula installs this layout: + +```text +/vite-plus// +├── bin/ +│ ├── vp +│ ├── vpr -> vp +│ └── vpx -> vp +├── node_modules/ +│ └── vite-plus/ # JS, native bindings, and production dependencies +├── share/ # completions and license notices +└── INSTALL_RECEIPT.json # written by Homebrew +``` + +This layout matches the current [JavaScript resolver](../crates/vp_global_cli/src/js_executor.rs) +and [Homebrew ownership check](../crates/vp_global_cli/src/homebrew.rs). +Keep the real executable at `prefix/bin/vp`; a `libexec` wrapper would require +another ownership and path-resolution contract. + +| Owner | Files and operations | +| ------------------- | --------------------------------------------------------------------------------------------------------- | +| Homebrew | Cellar payload, public command links, package version, package removal | +| Vite+ for each user | Setup receipt, shell configuration, tool shims, preferences, downloaded runtimes, caches, global packages | + +The formula only extracts and installs the bundle. It does not execute `vp` +during `install` or `post_install`, edit shell profiles, or set `VP_HOME` and +`VP_*_DIR`. Completions are generated in release CI and copied as files. +It must not ship `.vp-setup-complete`, because setup is specific to each user. + +On first use, the CLI performs the same per-user setup as other bundled external +installations. It must not copy the payload, install its npm dependencies, or +write inside the keg. It reports when the user must restart the terminal to +activate shell changes. Later invocations reuse the receipt. + +Shims must follow a stable Homebrew entrypoint, not a versioned Cellar path. +Replacing the package can refresh its receipt, but must preserve both managed +mode and mixed per-tool preferences. Tests must exercise a saved `vp` path and +a tool shim after Homebrew removes the previous keg. + +## Bundle and runtime requirements + +Add separate assets, tentatively named `vp-bundle-.tar.gz`, to the existing +release. Keep the current small archives unchanged for existing consumers. +Proposed targets are macOS ARM64 and x64, and glibc Linux ARM64 and x64. +Only advertise a target after its Homebrew installation test passes. Minimum OS, +libc, and CPU requirements must match the binaries in the bundle. + +Each bundle contains the native CLI and the complete production dependency tree +for the same version and target. Packaging must retain required optional native +packages, including Oxfmt, Oxlint, and Rolldown bindings. Blanket use of +`--no-optional` is unsuitable. Workspace links, pnpm store links, and build-machine +paths must not escape the extracted bundle. Include third-party license notices. + +Use the existing release binaries and package outputs. Add an explicit assembly +step for the production dependency tree; the current binary archive step is not +sufficient. The assembly must pin dependency versions, verify package versions, +and test the extracted bundle outside the source checkout. Fetching and preparing +dependencies happens in release CI. + +Validate archive contents and dynamic library dependencies for each target. +Missing assets, checksum failures, or an unsupported target must fail installation +without falling back to an npm bootstrap or a source build. + +The proposed formula does not depend on Homebrew's `node` and does not bundle a +private Node.js initially. It uses the current runtime resolver: system-first +mode can use Node.js on `PATH`; otherwise the CLI obtains a managed runtime. +Consequently, a first JavaScript command can still download Node.js. Choosing +system-first mode does not guarantee offline operation when no usable Node.js +exists. This runtime choice needs agreement before implementation. + +The tap removes npm access from CLI payload installation. It does not remove +network access from every command. Users still need access to GitHub release +assets and any required Homebrew downloads. Runtime downloads and project +dependencies have their own network requirements. Existing registry settings +continue to govern npm operations where supported; they do not select the +bundle URL. No new registry variable is needed for the proposed formula. + +## Commands and diagnostics + +Retain the ownership rules introduced for core installations: + +| Command | Expected behavior for the tap | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `vp upgrade` | Direct the user to `brew upgrade voidzero-dev/tap/vite-plus`; do not install a managed copy | +| `vp upgrade --check` | Direct the user to `brew outdated voidzero-dev/tap/vite-plus` | +| Upgrade with a version, `--force`, or `--rollback` | Keep the Homebrew ownership guard; do not switch installation channels | +| Automatic update check | Keep npm checks disabled for Homebrew-owned binaries | +| `vp env doctor` | Report Homebrew ownership, formula identity, binary path, and actual PATH/shim problems | +| `vp implode` | Explain and remove Vite+-managed user data; retain the keg and direct package removal to Homebrew | +| `brew uninstall voidzero-dev/tap/vite-plus` | Remove the keg and public links; retain user data | + +Current detection returns a boolean, and command messages use `vite-plus`. +Extend the ownership information to obtain the tap from the Homebrew receipt +when available. Use the formula's full name in guidance. Preserve the existing +generic guidance for older or incomplete receipts. Do not run `brew` merely to +identify the active CLI, and do not add upgrade advice to unrelated doctor output. + +For complete removal, document `vp implode` before `brew uninstall`, including +its removal of runtimes, settings, and global packages. Package removal alone +can leave user shims pointing to an absent entrypoint. Reinstalling the package +or explicitly cleaning those shims resolves that state; the formula must not +delete another user's files from an uninstall hook. + +## Release automation and recovery + +The Vite+ release pipeline assembles and tests bundles, then publishes them with +SHA-256 checksums. Formula URLs reference a specific release tag. The formula +contains each target's checksum; it never downloads a mutable `latest` URL or +executes a remote installation script. + +After the stable release and required assets are available, a separate job opens +an update PR in the tap. A narrowly scoped GitHub App token grants write access +to that repository. Ordinary PR jobs receive no publishing credentials. + +The update job verifies every required asset before changing the formula. It +uses the release version as its idempotency key, updates an existing PR on retry, +and rejects an update that would replace a newer formula with an older release. +Tap CI tests the candidate before a maintainer merges it. Automatic merging can +be considered after the process proves reliable. + +A failed tap update leaves the previous formula available. It does not undo an +already published npm or GitHub release. Report the failure in the release run +and allow the same update job to be retried without rebuilding published assets. + +Do not overwrite a published asset to repair packaging. Use an immutable packaging +revision with a new URL and checksum, or a new product release. Document recovery +for users already on a bad version; reverting a formula commit alone does not +cause Homebrew to downgrade those users. The first implementation must not promise +`vp upgrade --rollback` support for Homebrew installations. + +## Coexistence and migration + +The core formula remains independently maintained. Homebrew's +[upstream tap policy](https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap#upstream-taps) +does not promise removal or redirection of a core formula when an upstream tap +appears. Continue fixing compatibility with core installations. + +The tap and core formula share the name `vite-plus` and cannot be installed +side by side in the same Homebrew prefix. Migration is explicit. The initial +documented route should prefetch the tap package, remove the core package, and +install the fully qualified tap formula. Keep user data and settings; do not use +`vp implode` as a migration step. Check the installed receipt and active command +afterward. Test the exact sequence, dependency behavior, and recovery from a +failed installation before publishing copy-and-paste instructions. + +Switching from a script installation needs separate guidance. Its shims can +precede Homebrew's `bin` on `PATH`. Run the new Homebrew executable by its explicit +path to select it and refresh user shims. Preserve preferences, runtimes, global +packages, and the old payload. Do not silently delete the script installation. +Starting a fresh shell and checking `vp env doctor` must confirm the selected +installation. Switching back must also be tested. + +These are migration requirements, not a claim that all existing channel-switching +paths already preserve this state. Resolve any gaps in separate CLI changes +before the tap launch. + +Launch only with a release that contains the external setup fixes. There is no +requirement to backport the new tap to old Vite+ releases. Core, script, and +`vp-setup` installations retain their existing behavior. + +## Validation before launch + +Keep the formula installation test small, but cover a real bundled JavaScript +command. A Rust-only `vp --help` test cannot detect missing native dependencies. +Use an isolated user home and explicit management settings. + +The release and tap tests must cover: + +- Installation without Rust, CMake, or pnpm; no source checkout or external store links in the bundle. +- First and second invocation with a read-only keg, checking that only user setup state changes. +- Formatting, linting, and a small build outside a project-local `vite-plus` installation, to exercise bundled native packages. +- CLI startup and bundled commands with npm registry access blocked, using an available Node.js runtime to isolate that requirement. +- Runtime acquisition with no system Node.js, plus system-first behavior with an existing runtime. +- Two users sharing a package with separate setup receipts and management preferences. +- Package replacement and removal of the old keg, followed by saved `vp` and tool shim paths in existing Bash and Zsh sessions. +- Managed and mixed per-tool settings across upgrades, reinstall, and interrupted setup. +- `upgrade`, `upgrade --check`, `implode`, and doctor output for both core and tap receipts. +- Migration in both directions, script installation coexistence, package removal, and retained user data. + +Run tap checks on formula PRs and bundle checks on release candidates. In the +Vite+ repository, allow maintainers to request the full Homebrew installation +suite with the `test: install-e2e` label. Run untrusted PR code without publishing +credentials. These checks need not run on every ordinary source PR. + +## Alternatives + +| Approach | Benefit | Cost or limitation | +| ----------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Complete bundle in a project-owned tap (proposed) | Small formula; no local build or npm bootstrap; Homebrew retains ownership | New bundle assembly, larger assets, and project-owned release support | +| Existing native archive only | Small download and nearly identical to script bootstrap | Downloads JS on first use; current setup deploys a user-managed copy, so Homebrew no longer controls the active CLI | +| Run `install.sh` from a formula | Reuses the installation entrypoint directly | Writes user state during package installation and separates installed version tracking from the active CLI | +| Maintain a source formula and publish our own bottles | Conventional Homebrew build and bottle flow | Retains source recipe complexity and adds a bottle build pipeline | +| Continue with core only | No new repository or distribution channel | Packaging updates follow core's process and source build constraints | +| macOS cask | Suitable for a prebuilt macOS distribution | Requires a separate Linux solution and different installation detection | + +An upstream tap can distribute prebuilt archives; the +[Bun tap](https://github.com/oven-sh/homebrew-bun/blob/main/Formula/bun.rb) +provides an example. This does not imply that the same formula would be accepted +in core. The proposed binary formula would not offer a source-build mode; +`--build-from-source` would not recreate the upstream build. Source builds remain +available through the project and core. + +## Decisions requested + +1. Should Homebrew retain ownership of the active CLI, with complete bundles as proposed, or should the tap only bootstrap a script-managed install? +2. Is `voidzero-dev/homebrew-tap` the right repository name, and who owns release updates and support? +3. Should the initial tap cover all four proposed targets, or start with macOS while Linux tests mature? +4. Is the existing Node.js runtime resolver sufficient, or should the tap require Homebrew Node.js or ship a private runtime? +5. Should tap updates require maintainer review initially, and what packaging-repair version scheme should we use? + +After agreement, split implementation into bundle assembly, CLI ownership +metadata, the tap and its tests, and release publication. Keep product +documentation on the current installation instructions until the tap is usable. +Then update the global CLI, upgrade, and implode guides, plus the directory +layout RFC where the ownership metadata contract changes. From 646788a51cea82cc6891f81459458b2d0d477eea Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 18 Sep 2026 16:44:28 +0800 Subject: [PATCH 02/31] docs: keep Homebrew tap in vite-plus repository --- rfcs/official-homebrew-tap.md | 131 ++++++++++++++++++++++++++-------- 1 file changed, 101 insertions(+), 30 deletions(-) diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index fe33d318f0..b8b85ef537 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -5,9 +5,10 @@ ## Proposal -Maintain a public Homebrew tap under the VoidZero organization. The proposed -repository is `voidzero-dev/homebrew-tap`, with the formula -`voidzero-dev/tap/vite-plus`. +Maintain the Homebrew tap in the existing `voidzero-dev/vite-plus` repository. +Add `HomebrewFormula/vp.rb`, with the Ruby class `Vp < Formula`. The tap name is +`voidzero-dev/vite-plus`, and the formula name is `voidzero-dev/vite-plus/vp`. +`vp` is the actual formula name, not an alias for a `vite-plus` formula. The formula installs a complete, prebuilt CLI bundle from a versioned GitHub release. Homebrew owns the binary and bundled JavaScript in its Cellar. The @@ -75,18 +76,65 @@ work without network access. After the tap is available, a new user would run: ```sh -brew install voidzero-dev/tap/vite-plus +brew tap voidzero-dev/vite-plus https://github.com/voidzero-dev/vite-plus +brew install voidzero-dev/vite-plus/vp vp ``` Use the fully qualified formula name in tap instructions to distinguish it from -`homebrew/core/vite-plus`. Homebrew can add a tap during direct installation. -See [Homebrew's tap guide](https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap#installing). +`homebrew/core/vite-plus` and core's `vp` alias. The initial `brew tap` command +needs the explicit URL. Without it, Homebrew looks for +`voidzero-dev/homebrew-vite-plus`. Subsequent installs and upgrades use the saved +remote. See [the tap command documentation](https://docs.brew.sh/Manpage#tap-options-userrepo-url). + +### Repository layout + +The proposed files live beside the existing release and installation code: + +```text +vite-plus/ +├── HomebrewFormula/ +│ └── vp.rb # new: formula +├── .github/ +│ ├── workflows/ +│ │ ├── release.yml # extend: publish bundles and open formula PRs +│ │ └── test-homebrew.yml # new: installation tests +│ └── scripts/ +│ ├── package-homebrew-bundle.mjs # new: assemble the release bundle +│ └── update-homebrew-formula.mjs # new: update version, URLs, and checksums +├── packages/cli/ +│ └── install.sh # existing script installer +├── crates/vp_global_cli/src/ +│ ├── self_setup.rs # shared per-user setup +│ └── homebrew.rs # ownership and formula identity +└── rfcs/ + └── official-homebrew-tap.md +``` + +New script and workflow names are illustrative. Only `HomebrewFormula/vp.rb` +is needed for formula discovery. Release bundles remain GitHub release assets; +they are not committed to the source tree. + +Homebrew recognizes a root-level `HomebrewFormula/` directory. It taps the Git +repository, not a subdirectory URL. Standard tap installation clones the project +repository, and updates fetch its changes, including changes unrelated to the +formula. It does not limit the checkout to `HomebrewFormula/`. This is the download +cost of keeping packaging in the same repository. See +[Homebrew's tap layout documentation](https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap#creating-a-tap) +and [clone implementation](https://github.com/Homebrew/brew/blob/main/Library/Homebrew/tap.rb). + +The formula on `main` must reference a published stable release, even while the +source contains newer development work. Update it only after the corresponding +bundles and checksums are available. Formula PRs follow the repository's review +rules; CI should use path filters to avoid unnecessary product builds for these +packaging-only changes. + +### Installed layout The formula installs this layout: ```text -/vite-plus// +/vp// ├── bin/ │ ├── vp │ ├── vpr -> vp @@ -101,6 +149,8 @@ This layout matches the current [JavaScript resolver](../crates/vp_global_cli/sr and [Homebrew ownership check](../crates/vp_global_cli/src/homebrew.rs). Keep the real executable at `prefix/bin/vp`; a `libexec` wrapper would require another ownership and path-resolution contract. +The npm package remains named `vite-plus`; the Homebrew formula name does not +change JavaScript package names or Vite+-managed directory names. | Owner | Files and operations | | ------------------- | --------------------------------------------------------------------------------------------------------- | @@ -166,19 +216,23 @@ Retain the ownership rules introduced for core installations: | Command | Expected behavior for the tap | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| `vp upgrade` | Direct the user to `brew upgrade voidzero-dev/tap/vite-plus`; do not install a managed copy | -| `vp upgrade --check` | Direct the user to `brew outdated voidzero-dev/tap/vite-plus` | +| `vp upgrade` | Direct the user to `brew upgrade voidzero-dev/vite-plus/vp`; do not install a managed copy | +| `vp upgrade --check` | Direct the user to `brew outdated voidzero-dev/vite-plus/vp` | | Upgrade with a version, `--force`, or `--rollback` | Keep the Homebrew ownership guard; do not switch installation channels | | Automatic update check | Keep npm checks disabled for Homebrew-owned binaries | | `vp env doctor` | Report Homebrew ownership, formula identity, binary path, and actual PATH/shim problems | | `vp implode` | Explain and remove Vite+-managed user data; retain the keg and direct package removal to Homebrew | -| `brew uninstall voidzero-dev/tap/vite-plus` | Remove the keg and public links; retain user data | +| `brew uninstall voidzero-dev/vite-plus/vp` | Remove the keg and public links; retain user data | Current detection returns a boolean, and command messages use `vite-plus`. -Extend the ownership information to obtain the tap from the Homebrew receipt -when available. Use the formula's full name in guidance. Preserve the existing -generic guidance for older or incomplete receipts. Do not run `brew` merely to -identify the active CLI, and do not add upgrade advice to unrelated doctor output. +Extend the ownership information to identify the installed formula and read +`source.tap` from the Homebrew receipt when available. Use the full name in +guidance: `voidzero-dev/vite-plus/vp` for this tap and `homebrew/core/vite-plus` +for core. Do not infer the formula name from the npm package name. If an older +or incomplete receipt prevents identification, keep the ownership guard and +ask the user to check the installed formula rather than guess its name. +Do not run `brew` merely to identify the active CLI, and do not add upgrade +advice to unrelated doctor output. For complete removal, document `vp implode` before `brew uninstall`, including its removal of runtimes, settings, and global packages. Package removal alone @@ -194,14 +248,17 @@ contains each target's checksum; it never downloads a mutable `latest` URL or executes a remote installation script. After the stable release and required assets are available, a separate job opens -an update PR in the tap. A narrowly scoped GitHub App token grants write access -to that repository. Ordinary PR jobs receive no publishing credentials. +a PR in `voidzero-dev/vite-plus` to update `HomebrewFormula/vp.rb`. The job only +needs write access to this repository. Its credentials must support the required +PR checks, either through normal events or an explicit test workflow dispatch. +Ordinary PR jobs receive no publishing credentials. The update job verifies every required asset before changing the formula. It uses the release version as its idempotency key, updates an existing PR on retry, and rejects an update that would replace a newer formula with an older release. -Tap CI tests the candidate before a maintainer merges it. Automatic merging can -be considered after the process proves reliable. +The Homebrew workflow tests the candidate before a maintainer merges it. A +formula-only merge must not publish another product release. Automatic merging +can be considered after the process proves reliable. A failed tap update leaves the previous formula available. It does not undo an already published npm or GitHub release. Report the failure in the release run @@ -220,11 +277,16 @@ The core formula remains independently maintained. Homebrew's does not promise removal or redirection of a core formula when an upstream tap appears. Continue fixing compatibility with core installations. -The tap and core formula share the name `vite-plus` and cannot be installed -side by side in the same Homebrew prefix. Migration is explicit. The initial -documented route should prefetch the tap package, remove the core package, and -install the fully qualified tap formula. Keep user data and settings; do not use -`vp implode` as a migration step. Check the installed receipt and active command +The tap formula is named `vp`, while the core formula is named `vite-plus`. +They use different Cellar directories, but both install `vp`, `vpr`, and `vpx` +into Homebrew's public `bin`. Declare a conflict with `vite-plus` in the tap +formula and document an explicit migration. Do not overwrite the existing links +or use core's `vp` alias to select the tap package. + +The initial documented route should prefetch `voidzero-dev/vite-plus/vp`, remove +`homebrew/core/vite-plus`, and install the fully qualified tap formula. Keep user +data and settings; do not use `vp implode` as a migration step. Check the +installed receipt and active command afterward. Test the exact sequence, dependency behavior, and recovery from a failed installation before publishing copy-and-paste instructions. @@ -259,23 +321,32 @@ The release and tap tests must cover: - Two users sharing a package with separate setup receipts and management preferences. - Package replacement and removal of the old keg, followed by saved `vp` and tool shim paths in existing Bash and Zsh sessions. - Managed and mixed per-tool settings across upgrades, reinstall, and interrupted setup. -- `upgrade`, `upgrade --check`, `implode`, and doctor output for both core and tap receipts. +- `upgrade`, `upgrade --check`, `implode`, and doctor output for both formula names, including incomplete receipts. +- Formula discovery through the explicit repository URL, conflict handling, and core's existing `vp` alias. - Migration in both directions, script installation coexistence, package removal, and retained user data. -Run tap checks on formula PRs and bundle checks on release candidates. In the -Vite+ repository, allow maintainers to request the full Homebrew installation -suite with the `test: install-e2e` label. Run untrusted PR code without publishing -credentials. These checks need not run on every ordinary source PR. +Run Homebrew checks on PRs that change `HomebrewFormula/vp.rb` or its packaging +scripts, and bundle checks on release candidates. Allow maintainers to request +the full Homebrew installation suite for other source changes with the +`test: install-e2e` label. Run untrusted PR code without publishing credentials. +These checks need not run on every ordinary source PR. ## Alternatives +A separate repository such as `voidzero-dev/homebrew-tap` would provide a smaller +checkout and direct installation without an initial custom-URL tap command. It +would also need separate repository administration and release-update access. +Keeping the formula in `vite-plus` puts code, packaging, and tests in one review +process. A separate tap remains an option if checkout size becomes a problem; +it is not required for the installation design. + | Approach | Benefit | Cost or limitation | | ----------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | Complete bundle in a project-owned tap (proposed) | Small formula; no local build or npm bootstrap; Homebrew retains ownership | New bundle assembly, larger assets, and project-owned release support | | Existing native archive only | Small download and nearly identical to script bootstrap | Downloads JS on first use; current setup deploys a user-managed copy, so Homebrew no longer controls the active CLI | | Run `install.sh` from a formula | Reuses the installation entrypoint directly | Writes user state during package installation and separates installed version tracking from the active CLI | | Maintain a source formula and publish our own bottles | Conventional Homebrew build and bottle flow | Retains source recipe complexity and adds a bottle build pipeline | -| Continue with core only | No new repository or distribution channel | Packaging updates follow core's process and source build constraints | +| Continue with core only | No additional formula or distribution channel | Packaging updates follow core's process and source build constraints | | macOS cask | Suitable for a prebuilt macOS distribution | Requires a separate Linux solution and different installation detection | An upstream tap can distribute prebuilt archives; the @@ -288,7 +359,7 @@ available through the project and core. ## Decisions requested 1. Should Homebrew retain ownership of the active CLI, with complete bundles as proposed, or should the tap only bootstrap a script-managed install? -2. Is `voidzero-dev/homebrew-tap` the right repository name, and who owns release updates and support? +2. Who owns formula updates and Homebrew support within the Vite+ project? 3. Should the initial tap cover all four proposed targets, or start with macOS while Linux tests mature? 4. Is the existing Node.js runtime resolver sufficient, or should the tap require Homebrew Node.js or ship a private runtime? 5. Should tap updates require maintainer review initially, and what packaging-repair version scheme should we use? From f509b98885346dafba0967e7d3b9852802a7457d Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 18 Sep 2026 17:20:48 +0800 Subject: [PATCH 03/31] docs: link original Homebrew distribution requirement --- rfcs/official-homebrew-tap.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index b8b85ef537..e8ebc05a8b 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -1,6 +1,7 @@ # RFC: Vite+-managed Homebrew tap - Status: Draft for discussion. This RFC does not create a tap or change installation behavior. +- Original requirement: [Distributing and Installing with Homebrew (#1171)](https://github.com/voidzero-dev/vite-plus/issues/1171). - Related: [Homebrew first-run failure (#2719)](https://github.com/voidzero-dev/vite-plus/issues/2719), [external installation support (#2729)](https://github.com/voidzero-dev/vite-plus/pull/2729). ## Proposal @@ -26,6 +27,14 @@ discussion. ## Motivation and current behavior +Issue [#1171](https://github.com/voidzero-dev/vite-plus/issues/1171) requested +Homebrew distribution for Bluefin Dx and other Universal Blue / Atomic Fedora +systems. It also identified enterprise management through WorkBrew as a use +case. These requirements motivate Linux support and Homebrew ownership of +installation, upgrades, and removal. Issue +[#2719](https://github.com/voidzero-dev/vite-plus/issues/2719) later exposed a +first-run setup failure with the core formula on macOS. + The [core formula](https://github.com/Homebrew/homebrew-core/blob/main/Formula/v/vite-plus.rb) builds from source when a bottle is unavailable or a user requests a source build. It stages upstream repositories, adjusts package-manager configuration, From 7d26116af6f71d978b15a83f5d4444ad7f1835df Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 18 Sep 2026 23:29:07 +0800 Subject: [PATCH 04/31] docs: describe authenticated npm downloads for Homebrew tap --- rfcs/official-homebrew-tap.md | 68 +++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index e8ebc05a8b..81d1ed27dc 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -212,12 +212,13 @@ Consequently, a first JavaScript command can still download Node.js. Choosing system-first mode does not guarantee offline operation when no usable Node.js exists. This runtime choice needs agreement before implementation. -The tap removes npm access from CLI payload installation. It does not remove -network access from every command. Users still need access to GitHub release +The GitHub bundle proposal removes npm access from CLI payload installation. +It does not remove network access from every command. Users still need access to GitHub release assets and any required Homebrew downloads. Runtime downloads and project dependencies have their own network requirements. Existing registry settings continue to govern npm operations where supported; they do not select the -bundle URL. No new registry variable is needed for the proposed formula. +bundle URL. No new registry variable is needed for GitHub bundles. The npm +download alternative below has separate registry and authentication requirements. ## Commands and diagnostics @@ -365,6 +366,66 @@ in core. The proposed binary formula would not offer a source-build mode; `--build-from-source` would not recreate the upstream build. Source builds remain available through the project and core. +### npm registry downloads and authentication + +The tap could download the existing `@voidzero-dev/vite-plus-cli-` +package from an npm registry. This option keeps the npm package format unchanged. +It still needs a decision about how to install the JavaScript dependencies under +Homebrew ownership; downloading the native binary alone does not provide them. +Registry authentication must also cover those dependency downloads if this +option is selected. + +Use a nonempty `HOMEBREW_VP_NPM_REGISTRY` as a tap-specific default registry override. +Otherwise, read `registry` from `~/.npmrc`, then fall back to +`https://registry.npmjs.org`. Honor scoped registry settings such as +`@voidzero-dev:registry` for the corresponding packages, as npm does. These scoped +settings take precedence over the default registry. Do not read a project's +`.npmrc` based on the directory from which the user runs `brew`. + +Homebrew's [launcher](https://github.com/Homebrew/brew/blob/main/bin/brew) removes +`NPM_CONFIG_REGISTRY` and ordinary token variables before formula evaluation. +It preserves `HOMEBREW_*` variables. Users could forward an existing setting: + +```sh +HOMEBREW_VP_NPM_REGISTRY="${NPM_CONFIG_REGISTRY:-}" \ + brew install voidzero-dev/vite-plus/vp +``` + +Support registry-scoped `_authToken` entries in `~/.npmrc`. For example, a user +could configure the following and export `HOMEBREW_VP_NPM_TOKEN` in their shell: + +```ini +registry=https://registry.example.com/repository/npm/ +//registry.example.com/repository/npm/:_authToken=${HOMEBREW_VP_NPM_TOKEN} +``` + +Literal tokens in the file are also supported by this design. Existing entries +that reference `${NPM_TOKEN}` need an explicit forwarding mechanism or a change +to the placeholder; that variable does not survive Homebrew's launcher. Do not +silently substitute a different token or send an unresolved placeholder. + +Resolve user configuration and credentials at download time in a tap-owned +download strategy. Do not read credentials during formula evaluation or from +`install`: Homebrew's [build sandbox](https://github.com/Homebrew/brew/blob/main/Library/Homebrew/sandbox.rb) +restricts access to the user's home, including `.npmrc`. Keep registry tokens +out of formula metadata, receipts, downloaded artifacts, and diagnostic output. +The install step should consume downloaded files without user credentials. + +Use the pinned package version's metadata to obtain `dist.tarball`; private +registries can use different tarball paths. Match credentials to each request's +host and path, including tarball requests and redirects. A registry override must +not send another registry's token to the new URL. Retain the formula's SHA-256 +check, and report authentication failures without falling back to the public +registry. Follow npm's [configuration and authentication rules](https://docs.npmjs.com/cli/v11/configuring-npm/npmrc/). +This initial option covers bearer tokens; other enterprise requirements such as +client certificates need separate design and validation. + +Before selecting this option, use an isolated registry and synthetic `.npmrc` +files to check registry precedence, scoped tokens, token substitution, custom +tarball paths, redirects, authentication failures, checksum failures, and secret +redaction. Test `brew fetch`, installation, reinstall, and upgrade on macOS and +Linux. This is a proposed download design, not existing tap functionality. + ## Decisions requested 1. Should Homebrew retain ownership of the active CLI, with complete bundles as proposed, or should the tap only bootstrap a script-managed install? @@ -372,6 +433,7 @@ available through the project and core. 3. Should the initial tap cover all four proposed targets, or start with macOS while Linux tests mature? 4. Is the existing Node.js runtime resolver sufficient, or should the tap require Homebrew Node.js or ship a private runtime? 5. Should tap updates require maintainer review initially, and what packaging-repair version scheme should we use? +6. Should the tap use GitHub bundles, or existing npm packages with `HOMEBREW_VP_NPM_REGISTRY` and authenticated `~/.npmrc` support? The npm option also needs a dependency installation design. After agreement, split implementation into bundle assembly, CLI ownership metadata, the tap and its tests, and release publication. Keep product From cb7ba6af18844f28352ff9f8bb2e5c4379b97a71 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 18 Sep 2026 23:37:33 +0800 Subject: [PATCH 05/31] docs: rename proposed Homebrew npm registry variable --- rfcs/official-homebrew-tap.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index 81d1ed27dc..9c4da51e73 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -375,19 +375,20 @@ Homebrew ownership; downloading the native binary alone does not provide them. Registry authentication must also cover those dependency downloads if this option is selected. -Use a nonempty `HOMEBREW_VP_NPM_REGISTRY` as a tap-specific default registry override. +Use a nonempty `HOMEBREW_NPM_CONFIG_REGISTRY` as the default registry override. Otherwise, read `registry` from `~/.npmrc`, then fall back to `https://registry.npmjs.org`. Honor scoped registry settings such as `@voidzero-dev:registry` for the corresponding packages, as npm does. These scoped settings take precedence over the default registry. Do not read a project's `.npmrc` based on the directory from which the user runs `brew`. +The tap implements this setting; it is not a built-in Homebrew setting. Homebrew's [launcher](https://github.com/Homebrew/brew/blob/main/bin/brew) removes `NPM_CONFIG_REGISTRY` and ordinary token variables before formula evaluation. It preserves `HOMEBREW_*` variables. Users could forward an existing setting: ```sh -HOMEBREW_VP_NPM_REGISTRY="${NPM_CONFIG_REGISTRY:-}" \ +HOMEBREW_NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY:-}" \ brew install voidzero-dev/vite-plus/vp ``` @@ -433,7 +434,7 @@ Linux. This is a proposed download design, not existing tap functionality. 3. Should the initial tap cover all four proposed targets, or start with macOS while Linux tests mature? 4. Is the existing Node.js runtime resolver sufficient, or should the tap require Homebrew Node.js or ship a private runtime? 5. Should tap updates require maintainer review initially, and what packaging-repair version scheme should we use? -6. Should the tap use GitHub bundles, or existing npm packages with `HOMEBREW_VP_NPM_REGISTRY` and authenticated `~/.npmrc` support? The npm option also needs a dependency installation design. +6. Should the tap use GitHub bundles, or existing npm packages with `HOMEBREW_NPM_CONFIG_REGISTRY` and authenticated `~/.npmrc` support? The npm option also needs a dependency installation design. After agreement, split implementation into bundle assembly, CLI ownership metadata, the tap and its tests, and release publication. Keep product From ddfd0833a6e1da624b3e486a650b2e2d78182b88 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 18 Sep 2026 23:41:50 +0800 Subject: [PATCH 06/31] docs: rename proposed Homebrew npm token variable --- rfcs/official-homebrew-tap.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index 9c4da51e73..591a2b0d5c 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -393,11 +393,11 @@ HOMEBREW_NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY:-}" \ ``` Support registry-scoped `_authToken` entries in `~/.npmrc`. For example, a user -could configure the following and export `HOMEBREW_VP_NPM_TOKEN` in their shell: +could configure the following and export `HOMEBREW_NPM_CONFIG_TOKEN` in their shell: ```ini registry=https://registry.example.com/repository/npm/ -//registry.example.com/repository/npm/:_authToken=${HOMEBREW_VP_NPM_TOKEN} +//registry.example.com/repository/npm/:_authToken=${HOMEBREW_NPM_CONFIG_TOKEN} ``` Literal tokens in the file are also supported by this design. Existing entries From 0d08fbcc125d203b04d4ba2bc20e776016c20af6 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 18 Sep 2026 23:42:24 +0800 Subject: [PATCH 07/31] docs: use HOMEBREW_NPM_TOKEN in tap RFC --- rfcs/official-homebrew-tap.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index 591a2b0d5c..1c060c5d32 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -393,11 +393,11 @@ HOMEBREW_NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY:-}" \ ``` Support registry-scoped `_authToken` entries in `~/.npmrc`. For example, a user -could configure the following and export `HOMEBREW_NPM_CONFIG_TOKEN` in their shell: +could configure the following and export `HOMEBREW_NPM_TOKEN` in their shell: ```ini registry=https://registry.example.com/repository/npm/ -//registry.example.com/repository/npm/:_authToken=${HOMEBREW_NPM_CONFIG_TOKEN} +//registry.example.com/repository/npm/:_authToken=${HOMEBREW_NPM_TOKEN} ``` Literal tokens in the file are also supported by this design. Existing entries From 14870f95c1bca4e70d6e7864cff41257c8902352 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 00:02:04 +0800 Subject: [PATCH 08/31] docs: make npm primary in Homebrew tap RFC --- rfcs/official-homebrew-tap.md | 587 +++++++++++++++++----------------- 1 file changed, 297 insertions(+), 290 deletions(-) diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index 1c060c5d32..6866f8c2c3 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -11,74 +11,89 @@ Add `HomebrewFormula/vp.rb`, with the Ruby class `Vp < Formula`. The tap name is `voidzero-dev/vite-plus`, and the formula name is `voidzero-dev/vite-plus/vp`. `vp` is the actual formula name, not an alias for a `vite-plus` formula. -The formula installs a complete, prebuilt CLI bundle from a versioned GitHub -release. Homebrew owns the binary and bundled JavaScript in its Cellar. The -first `vp` invocation uses the existing Rust setup code to configure the user's -shell, shims, and management preferences. +Use the npm registry as the primary distribution source. Download the existing +`@voidzero-dev/vite-plus-cli-` package for the native binary and install +`vite-plus` at the same version with its production dependencies. Keep the npm +package contents unchanged. No new complete release bundle is required. -This shares release outputs and setup logic with the script installer. It keeps -package ownership with Homebrew: `brew upgrade` updates the package, and -`brew uninstall` removes it. The formula does not run `install.sh` or install a -second CLI into the user's data directory. +Follow the script installer's sequence: acquire the native binary, prepare its +JavaScript dependencies, then configure the user's environment. Homebrew performs +the first two steps in its installation directories. The first `vp` invocation +uses the existing Rust setup code for shell configuration, shims, and preferences. +Homebrew owns the installed binary and JavaScript, including upgrades and removal. -The recommended starting point is a complete bundle, rather than a binary that -downloads its JavaScript on first use. The alternatives below remain open for -discussion. +Support `HOMEBREW_NPM_CONFIG_REGISTRY` and registry credentials in `~/.npmrc` +through a tap-owned downloader. Use `HOMEBREW_NPM_TOKEN` in token placeholders +when the value must pass through Homebrew's environment filter. -## Motivation and current behavior +## Motivation and feasibility Issue [#1171](https://github.com/voidzero-dev/vite-plus/issues/1171) requested Homebrew distribution for Bluefin Dx and other Universal Blue / Atomic Fedora systems. It also identified enterprise management through WorkBrew as a use -case. These requirements motivate Linux support and Homebrew ownership of -installation, upgrades, and removal. Issue -[#2719](https://github.com/voidzero-dev/vite-plus/issues/2719) later exposed a -first-run setup failure with the core formula on macOS. +case. Issue [#2719](https://github.com/voidzero-dev/vite-plus/issues/2719) later +exposed a first-run setup failure with the core formula on macOS. The [core formula](https://github.com/Homebrew/homebrew-core/blob/main/Formula/v/vite-plus.rb) builds from source when a bottle is unavailable or a user requests a source -build. It stages upstream repositories, adjusts package-manager configuration, -runs `just build` and `cargo install`, and deploys production JavaScript with -`pnpm`. Normal bottle installs already avoid this build work. - -A project-owned tap could reuse the release build instead of maintaining a -second build recipe. It would let the project test and publish packaging changes -with the corresponding CLI release. The project would also take responsibility -for that packaging and its support. - -The canonical [script installer](../packages/cli/install.sh) downloads a native -CLI package and delegates installation to that binary's self-setup code. Old -binaries use the legacy installer. This is simpler than duplicating setup in -Ruby, but the standalone path still installs JavaScript dependencies from an -npm registry. - -Current [external setup](../crates/vp_global_cli/src/self_setup.rs) already -recognizes a Unix binary with `node_modules/vite-plus` beside its `bin` -directory. It reuses that payload and records setup in the user's state -directory. A tap can use this behavior without taking ownership of the user's -Vite+ directories. - -The existing [GitHub release archives](../.github/workflows/reusable-release-build.yml) -contain the native CLI, the version synchronization script, and the toolchain -manifest. They do not contain the complete JavaScript dependency tree. Installing -one of these archives alone would still enter standalone bootstrap. A complete -bundle is new release work, not just a new formula URL. +build. It stages upstream repositories, runs Rust and JavaScript builds, and +deploys the resulting package. A project-owned tap can reuse published npm +packages and avoid those source builds. + +The [script installer](../packages/cli/install.sh) already downloads the native +CLI from npm. Its self-setup code installs `vite-plus` through a wrapper manifest +and a pinned pnpm version. The [package publisher](../packages/cli/publish-native-addons.ts) +publishes the native CLI separately from the JavaScript package and native +bindings. These existing packages provide the inputs for the tap. + +Current [external setup](../crates/vp_global_cli/src/self_setup.rs) recognizes a +Unix binary with `node_modules/vite-plus` beside its `bin` directory. It reuses +that payload and records setup in the user's state directory. The +[JavaScript resolver](../crates/vp_global_cli/src/js_executor.rs) already supports +this layout. The registry-based approach therefore does not need a new package +format or a new JavaScript resolution layout. + +Running `install.sh` unchanged inside a formula would use the installing user's +data directories and configure that user's shell. Installing only its binary +would still trigger standalone bootstrap on first use. The tap must prepare the +JavaScript dependencies in the keg before normal CLI execution. + +| Step | Script installation | Homebrew installation | +| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------- | +| Version selection | Requested version or npm tag | Exact version pinned by the formula | +| Native CLI | Existing platform npm tarball | The same package, with a formula checksum | +| JavaScript dependencies | pnpm installs into the user's version directory | Pinned pnpm installs into staging; Homebrew installs the result into the keg | +| User setup | Runs during installer handoff | Runs on the user's first `vp` invocation | +| Upgrade and removal | `vp upgrade` and `vp implode` | Homebrew manages the package; `vp implode` manages user data | + +The current Rust [dependency installer](../crates/vp_setup/src/install.rs) +also provisions managed Node.js and pnpm. The formula should reuse its package +selection and installation layout, with Homebrew supplying the build environment. +It must not call this user-installation routine unchanged. Reading `.npmrc` for +native tarball authentication is also new download logic; the current shell +installer does not provide that functionality. ## Goals and boundaries -- Install the released CLI without a local Rust, CMake, or pnpm build. -- Obtain the complete CLI payload without contacting an npm registry on the user's machine. -- Reuse existing setup, directory resolution, runtime management, and tool dispatch. -- Keep upgrades, package removal, and package files under Homebrew's control. +- Install existing released npm packages without compiling Vite+ from source. +- Use the same native package, JavaScript version, and pnpm installation model as the script installer. +- Support corporate npm registries and scoped bearer-token authentication. +- Keep package files, upgrades, and removal under Homebrew's control. - Preserve user settings and working shims across package replacement. +- Keep all published package formats unchanged. -The first version supports stable releases. Preview builds, historical formulae, -Windows, and musl Linux remain outside this tap's initial scope. The script -installer retains its existing platforms and version-selection behavior. +The first version supports stable releases. Proposed targets are macOS ARM64 +and x64, and glibc Linux ARM64 and x64. Advertise each target only after its +Homebrew installation tests pass. Windows, musl Linux, preview builds, and +historical formulae remain outside the initial tap scope. -This proposal does not remove the core formula or automatically migrate its -users. It also does not make project dependency installation or runtime downloads -work without network access. +Registry access is required to acquire the CLI and its dependencies unless the +files are already cached. A corporate mirror can provide that access when the +public npm registry is blocked. This design does not provide installation when +both the public registry and all configured mirrors are inaccessible. +Homebrew dependencies, Node.js runtime downloads, and project dependencies have +their own network requirements. The core formula and script installer remain +available independently. ## Installation and ownership @@ -90,57 +105,102 @@ brew install voidzero-dev/vite-plus/vp vp ``` -Use the fully qualified formula name in tap instructions to distinguish it from +Use the fully qualified formula name to distinguish it from `homebrew/core/vite-plus` and core's `vp` alias. The initial `brew tap` command -needs the explicit URL. Without it, Homebrew looks for +needs the explicit URL. Otherwise, Homebrew looks for `voidzero-dev/homebrew-vite-plus`. Subsequent installs and upgrades use the saved remote. See [the tap command documentation](https://docs.brew.sh/Manpage#tap-options-userrepo-url). ### Repository layout -The proposed files live beside the existing release and installation code: - ```text vite-plus/ ├── HomebrewFormula/ -│ └── vp.rb # new: formula +│ ├── vp.rb # new: formula +│ └── vp/ +│ ├── package.json # new: pinned installation manifest +│ ├── pnpm-lock.yaml # new: production dependency lock +│ └── resources.json # new: package versions and checksums by target +├── lib/homebrew/ +│ └── vp_npm_download_strategy.rb # new: registry configuration and authenticated downloads ├── .github/ │ ├── workflows/ -│ │ ├── release.yml # extend: publish bundles and open formula PRs +│ │ ├── release.yml # extend: open formula update PRs after npm publication │ │ └── test-homebrew.yml # new: installation tests │ └── scripts/ -│ ├── package-homebrew-bundle.mjs # new: assemble the release bundle -│ └── update-homebrew-formula.mjs # new: update version, URLs, and checksums +│ └── update-homebrew-formula.mjs # new: update version, lock, and resource checksums ├── packages/cli/ │ └── install.sh # existing script installer ├── crates/vp_global_cli/src/ -│ ├── self_setup.rs # shared per-user setup +│ ├── self_setup.rs # existing per-user setup │ └── homebrew.rs # ownership and formula identity └── rfcs/ └── official-homebrew-tap.md ``` -New script and workflow names are illustrative. Only `HomebrewFormula/vp.rb` -is needed for formula discovery. Release bundles remain GitHub release assets; -they are not committed to the source tree. +Support-file names are illustrative. Keep Ruby support code outside the formula +directory. The lock and resource manifest are small text files; npm tarballs, +package caches, and installed dependencies are not committed to the repository. -Homebrew recognizes a root-level `HomebrewFormula/` directory. It taps the Git -repository, not a subdirectory URL. Standard tap installation clones the project -repository, and updates fetch its changes, including changes unrelated to the -formula. It does not limit the checkout to `HomebrewFormula/`. This is the download +Homebrew recognizes `HomebrewFormula/` at the repository root. Standard tap +installation clones the project repository, including files unrelated to the +formula. It does not limit checkout to this directory. This is the download cost of keeping packaging in the same repository. See [Homebrew's tap layout documentation](https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap#creating-a-tap) and [clone implementation](https://github.com/Homebrew/brew/blob/main/Library/Homebrew/tap.rb). -The formula on `main` must reference a published stable release, even while the -source contains newer development work. Update it only after the corresponding -bundles and checksums are available. Formula PRs follow the repository's review -rules; CI should use path filters to avoid unnecessary product builds for these -packaging-only changes. - -### Installed layout - -The formula installs this layout: +The formula on `main` references the last published stable release. Update its +version, dependency lock, and checksums together after the npm packages become +available. Formula PRs follow repository review rules. Use CI path filters to +avoid unrelated product builds for formula-only changes. + +### Package preparation + +The proposed formula uses the following sequence: + +1. Fetch the native CLI and the pinned pnpm package from npm as Homebrew resources. +2. Fetch the locked production dependency tarballs for the target, including required optional native packages. +3. Stage an installation manifest that pins `vite-plus` to the native CLI's version. Point a working copy of the lockfile at the verified local tarballs. +4. Run the pinned pnpm with `--offline --frozen-lockfile --prod --ignore-scripts` in an isolated build directory. +5. Install the native executable into `prefix/bin` and the resulting dependency tree into `prefix/node_modules`. Add `vpr` and `vpx` links to `vp`. + +Declare Homebrew Node.js as a build dependency to run pnpm. Pin pnpm to the +version used by the standalone dependency installer, currently `10.33.0`, and +update that selection deliberately. The formula does not depend on a user's +Node.js or pnpm installation. No Rust, CMake, or upstream source checkout is +needed to prepare Vite+ itself. + +Generate the wrapper manifest, lock, and resource list during formula-update CI. +Pin transitive versions and integrity values, not only `vite-plus`. The resource +list must match the lock and select the correct OS, CPU, and libc packages. +Use Homebrew's normal resource download stage for authenticated acquisition, +before the build sandbox. Each resource retains a reviewed checksum. The offline +installation then needs neither registry credentials nor access to `~/.npmrc`. +This uses Homebrew's [resource downloads](https://docs.brew.sh/Formula-Cookbook#language-specific-dependencies) +and pnpm's [offline installation mode](https://pnpm.io/cli/install#--offline). + +The working lockfile may replace remote tarball locations with staged `file:` +locations while retaining package versions and integrity values. This adapts +Homebrew's verified downloads to pnpm's offline installer. Do not replace locked +integrity values when a mirror serves different bytes. + +Use an isolated pnpm store and copy package files into the installed tree. +Internal relative pnpm links are acceptable; no link may depend on the build +directory, an external store, or the user's files. The installed CLI must work +after the build directory and store are removed. + +Retain native optional dependencies such as Oxfmt, Oxlint, Rolldown, and the +required platform bindings. Do not use `--no-optional`. The initial design skips +dependency lifecycle scripts and relies on published prebuilt files. Functional +tests must detect any release that starts requiring a build or postinstall +script. Such a release needs a packaging decision before the formula advances. + +This preparation follows the script installer's package installation model. +The locked resource list and offline installation are Homebrew-specific additions +that make downloads verifiable and keep credentials outside the build sandbox. +They do not change the published npm packages or require new GitHub bundles. + +### Installed layout and first use ```text /vp// @@ -149,76 +209,83 @@ The formula installs this layout: │ ├── vpr -> vp │ └── vpx -> vp ├── node_modules/ -│ └── vite-plus/ # JS, native bindings, and production dependencies -├── share/ # completions and license notices +│ ├── vite-plus/ # may be an internal relative pnpm link +│ └── .pnpm/ # package files and production dependencies └── INSTALL_RECEIPT.json # written by Homebrew ``` -This layout matches the current [JavaScript resolver](../crates/vp_global_cli/src/js_executor.rs) -and [Homebrew ownership check](../crates/vp_global_cli/src/homebrew.rs). -Keep the real executable at `prefix/bin/vp`; a `libexec` wrapper would require -another ownership and path-resolution contract. -The npm package remains named `vite-plus`; the Homebrew formula name does not -change JavaScript package names or Vite+-managed directory names. - -| Owner | Files and operations | -| ------------------- | --------------------------------------------------------------------------------------------------------- | -| Homebrew | Cellar payload, public command links, package version, package removal | -| Vite+ for each user | Setup receipt, shell configuration, tool shims, preferences, downloaded runtimes, caches, global packages | - -The formula only extracts and installs the bundle. It does not execute `vp` -during `install` or `post_install`, edit shell profiles, or set `VP_HOME` and -`VP_*_DIR`. Completions are generated in release CI and copied as files. -It must not ship `.vp-setup-complete`, because setup is specific to each user. - -On first use, the CLI performs the same per-user setup as other bundled external -installations. It must not copy the payload, install its npm dependencies, or -write inside the keg. It reports when the user must restart the terminal to -activate shell changes. Later invocations reuse the receipt. - -Shims must follow a stable Homebrew entrypoint, not a versioned Cellar path. -Replacing the package can refresh its receipt, but must preserve both managed -mode and mixed per-tool preferences. Tests must exercise a saved `vp` path and -a tool shim after Homebrew removes the previous keg. - -## Bundle and runtime requirements - -Add separate assets, tentatively named `vp-bundle-.tar.gz`, to the existing -release. Keep the current small archives unchanged for existing consumers. -Proposed targets are macOS ARM64 and x64, and glibc Linux ARM64 and x64. -Only advertise a target after its Homebrew installation test passes. Minimum OS, -libc, and CPU requirements must match the binaries in the bundle. - -Each bundle contains the native CLI and the complete production dependency tree -for the same version and target. Packaging must retain required optional native -packages, including Oxfmt, Oxlint, and Rolldown bindings. Blanket use of -`--no-optional` is unsuitable. Workspace links, pnpm store links, and build-machine -paths must not escape the extracted bundle. Include third-party license notices. - -Use the existing release binaries and package outputs. Add an explicit assembly -step for the production dependency tree; the current binary archive step is not -sufficient. The assembly must pin dependency versions, verify package versions, -and test the extracted bundle outside the source checkout. Fetching and preparing -dependencies happens in release CI. - -Validate archive contents and dynamic library dependencies for each target. -Missing assets, checksum failures, or an unsupported target must fail installation -without falling back to an npm bootstrap or a source build. - -The proposed formula does not depend on Homebrew's `node` and does not bundle a -private Node.js initially. It uses the current runtime resolver: system-first -mode can use Node.js on `PATH`; otherwise the CLI obtains a managed runtime. -Consequently, a first JavaScript command can still download Node.js. Choosing -system-first mode does not guarantee offline operation when no usable Node.js -exists. This runtime choice needs agreement before implementation. - -The GitHub bundle proposal removes npm access from CLI payload installation. -It does not remove network access from every command. Users still need access to GitHub release -assets and any required Homebrew downloads. Runtime downloads and project -dependencies have their own network requirements. Existing registry settings -continue to govern npm operations where supported; they do not select the -bundle URL. No new registry variable is needed for GitHub bundles. The npm -download alternative below has separate registry and authentication requirements. +Keep the real executable at `prefix/bin/vp`. This matches the existing +[Homebrew ownership check](../crates/vp_global_cli/src/homebrew.rs) and JavaScript +resolver. Include applicable license files. The npm package remains named +`vite-plus`; the formula name does not change JavaScript package names or user +directory names. + +The formula does not run normal `vp` commands or first-run setup during +installation. It does not modify user shell profiles, set `VP_HOME` or +`VP_*_DIR`, or ship `.vp-setup-complete`. Setup is specific to each user. + +On first use, the existing bundled-external setup configures that user's shell, +shims, and preferences. It must not copy the payload, install dependencies, or +write inside the keg. It reports when the user must restart the terminal. +Later invocations reuse the receipt. + +Shims follow a stable Homebrew entrypoint. Replacing the package can refresh +its receipt, but must preserve managed mode and mixed per-tool preferences. +Test saved `vp` and tool-shim paths after the previous keg is removed. + +Node.js at runtime continues to follow the existing CLI resolver. System-first +mode can use Node.js on `PATH`; managed mode can download a separate runtime. +The Node.js build dependency does not override this user preference or make +all commands work offline. A separate runtime policy would need its own decision. + +## Registry configuration and authentication + +Use a nonempty `HOMEBREW_NPM_CONFIG_REGISTRY` as the default registry override. +Otherwise, read `registry` from `~/.npmrc`, then fall back to +`https://registry.npmjs.org`. Honor scoped registry settings such as +`@voidzero-dev:registry` for the corresponding packages, as npm does. These scoped +settings take precedence over the default registry. Do not read a project's +`.npmrc` based on the directory from which the user runs `brew`. +The proposed tap implements this setting; it is not a built-in Homebrew setting. + +Homebrew's [launcher](https://github.com/Homebrew/brew/blob/main/bin/brew) removes +`NPM_CONFIG_REGISTRY` and ordinary token variables before formula evaluation. +It preserves `HOMEBREW_*` variables. Users could forward an existing setting: + +```sh +HOMEBREW_NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY:-}" \ + brew install voidzero-dev/vite-plus/vp +``` + +Support registry-scoped `_authToken` entries in `~/.npmrc`. For example, a user +could configure the following and export `HOMEBREW_NPM_TOKEN` in their shell: + +```ini +registry=https://registry.example.com/repository/npm/ +//registry.example.com/repository/npm/:_authToken=${HOMEBREW_NPM_TOKEN} +``` + +Literal tokens in the file are also supported by this design. Existing entries +that reference `${NPM_TOKEN}` need an explicit forwarding mechanism or a change +to the placeholder; that variable does not survive Homebrew's launcher. Do not +silently substitute a different token or send an unresolved placeholder. + +Resolve user configuration and credentials at download time in the tap-owned +download strategy for both the binary and every dependency resource. Do not read +credentials during formula evaluation or from `install`: Homebrew's +[build sandbox](https://github.com/Homebrew/brew/blob/main/Library/Homebrew/sandbox.rb) +restricts access to the user's home, including `.npmrc`. Keep registry tokens +out of formula metadata, receipts, downloaded artifacts, and diagnostic output. +The install step should consume downloaded files without user credentials. + +Use the pinned package version's metadata to obtain `dist.tarball`; private +registries can use different tarball paths. Match credentials to each request's +host and path, including tarball requests and redirects. A registry override must +not send another registry's token to the new URL. Retain the formula's SHA-256 +check, and report authentication failures without falling back to the public +registry. Follow npm's [configuration and authentication rules](https://docs.npmjs.com/cli/v11/configuring-npm/npmrc/). +The initial authentication support covers bearer tokens. Other enterprise +requirements, such as client certificates, need separate design and validation. ## Commands and diagnostics @@ -252,33 +319,33 @@ delete another user's files from an uninstall hook. ## Release automation and recovery -The Vite+ release pipeline assembles and tests bundles, then publishes them with -SHA-256 checksums. Formula URLs reference a specific release tag. The formula -contains each target's checksum; it never downloads a mutable `latest` URL or -executes a remote installation script. - -After the stable release and required assets are available, a separate job opens -a PR in `voidzero-dev/vite-plus` to update `HomebrewFormula/vp.rb`. The job only -needs write access to this repository. Its credentials must support the required -PR checks, either through normal events or an explicit test workflow dispatch. -Ordinary PR jobs receive no publishing credentials. - -The update job verifies every required asset before changing the formula. It -uses the release version as its idempotency key, updates an existing PR on retry, -and rejects an update that would replace a newer formula with an older release. -The Homebrew workflow tests the candidate before a maintainer merges it. A -formula-only merge must not publish another product release. Automatic merging -can be considered after the process proves reliable. - -A failed tap update leaves the previous formula available. It does not undo an -already published npm or GitHub release. Report the failure in the release run -and allow the same update job to be retried without rebuilding published assets. - -Do not overwrite a published asset to repair packaging. Use an immutable packaging -revision with a new URL and checksum, or a new product release. Document recovery -for users already on a bad version; reverting a formula commit alone does not -cause Homebrew to downgrade those users. The first implementation must not promise -`vp upgrade --rollback` support for Homebrew installations. +The release pipeline publishes the existing npm packages. Once the native CLI, +JavaScript package, core package, and required native bindings are available, +a separate job opens a formula update PR in `voidzero-dev/vite-plus`. + +The update job selects an exact release version. It generates the installation +manifest, production lock, target resource lists, and SHA-256 checksums from the +published artifacts. Validate package versions and contents before opening the +PR. User installations must not resolve a mutable `latest` tag or regenerate +the dependency lock. + +The job only needs write access to this repository. Its credentials must support +required PR checks, through normal events or an explicit workflow dispatch. +Ordinary PR jobs receive no publishing credentials. Use the release version as +an idempotency key, update an existing PR on retry, and reject updates that +replace a newer formula with an older release. + +Run installation checks against each candidate before a maintainer merges it. +A formula-only merge must not publish another product release. A failed update +leaves the previous formula available and does not undo npm publication. + +Do not overwrite published packages to repair installation. Use a formula +`revision` for a corrected recipe or dependency lock with unchanged product +packages, or publish a new product release when package contents must change. +Keep checksums and dependency metadata consistent. Document recovery for users +already on a bad version: reverting a formula commit alone does not cause +Homebrew to downgrade their installation. `vp upgrade --rollback` remains +unavailable for Homebrew-owned packages. ## Coexistence and migration @@ -296,9 +363,9 @@ or use core's `vp` alias to select the tap package. The initial documented route should prefetch `voidzero-dev/vite-plus/vp`, remove `homebrew/core/vite-plus`, and install the fully qualified tap formula. Keep user data and settings; do not use `vp implode` as a migration step. Check the -installed receipt and active command -afterward. Test the exact sequence, dependency behavior, and recovery from a -failed installation before publishing copy-and-paste instructions. +installed receipt and active command afterward. Test the exact sequence, +dependency behavior, and recovery from a failed installation before publishing +copy-and-paste instructions. Switching from a script installation needs separate guidance. Its shims can precede Homebrew's `bin` on `PATH`. Run the new Homebrew executable by its explicit @@ -317,127 +384,67 @@ requirement to backport the new tap to old Vite+ releases. Core, script, and ## Validation before launch -Keep the formula installation test small, but cover a real bundled JavaScript -command. A Rust-only `vp --help` test cannot detect missing native dependencies. -Use an isolated user home and explicit management settings. - -The release and tap tests must cover: - -- Installation without Rust, CMake, or pnpm; no source checkout or external store links in the bundle. -- First and second invocation with a read-only keg, checking that only user setup state changes. -- Formatting, linting, and a small build outside a project-local `vite-plus` installation, to exercise bundled native packages. -- CLI startup and bundled commands with npm registry access blocked, using an available Node.js runtime to isolate that requirement. -- Runtime acquisition with no system Node.js, plus system-first behavior with an existing runtime. -- Two users sharing a package with separate setup receipts and management preferences. -- Package replacement and removal of the old keg, followed by saved `vp` and tool shim paths in existing Bash and Zsh sessions. -- Managed and mixed per-tool settings across upgrades, reinstall, and interrupted setup. -- `upgrade`, `upgrade --check`, `implode`, and doctor output for both formula names, including incomplete receipts. -- Formula discovery through the explicit repository URL, conflict handling, and core's existing `vp` alias. -- Migration in both directions, script installation coexistence, package removal, and retained user data. - -Run Homebrew checks on PRs that change `HomebrewFormula/vp.rb` or its packaging -scripts, and bundle checks on release candidates. Allow maintainers to request -the full Homebrew installation suite for other source changes with the -`test: install-e2e` label. Run untrusted PR code without publishing credentials. -These checks need not run on every ordinary source PR. +Use isolated homes and synthetic credentials. Test the actual npm tarballs, +including the published native bindings, without a project-local `vite-plus` +installation that could hide missing global dependencies. + +The tap tests must cover: + +- Native package and JavaScript version agreement, locked dependency checksums, and target filtering. +- Offline dependency installation from downloaded resources with an empty pnpm store and no lifecycle scripts. +- Relocation and operation after the staging directory and pnpm store are removed. +- First and second invocation with a read-only keg; no dependency bootstrap or writes to package files. +- Formatting, linting, and a small production build through the installed global CLI. +- Public registry downloads, a custom registry, scoped registry settings, and `.npmrc` bearer tokens. +- Token substitution, custom `dist.tarball` paths, redirects, authentication failures, missing packages, and checksum failures. +- No credentials in logs, formula metadata, receipts, or installed files; no fallback to the public registry after an authentication error. +- `brew fetch`, installation, reinstall, upgrade, and removal on every supported target. +- Two users sharing the package with separate setup receipts and preferences. +- Removal of an old keg followed by saved `vp` and tool-shim paths in existing Bash and Zsh sessions. +- Mixed management preferences, runtime selection, lifecycle command guidance, and channel migration. + +Run Homebrew checks on changes to the formula, downloader, dependency metadata, +and update script. Allow maintainers to request the full installation suite for +other source changes with the `test: install-e2e` label. Run untrusted PR code +without publishing credentials. These checks need not run on every source PR. ## Alternatives A separate repository such as `voidzero-dev/homebrew-tap` would provide a smaller -checkout and direct installation without an initial custom-URL tap command. It -would also need separate repository administration and release-update access. +checkout and direct installation without an initial custom-URL tap command. Keeping the formula in `vite-plus` puts code, packaging, and tests in one review -process. A separate tap remains an option if checkout size becomes a problem; -it is not required for the installation design. - -| Approach | Benefit | Cost or limitation | -| ----------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| Complete bundle in a project-owned tap (proposed) | Small formula; no local build or npm bootstrap; Homebrew retains ownership | New bundle assembly, larger assets, and project-owned release support | -| Existing native archive only | Small download and nearly identical to script bootstrap | Downloads JS on first use; current setup deploys a user-managed copy, so Homebrew no longer controls the active CLI | -| Run `install.sh` from a formula | Reuses the installation entrypoint directly | Writes user state during package installation and separates installed version tracking from the active CLI | -| Maintain a source formula and publish our own bottles | Conventional Homebrew build and bottle flow | Retains source recipe complexity and adds a bottle build pipeline | -| Continue with core only | No additional formula or distribution channel | Packaging updates follow core's process and source build constraints | -| macOS cask | Suitable for a prebuilt macOS distribution | Requires a separate Linux solution and different installation detection | - -An upstream tap can distribute prebuilt archives; the -[Bun tap](https://github.com/oven-sh/homebrew-bun/blob/main/Formula/bun.rb) -provides an example. This does not imply that the same formula would be accepted -in core. The proposed binary formula would not offer a source-build mode; -`--build-from-source` would not recreate the upstream build. Source builds remain -available through the project and core. - -### npm registry downloads and authentication - -The tap could download the existing `@voidzero-dev/vite-plus-cli-` -package from an npm registry. This option keeps the npm package format unchanged. -It still needs a decision about how to install the JavaScript dependencies under -Homebrew ownership; downloading the native binary alone does not provide them. -Registry authentication must also cover those dependency downloads if this -option is selected. - -Use a nonempty `HOMEBREW_NPM_CONFIG_REGISTRY` as the default registry override. -Otherwise, read `registry` from `~/.npmrc`, then fall back to -`https://registry.npmjs.org`. Honor scoped registry settings such as -`@voidzero-dev:registry` for the corresponding packages, as npm does. These scoped -settings take precedence over the default registry. Do not read a project's -`.npmrc` based on the directory from which the user runs `brew`. -The tap implements this setting; it is not a built-in Homebrew setting. - -Homebrew's [launcher](https://github.com/Homebrew/brew/blob/main/bin/brew) removes -`NPM_CONFIG_REGISTRY` and ordinary token variables before formula evaluation. -It preserves `HOMEBREW_*` variables. Users could forward an existing setting: - -```sh -HOMEBREW_NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY:-}" \ - brew install voidzero-dev/vite-plus/vp -``` - -Support registry-scoped `_authToken` entries in `~/.npmrc`. For example, a user -could configure the following and export `HOMEBREW_NPM_TOKEN` in their shell: - -```ini -registry=https://registry.example.com/repository/npm/ -//registry.example.com/repository/npm/:_authToken=${HOMEBREW_NPM_TOKEN} -``` - -Literal tokens in the file are also supported by this design. Existing entries -that reference `${NPM_TOKEN}` need an explicit forwarding mechanism or a change -to the placeholder; that variable does not survive Homebrew's launcher. Do not -silently substitute a different token or send an unresolved placeholder. - -Resolve user configuration and credentials at download time in a tap-owned -download strategy. Do not read credentials during formula evaluation or from -`install`: Homebrew's [build sandbox](https://github.com/Homebrew/brew/blob/main/Library/Homebrew/sandbox.rb) -restricts access to the user's home, including `.npmrc`. Keep registry tokens -out of formula metadata, receipts, downloaded artifacts, and diagnostic output. -The install step should consume downloaded files without user credentials. - -Use the pinned package version's metadata to obtain `dist.tarball`; private -registries can use different tarball paths. Match credentials to each request's -host and path, including tarball requests and redirects. A registry override must -not send another registry's token to the new URL. Retain the formula's SHA-256 -check, and report authentication failures without falling back to the public -registry. Follow npm's [configuration and authentication rules](https://docs.npmjs.com/cli/v11/configuring-npm/npmrc/). -This initial option covers bearer tokens; other enterprise requirements such as -client certificates need separate design and validation. - -Before selecting this option, use an isolated registry and synthetic `.npmrc` -files to check registry precedence, scoped tokens, token substitution, custom -tarball paths, redirects, authentication failures, checksum failures, and secret -redaction. Test `brew fetch`, installation, reinstall, and upgrade on macOS and -Linux. This is a proposed download design, not existing tap functionality. +process. A separate repository remains an option if checkout size becomes a +problem. + +| Approach | Benefit | Cost or limitation | +| -------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Existing npm packages (proposed) | Reuses released packages and the script installer's dependency installation model | Requires Node.js and pnpm during installation, dependency metadata, and registry authentication support | +| Complete bundle on GitHub Releases | Simple extraction and no npm access during CLI payload installation | Requires new bundle assembly, larger assets, and another packaging format | +| Run `install.sh` from a formula | Reuses the entrypoint directly | Installs user-managed files and loses Homebrew ownership of the active CLI | +| Keep only a native binary in the keg and install JavaScript per user | Avoids dependency installation during `brew install` | Requires a new external-binary setup and JavaScript resolution contract, per-user downloads, and version coordination | +| Source formula with project-built bottles | Conventional Homebrew packaging | Retains source recipe complexity and adds a bottle pipeline | +| Continue with core only | No additional distribution channel | Packaging follows core's process and source build constraints | + +The tap can distribute prebuilt executables inside npm tarballs. This does not +imply that the same recipe would be accepted in `homebrew/core`. The formula +prepares published binaries and packages; `--build-from-source` would not compile +the Vite+ sources. Source builds remain available through the project and core. ## Decisions requested -1. Should Homebrew retain ownership of the active CLI, with complete bundles as proposed, or should the tap only bootstrap a script-managed install? -2. Who owns formula updates and Homebrew support within the Vite+ project? -3. Should the initial tap cover all four proposed targets, or start with macOS while Linux tests mature? -4. Is the existing Node.js runtime resolver sufficient, or should the tap require Homebrew Node.js or ship a private runtime? -5. Should tap updates require maintainer review initially, and what packaging-repair version scheme should we use? -6. Should the tap use GitHub bundles, or existing npm packages with `HOMEBREW_NPM_CONFIG_REGISTRY` and authenticated `~/.npmrc` support? The npm option also needs a dependency installation design. - -After agreement, split implementation into bundle assembly, CLI ownership -metadata, the tap and its tests, and release publication. Keep product -documentation on the current installation instructions until the tap is usable. -Then update the global CLI, upgrade, and implode guides, plus the directory -layout RFC where the ownership metadata contract changes. +The primary direction is existing npm packages with Homebrew ownership, using +`HOMEBREW_NPM_CONFIG_REGISTRY` and authenticated `~/.npmrc` support. The npm +package format stays unchanged. Remaining decisions are: + +1. Who owns formula updates and Homebrew support within the project? +2. Should the initial tap cover all four proposed targets, or start with macOS while Linux tests mature? +3. Is a Homebrew Node.js build dependency acceptable while the CLI retains its existing runtime selection? +4. Does the locked-resource and offline-pnpm approach provide the right balance of reproducibility and formula complexity? +5. Which enterprise authentication requirements beyond scoped bearer tokens belong in the first version? + +After agreement, split implementation into registry downloads, package +installation and formula metadata, CLI formula identity, and release automation +with Homebrew tests. Keep product documentation on the current installation +instructions until the tap is usable. Then update the global CLI, upgrade, and +implode guides, plus the directory layout RFC where the ownership contract +changes. From 13c5442c62f53c7e9eee0afa5863aae7a7e69d3e Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 02:16:34 +0800 Subject: [PATCH 09/31] docs: simplify Homebrew tap RFC --- rfcs/official-homebrew-tap.md | 587 ++++++++++------------------------ 1 file changed, 177 insertions(+), 410 deletions(-) diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index 6866f8c2c3..2571f83ec6 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -1,103 +1,25 @@ # RFC: Vite+-managed Homebrew tap -- Status: Draft for discussion. This RFC does not create a tap or change installation behavior. -- Original requirement: [Distributing and Installing with Homebrew (#1171)](https://github.com/voidzero-dev/vite-plus/issues/1171). -- Related: [Homebrew first-run failure (#2719)](https://github.com/voidzero-dev/vite-plus/issues/2719), [external installation support (#2729)](https://github.com/voidzero-dev/vite-plus/pull/2729). +Status: Draft for discussion. This RFC does not change installation behavior. -## Proposal - -Maintain the Homebrew tap in the existing `voidzero-dev/vite-plus` repository. -Add `HomebrewFormula/vp.rb`, with the Ruby class `Vp < Formula`. The tap name is -`voidzero-dev/vite-plus`, and the formula name is `voidzero-dev/vite-plus/vp`. -`vp` is the actual formula name, not an alias for a `vite-plus` formula. - -Use the npm registry as the primary distribution source. Download the existing -`@voidzero-dev/vite-plus-cli-` package for the native binary and install -`vite-plus` at the same version with its production dependencies. Keep the npm -package contents unchanged. No new complete release bundle is required. - -Follow the script installer's sequence: acquire the native binary, prepare its -JavaScript dependencies, then configure the user's environment. Homebrew performs -the first two steps in its installation directories. The first `vp` invocation -uses the existing Rust setup code for shell configuration, shims, and preferences. -Homebrew owns the installed binary and JavaScript, including upgrades and removal. +## Motivation -Support `HOMEBREW_NPM_CONFIG_REGISTRY` and registry credentials in `~/.npmrc` -through a tap-owned downloader. Use `HOMEBREW_NPM_TOKEN` in token placeholders -when the value must pass through Homebrew's environment filter. +Issue [#1171](https://github.com/voidzero-dev/vite-plus/issues/1171) requested +Homebrew distribution for atomic Linux desktops and enterprise management. +The [core formula](https://github.com/Homebrew/homebrew-core/blob/main/Formula/v/vite-plus.rb) +requires Rust and JavaScript builds when a bottle is unavailable. Issue +[#2719](https://github.com/voidzero-dev/vite-plus/issues/2719) also exposed a +mismatch between Homebrew installation and first-run setup, addressed by +[#2729](https://github.com/voidzero-dev/vite-plus/pull/2729). -## Motivation and feasibility +A project-maintained tap can reuse the npm packages used by +[`install.sh`](../packages/cli/install.sh), avoid compiling Vite+ from source, +and support enterprise npm registries. -Issue [#1171](https://github.com/voidzero-dev/vite-plus/issues/1171) requested -Homebrew distribution for Bluefin Dx and other Universal Blue / Atomic Fedora -systems. It also identified enterprise management through WorkBrew as a use -case. Issue [#2719](https://github.com/voidzero-dev/vite-plus/issues/2719) later -exposed a first-run setup failure with the core formula on macOS. +## Proposal -The [core formula](https://github.com/Homebrew/homebrew-core/blob/main/Formula/v/vite-plus.rb) -builds from source when a bottle is unavailable or a user requests a source -build. It stages upstream repositories, runs Rust and JavaScript builds, and -deploys the resulting package. A project-owned tap can reuse published npm -packages and avoid those source builds. - -The [script installer](../packages/cli/install.sh) already downloads the native -CLI from npm. Its self-setup code installs `vite-plus` through a wrapper manifest -and a pinned pnpm version. The [package publisher](../packages/cli/publish-native-addons.ts) -publishes the native CLI separately from the JavaScript package and native -bindings. These existing packages provide the inputs for the tap. - -Current [external setup](../crates/vp_global_cli/src/self_setup.rs) recognizes a -Unix binary with `node_modules/vite-plus` beside its `bin` directory. It reuses -that payload and records setup in the user's state directory. The -[JavaScript resolver](../crates/vp_global_cli/src/js_executor.rs) already supports -this layout. The registry-based approach therefore does not need a new package -format or a new JavaScript resolution layout. - -Running `install.sh` unchanged inside a formula would use the installing user's -data directories and configure that user's shell. Installing only its binary -would still trigger standalone bootstrap on first use. The tap must prepare the -JavaScript dependencies in the keg before normal CLI execution. - -| Step | Script installation | Homebrew installation | -| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------- | -| Version selection | Requested version or npm tag | Exact version pinned by the formula | -| Native CLI | Existing platform npm tarball | The same package, with a formula checksum | -| JavaScript dependencies | pnpm installs into the user's version directory | Pinned pnpm installs into staging; Homebrew installs the result into the keg | -| User setup | Runs during installer handoff | Runs on the user's first `vp` invocation | -| Upgrade and removal | `vp upgrade` and `vp implode` | Homebrew manages the package; `vp implode` manages user data | - -The current Rust [dependency installer](../crates/vp_setup/src/install.rs) -also provisions managed Node.js and pnpm. The formula should reuse its package -selection and installation layout, with Homebrew supplying the build environment. -It must not call this user-installation routine unchanged. Reading `.npmrc` for -native tarball authentication is also new download logic; the current shell -installer does not provide that functionality. - -## Goals and boundaries - -- Install existing released npm packages without compiling Vite+ from source. -- Use the same native package, JavaScript version, and pnpm installation model as the script installer. -- Support corporate npm registries and scoped bearer-token authentication. -- Keep package files, upgrades, and removal under Homebrew's control. -- Preserve user settings and working shims across package replacement. -- Keep all published package formats unchanged. - -The first version supports stable releases. Proposed targets are macOS ARM64 -and x64, and glibc Linux ARM64 and x64. Advertise each target only after its -Homebrew installation tests pass. Windows, musl Linux, preview builds, and -historical formulae remain outside the initial tap scope. - -Registry access is required to acquire the CLI and its dependencies unless the -files are already cached. A corporate mirror can provide that access when the -public npm registry is blocked. This design does not provide installation when -both the public registry and all configured mirrors are inaccessible. -Homebrew dependencies, Node.js runtime downloads, and project dependencies have -their own network requirements. The core formula and script installer remain -available independently. - -## Installation and ownership - -After the tap is available, a new user would run: +Maintain `HomebrewFormula/vp.rb` in the existing `voidzero-dev/vite-plus` +repository. Once the tap is available, users install it with: ```sh brew tap voidzero-dev/vite-plus https://github.com/voidzero-dev/vite-plus @@ -105,346 +27,191 @@ brew install voidzero-dev/vite-plus/vp vp ``` -Use the fully qualified formula name to distinguish it from -`homebrew/core/vite-plus` and core's `vp` alias. The initial `brew tap` command -needs the explicit URL. Otherwise, Homebrew looks for -`voidzero-dev/homebrew-vite-plus`. Subsequent installs and upgrades use the saved -remote. See [the tap command documentation](https://docs.brew.sh/Manpage#tap-options-userrepo-url). +The explicit repository URL is required because Homebrew otherwise looks for +`voidzero-dev/homebrew-vite-plus`. Use the qualified formula name to distinguish +it from core's `vite-plus` formula and `vp` alias. -### Repository layout +Use npm as the primary distribution source. Download the native binary from +`@voidzero-dev/vite-plus-cli-` and install `vite-plus` at the same +version with its production dependencies. Keep the published package formats +unchanged. -```text -vite-plus/ -├── HomebrewFormula/ -│ ├── vp.rb # new: formula -│ └── vp/ -│ ├── package.json # new: pinned installation manifest -│ ├── pnpm-lock.yaml # new: production dependency lock -│ └── resources.json # new: package versions and checksums by target -├── lib/homebrew/ -│ └── vp_npm_download_strategy.rb # new: registry configuration and authenticated downloads -├── .github/ -│ ├── workflows/ -│ │ ├── release.yml # extend: open formula update PRs after npm publication -│ │ └── test-homebrew.yml # new: installation tests -│ └── scripts/ -│ └── update-homebrew-formula.mjs # new: update version, lock, and resource checksums -├── packages/cli/ -│ └── install.sh # existing script installer -├── crates/vp_global_cli/src/ -│ ├── self_setup.rs # existing per-user setup -│ └── homebrew.rs # ownership and formula identity -└── rfcs/ - └── official-homebrew-tap.md -``` +Follow the script installer's sequence: download the binary, install JavaScript +dependencies, then configure the user environment. Homebrew owns the first two +steps and all package upgrades and removal. The first `vp` invocation runs the +existing per-user setup. Running `install.sh` unchanged would instead place +files in the user's managed installation. + +The initial scope is stable releases on macOS and glibc Linux, for ARM64 and +x64. Launch each target only after its installation tests pass. -Support-file names are illustrative. Keep Ruby support code outside the formula -directory. The lock and resource manifest are small text files; npm tarballs, -package caches, and installed dependencies are not committed to the repository. - -Homebrew recognizes `HomebrewFormula/` at the repository root. Standard tap -installation clones the project repository, including files unrelated to the -formula. It does not limit checkout to this directory. This is the download -cost of keeping packaging in the same repository. See -[Homebrew's tap layout documentation](https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap#creating-a-tap) -and [clone implementation](https://github.com/Homebrew/brew/blob/main/Library/Homebrew/tap.rb). - -The formula on `main` references the last published stable release. Update its -version, dependency lock, and checksums together after the npm packages become -available. Formula PRs follow repository review rules. Use CI path filters to -avoid unrelated product builds for formula-only changes. - -### Package preparation - -The proposed formula uses the following sequence: - -1. Fetch the native CLI and the pinned pnpm package from npm as Homebrew resources. -2. Fetch the locked production dependency tarballs for the target, including required optional native packages. -3. Stage an installation manifest that pins `vite-plus` to the native CLI's version. Point a working copy of the lockfile at the verified local tarballs. -4. Run the pinned pnpm with `--offline --frozen-lockfile --prod --ignore-scripts` in an isolated build directory. -5. Install the native executable into `prefix/bin` and the resulting dependency tree into `prefix/node_modules`. Add `vpr` and `vpx` links to `vp`. - -Declare Homebrew Node.js as a build dependency to run pnpm. Pin pnpm to the -version used by the standalone dependency installer, currently `10.33.0`, and -update that selection deliberately. The formula does not depend on a user's -Node.js or pnpm installation. No Rust, CMake, or upstream source checkout is -needed to prepare Vite+ itself. - -Generate the wrapper manifest, lock, and resource list during formula-update CI. -Pin transitive versions and integrity values, not only `vite-plus`. The resource -list must match the lock and select the correct OS, CPU, and libc packages. -Use Homebrew's normal resource download stage for authenticated acquisition, -before the build sandbox. Each resource retains a reviewed checksum. The offline -installation then needs neither registry credentials nor access to `~/.npmrc`. -This uses Homebrew's [resource downloads](https://docs.brew.sh/Formula-Cookbook#language-specific-dependencies) -and pnpm's [offline installation mode](https://pnpm.io/cli/install#--offline). - -The working lockfile may replace remote tarball locations with staged `file:` -locations while retaining package versions and integrity values. This adapts -Homebrew's verified downloads to pnpm's offline installer. Do not replace locked -integrity values when a mirror serves different bytes. - -Use an isolated pnpm store and copy package files into the installed tree. -Internal relative pnpm links are acceptable; no link may depend on the build -directory, an external store, or the user's files. The installed CLI must work -after the build directory and store are removed. - -Retain native optional dependencies such as Oxfmt, Oxlint, Rolldown, and the -required platform bindings. Do not use `--no-optional`. The initial design skips -dependency lifecycle scripts and relies on published prebuilt files. Functional -tests must detect any release that starts requiring a build or postinstall -script. Such a release needs a packaging decision before the formula advances. - -This preparation follows the script installer's package installation model. -The locked resource list and offline installation are Homebrew-specific additions -that make downloads verifiable and keep credentials outside the build sandbox. -They do not change the published npm packages or require new GitHub bundles. - -### Installed layout and first use +## Installation design + +The formula uses a release-generated manifest, dependency lock, and package +checksums: + +1. Download the native CLI, pinned pnpm, and locked production dependencies as + [Homebrew resources](https://docs.brew.sh/Formula-Cookbook#language-specific-dependencies). + Include the target's optional native packages. +2. Point a working copy of the lockfile at the verified local tarballs. Retain + its package versions and integrity values. +3. Use a Homebrew Node.js build dependency to run the pinned pnpm with + `--offline --frozen-lockfile --prod --ignore-scripts`. +4. Install the executable into `prefix/bin/vp` and the dependency tree into + `prefix/node_modules`. Add `vpr` and `vpx` links to `vp`. + +Pin pnpm to the version used by the existing +[dependency installer](../crates/vp_setup/src/install.rs). Copy dependency files +from an isolated pnpm store; installed links must stay within the package. +The CLI must work after the build directory and store are removed. ```text /vp// -├── bin/ -│ ├── vp -│ ├── vpr -> vp -│ └── vpx -> vp -├── node_modules/ -│ ├── vite-plus/ # may be an internal relative pnpm link -│ └── .pnpm/ # package files and production dependencies -└── INSTALL_RECEIPT.json # written by Homebrew +├── bin/ # vp, vpr, vpx +├── node_modules/ # vite-plus and its production dependencies +└── INSTALL_RECEIPT.json # Homebrew ownership record ``` -Keep the real executable at `prefix/bin/vp`. This matches the existing -[Homebrew ownership check](../crates/vp_global_cli/src/homebrew.rs) and JavaScript -resolver. Include applicable license files. The npm package remains named -`vite-plus`; the formula name does not change JavaScript package names or user -directory names. - -The formula does not run normal `vp` commands or first-run setup during -installation. It does not modify user shell profiles, set `VP_HOME` or -`VP_*_DIR`, or ship `.vp-setup-complete`. Setup is specific to each user. +The existing [external setup](../crates/vp_global_cli/src/self_setup.rs) and +[JavaScript resolver](../crates/vp_global_cli/src/js_executor.rs) support this +layout. First use creates each user's settings, shims, and setup receipt without +copying the package or writing into the Cellar. Upgrades must preserve those +settings, and shims must survive removal of the old package version. -On first use, the existing bundled-external setup configures that user's shell, -shims, and preferences. It must not copy the payload, install dependencies, or -write inside the keg. It reports when the user must restart the terminal. -Later invocations reuse the receipt. +Runtime Node.js selection remains unchanged: system-first mode can use Node.js +on `PATH`, while managed mode can download a separate runtime. The Node.js build +dependency does not override that preference. -Shims follow a stable Homebrew entrypoint. Replacing the package can refresh -its receipt, but must preserve managed mode and mixed per-tool preferences. -Test saved `vp` and tool-shim paths after the previous keg is removed. +## Custom registries and authentication -Node.js at runtime continues to follow the existing CLI resolver. System-first -mode can use Node.js on `PATH`; managed mode can download a separate runtime. -The Node.js build dependency does not override this user preference or make -all commands work offline. A separate runtime policy would need its own decision. +Choose the default registry in this order: -## Registry configuration and authentication +1. A nonempty `HOMEBREW_NPM_CONFIG_REGISTRY`. +2. `registry` in `~/.npmrc`. +3. `https://registry.npmjs.org`. -Use a nonempty `HOMEBREW_NPM_CONFIG_REGISTRY` as the default registry override. -Otherwise, read `registry` from `~/.npmrc`, then fall back to -`https://registry.npmjs.org`. Honor scoped registry settings such as -`@voidzero-dev:registry` for the corresponding packages, as npm does. These scoped -settings take precedence over the default registry. Do not read a project's -`.npmrc` based on the directory from which the user runs `brew`. -The proposed tap implements this setting; it is not a built-in Homebrew setting. +Honor scoped registry entries such as `@voidzero-dev:registry` ahead of the +default for matching packages. Read the user's `.npmrc`, not a project's file. +These settings belong to the proposed tap; they are not built-in Homebrew +registry settings. -Homebrew's [launcher](https://github.com/Homebrew/brew/blob/main/bin/brew) removes -`NPM_CONFIG_REGISTRY` and ordinary token variables before formula evaluation. -It preserves `HOMEBREW_*` variables. Users could forward an existing setting: +Homebrew's [launcher](https://github.com/Homebrew/brew/blob/main/bin/brew) +filters out ordinary npm environment variables but preserves `HOMEBREW_*`. +Users can forward an existing registry setting: ```sh HOMEBREW_NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY:-}" \ brew install voidzero-dev/vite-plus/vp ``` -Support registry-scoped `_authToken` entries in `~/.npmrc`. For example, a user -could configure the following and export `HOMEBREW_NPM_TOKEN` in their shell: +Support registry-scoped bearer tokens in `~/.npmrc`, either as literal values or +through `${HOMEBREW_NPM_TOKEN}`. For example, after exporting that variable: ```ini registry=https://registry.example.com/repository/npm/ //registry.example.com/repository/npm/:_authToken=${HOMEBREW_NPM_TOKEN} ``` -Literal tokens in the file are also supported by this design. Existing entries -that reference `${NPM_TOKEN}` need an explicit forwarding mechanism or a change -to the placeholder; that variable does not survive Homebrew's launcher. Do not -silently substitute a different token or send an unresolved placeholder. - -Resolve user configuration and credentials at download time in the tap-owned -download strategy for both the binary and every dependency resource. Do not read -credentials during formula evaluation or from `install`: Homebrew's -[build sandbox](https://github.com/Homebrew/brew/blob/main/Library/Homebrew/sandbox.rb) -restricts access to the user's home, including `.npmrc`. Keep registry tokens -out of formula metadata, receipts, downloaded artifacts, and diagnostic output. -The install step should consume downloaded files without user credentials. - -Use the pinned package version's metadata to obtain `dist.tarball`; private -registries can use different tarball paths. Match credentials to each request's -host and path, including tarball requests and redirects. A registry override must -not send another registry's token to the new URL. Retain the formula's SHA-256 -check, and report authentication failures without falling back to the public -registry. Follow npm's [configuration and authentication rules](https://docs.npmjs.com/cli/v11/configuring-npm/npmrc/). -The initial authentication support covers bearer tokens. Other enterprise -requirements, such as client certificates, need separate design and validation. - -## Commands and diagnostics - -Retain the ownership rules introduced for core installations: - -| Command | Expected behavior for the tap | -| -------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| `vp upgrade` | Direct the user to `brew upgrade voidzero-dev/vite-plus/vp`; do not install a managed copy | -| `vp upgrade --check` | Direct the user to `brew outdated voidzero-dev/vite-plus/vp` | -| Upgrade with a version, `--force`, or `--rollback` | Keep the Homebrew ownership guard; do not switch installation channels | -| Automatic update check | Keep npm checks disabled for Homebrew-owned binaries | -| `vp env doctor` | Report Homebrew ownership, formula identity, binary path, and actual PATH/shim problems | -| `vp implode` | Explain and remove Vite+-managed user data; retain the keg and direct package removal to Homebrew | -| `brew uninstall voidzero-dev/vite-plus/vp` | Remove the keg and public links; retain user data | - -Current detection returns a boolean, and command messages use `vite-plus`. -Extend the ownership information to identify the installed formula and read -`source.tap` from the Homebrew receipt when available. Use the full name in -guidance: `voidzero-dev/vite-plus/vp` for this tap and `homebrew/core/vite-plus` -for core. Do not infer the formula name from the npm package name. If an older -or incomplete receipt prevents identification, keep the ownership guard and -ask the user to check the installed formula rather than guess its name. -Do not run `brew` merely to identify the active CLI, and do not add upgrade -advice to unrelated doctor output. - -For complete removal, document `vp implode` before `brew uninstall`, including -its removal of runtimes, settings, and global packages. Package removal alone -can leave user shims pointing to an absent entrypoint. Reinstalling the package -or explicitly cleaning those shims resolves that state; the formula must not -delete another user's files from an uninstall hook. - -## Release automation and recovery - -The release pipeline publishes the existing npm packages. Once the native CLI, -JavaScript package, core package, and required native bindings are available, -a separate job opens a formula update PR in `voidzero-dev/vite-plus`. - -The update job selects an exact release version. It generates the installation -manifest, production lock, target resource lists, and SHA-256 checksums from the -published artifacts. Validate package versions and contents before opening the -PR. User installations must not resolve a mutable `latest` tag or regenerate -the dependency lock. - -The job only needs write access to this repository. Its credentials must support -required PR checks, through normal events or an explicit workflow dispatch. -Ordinary PR jobs receive no publishing credentials. Use the release version as -an idempotency key, update an existing PR on retry, and reject updates that -replace a newer formula with an older release. - -Run installation checks against each candidate before a maintainer merges it. -A formula-only merge must not publish another product release. A failed update -leaves the previous formula available and does not undo npm publication. - -Do not overwrite published packages to repair installation. Use a formula -`revision` for a corrected recipe or dependency lock with unchanged product -packages, or publish a new product release when package contents must change. -Keep checksums and dependency metadata consistent. Document recovery for users -already on a bad version: reverting a formula commit alone does not cause -Homebrew to downgrade their installation. `vp upgrade --rollback` remains -unavailable for Homebrew-owned packages. - -## Coexistence and migration - -The core formula remains independently maintained. Homebrew's -[upstream tap policy](https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap#upstream-taps) -does not promise removal or redirection of a core formula when an upstream tap -appears. Continue fixing compatibility with core installations. - -The tap formula is named `vp`, while the core formula is named `vite-plus`. -They use different Cellar directories, but both install `vp`, `vpr`, and `vpx` -into Homebrew's public `bin`. Declare a conflict with `vite-plus` in the tap -formula and document an explicit migration. Do not overwrite the existing links -or use core's `vp` alias to select the tap package. - -The initial documented route should prefetch `voidzero-dev/vite-plus/vp`, remove -`homebrew/core/vite-plus`, and install the fully qualified tap formula. Keep user -data and settings; do not use `vp implode` as a migration step. Check the -installed receipt and active command afterward. Test the exact sequence, -dependency behavior, and recovery from a failed installation before publishing -copy-and-paste instructions. - -Switching from a script installation needs separate guidance. Its shims can -precede Homebrew's `bin` on `PATH`. Run the new Homebrew executable by its explicit -path to select it and refresh user shims. Preserve preferences, runtimes, global -packages, and the old payload. Do not silently delete the script installation. -Starting a fresh shell and checking `vp env doctor` must confirm the selected -installation. Switching back must also be tested. - -These are migration requirements, not a claim that all existing channel-switching -paths already preserve this state. Resolve any gaps in separate CLI changes -before the tap launch. - -Launch only with a release that contains the external setup fixes. There is no -requirement to backport the new tap to old Vite+ releases. Core, script, and -`vp-setup` installations retain their existing behavior. - -## Validation before launch - -Use isolated homes and synthetic credentials. Test the actual npm tarballs, -including the published native bindings, without a project-local `vite-plus` -installation that could hide missing global dependencies. - -The tap tests must cover: - -- Native package and JavaScript version agreement, locked dependency checksums, and target filtering. -- Offline dependency installation from downloaded resources with an empty pnpm store and no lifecycle scripts. -- Relocation and operation after the staging directory and pnpm store are removed. -- First and second invocation with a read-only keg; no dependency bootstrap or writes to package files. -- Formatting, linting, and a small production build through the installed global CLI. -- Public registry downloads, a custom registry, scoped registry settings, and `.npmrc` bearer tokens. -- Token substitution, custom `dist.tarball` paths, redirects, authentication failures, missing packages, and checksum failures. -- No credentials in logs, formula metadata, receipts, or installed files; no fallback to the public registry after an authentication error. -- `brew fetch`, installation, reinstall, upgrade, and removal on every supported target. -- Two users sharing the package with separate setup receipts and preferences. -- Removal of an old keg followed by saved `vp` and tool-shim paths in existing Bash and Zsh sessions. -- Mixed management preferences, runtime selection, lifecycle command guidance, and channel migration. - -Run Homebrew checks on changes to the formula, downloader, dependency metadata, -and update script. Allow maintainers to request the full installation suite for -other source changes with the `test: install-e2e` label. Run untrusted PR code -without publishing credentials. These checks need not run on every source PR. - -## Alternatives - -A separate repository such as `voidzero-dev/homebrew-tap` would provide a smaller -checkout and direct installation without an initial custom-URL tap command. -Keeping the formula in `vite-plus` puts code, packaging, and tests in one review -process. A separate repository remains an option if checkout size becomes a -problem. - -| Approach | Benefit | Cost or limitation | -| -------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| Existing npm packages (proposed) | Reuses released packages and the script installer's dependency installation model | Requires Node.js and pnpm during installation, dependency metadata, and registry authentication support | -| Complete bundle on GitHub Releases | Simple extraction and no npm access during CLI payload installation | Requires new bundle assembly, larger assets, and another packaging format | -| Run `install.sh` from a formula | Reuses the entrypoint directly | Installs user-managed files and loses Homebrew ownership of the active CLI | -| Keep only a native binary in the keg and install JavaScript per user | Avoids dependency installation during `brew install` | Requires a new external-binary setup and JavaScript resolution contract, per-user downloads, and version coordination | -| Source formula with project-built bottles | Conventional Homebrew packaging | Retains source recipe complexity and adds a bottle pipeline | -| Continue with core only | No additional distribution channel | Packaging follows core's process and source build constraints | - -The tap can distribute prebuilt executables inside npm tarballs. This does not -imply that the same recipe would be accepted in `homebrew/core`. The formula -prepares published binaries and packages; `--build-from-source` would not compile -the Vite+ sources. Source builds remain available through the project and core. - -## Decisions requested - -The primary direction is existing npm packages with Homebrew ownership, using -`HOMEBREW_NPM_CONFIG_REGISTRY` and authenticated `~/.npmrc` support. The npm -package format stays unchanged. Remaining decisions are: - -1. Who owns formula updates and Homebrew support within the project? -2. Should the initial tap cover all four proposed targets, or start with macOS while Linux tests mature? -3. Is a Homebrew Node.js build dependency acceptable while the CLI retains its existing runtime selection? -4. Does the locked-resource and offline-pnpm approach provide the right balance of reproducibility and formula complexity? -5. Which enterprise authentication requirements beyond scoped bearer tokens belong in the first version? - -After agreement, split implementation into registry downloads, package -installation and formula metadata, CLI formula identity, and release automation -with Homebrew tests. Keep product documentation on the current installation -instructions until the tap is usable. Then update the global CLI, upgrade, and -implode guides, plus the directory layout RFC where the ownership contract -changes. +To reuse a token stored in `NPM_TOKEN`, forward its value as `HOMEBREW_NPM_TOKEN` +and update the `.npmrc` placeholder. Homebrew removes `NPM_TOKEN` from its +environment. + +A custom download strategy reads credentials when fetching resources, before +Homebrew's [build sandbox](https://github.com/Homebrew/brew/blob/main/Library/Homebrew/sandbox.rb) +restricts access to `~/.npmrc`. It uses package metadata's `dist.tarball` URL and +scopes credentials to each request's host and path, including redirects. +Credentials must stay out of formula metadata, logs, receipts, and installed +files. Preserve checksums and report authentication errors without falling back +to the public registry. The subsequent pnpm installation runs offline and needs +no credentials. + +The registry setting covers CLI packages and their dependencies. Homebrew +itself, its dependencies, and managed Node.js downloads have separate network +requirements. Initial authentication support is limited to bearer tokens. + +## Repository layout + +```text +vite-plus/ +├── HomebrewFormula/ +│ ├── vp.rb +│ └── vp/ +│ ├── package.json +│ ├── pnpm-lock.yaml +│ └── resources.json +├── lib/homebrew/ +│ └── vp_npm_download_strategy.rb +└── .github/ + ├── scripts/update-homebrew-formula.mjs + └── workflows/ # release integration and installation tests +``` + +Support-file names are illustrative. Keep Ruby helpers outside the formula +directory and commit only dependency metadata, not downloaded packages. +Homebrew supports the [`HomebrewFormula/` layout](https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap#creating-a-tap), +but tapping the repository also downloads unrelated project files. A separate +tap repository would reduce that cost at the expense of a separate review process. + +## Commands and migration + +Extend the existing [Homebrew detection](../crates/vp_global_cli/src/homebrew.rs) +to identify the installed formula and its tap from the installation path and +receipt. `vp upgrade` must direct users to +`brew upgrade voidzero-dev/vite-plus/vp`, and `vp upgrade --check` to +`brew outdated voidzero-dev/vite-plus/vp`. Explicit versions, force, and rollback +must retain the ownership guard. Keep npm update checks disabled for these +installations, and report the formula identity in `vp env doctor`. + +`vp implode` removes user-managed data; `brew uninstall` removes the package. +Document both steps for complete removal. The formula must not delete user data +from an uninstall hook. + +Declare a conflict with core's `vite-plus` formula because both install the same +command names. Migration must replace the package and refresh user shims while +preserving preferences, runtimes, and global packages. Do not use `vp implode` +to migrate. Document switching from script installations as well, where existing +shims can take precedence over Homebrew's commands. + +Core and script installations remain supported. The tap targets new releases; +backporting it to older versions is outside this proposal. + +## Release and launch checks + +After all npm packages for a stable release are available, automation opens a +formula update PR. Update the version, dependency lock, target resources, and +checksums together. The formula on `main` must reference a published release; +installation must not resolve `latest` or regenerate the lockfile. + +A failed update leaves the previous formula available. Use a formula `revision` +for recipe fixes and a new npm release when package contents must change. +A formula-only merge must not trigger another product release. + +Before launch, test: + +- Public and authenticated private registries, scoped configuration, redirects, + and checksum failures, with no credential leaks or unintended public fallback. +- Offline dependency installation and CLI formatting, linting, and builds after + removing staging files and caches, without a project-local `vite-plus`. +- First and later use with a read-only package and separate user homes; upgrades + must preserve preferences and working shims after deleting the old version. +- Actual Homebrew fetch, install, reinstall, upgrade, removal, and migration on + each supported target. + +Run these checks for packaging changes and on the `test: install-e2e` label. +Use isolated homes and synthetic credentials; PR tests receive no publishing +credentials. Update user installation and migration guides when the tap is ready. + +## Tradeoffs and open decisions + +The npm approach reuses existing releases, but requires Node.js and pnpm during +installation, a locked resource list, and a registry downloader. Complete bundles +on GitHub Releases would simplify extraction but add another packaging format. +Keeping only the native binary in Homebrew would require new ownership and +version coordination for JavaScript installed per user. + +Before implementation, decide: + +1. Who maintains the tap and release automation? +2. Should launch include all four targets, or start with macOS? +3. Is the proposed build-time Node.js and offline pnpm approach acceptable? +4. Are scoped bearer tokens sufficient for the first release? From c905d9536651fb6bb8caefad419fb539fb399320 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 14:49:11 +0800 Subject: [PATCH 10/31] docs: simplify tap with first-run dependency setup --- rfcs/official-homebrew-tap.md | 248 +++++++++++++++------------------- 1 file changed, 109 insertions(+), 139 deletions(-) diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index 2571f83ec6..a521137fe9 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -12,9 +12,9 @@ requires Rust and JavaScript builds when a bottle is unavailable. Issue mismatch between Homebrew installation and first-run setup, addressed by [#2729](https://github.com/voidzero-dev/vite-plus/pull/2729). -A project-maintained tap can reuse the npm packages used by -[`install.sh`](../packages/cli/install.sh), avoid compiling Vite+ from source, -and support enterprise npm registries. +A project-maintained tap can install the native CLI from GitHub Releases and +reuse the script installer's first-run dependency setup. This keeps npm access +out of `brew install` and lets setup use the user's npm configuration. ## Proposal @@ -31,187 +31,157 @@ The explicit repository URL is required because Homebrew otherwise looks for `voidzero-dev/homebrew-vite-plus`. Use the qualified formula name to distinguish it from core's `vite-plus` formula and `vp` alias. -Use npm as the primary distribution source. Download the native binary from -`@voidzero-dev/vite-plus-cli-` and install `vite-plus` at the same -version with its production dependencies. Keep the published package formats -unchanged. +Homebrew downloads the target's [GitHub release archive](https://github.com/voidzero-dev/vite-plus/releases), +verifies its pinned SHA-256 checksum, and installs `vp` with `vpr` and `vpx` +aliases. The formula needs no npm access, Node.js, or pnpm during installation. -Follow the script installer's sequence: download the binary, install JavaScript -dependencies, then configure the user environment. Homebrew owns the first two -steps and all package upgrades and removal. The first `vp` invocation runs the -existing per-user setup. Running `install.sh` unchanged would instead place -files in the user's managed installation. +On first use, `vp` installs the matching `vite-plus` npm package and its +production dependencies into the user's data directory. It then configures the +user's settings and shims. Homebrew retains ownership of the executable; Vite+ +manages each user's dependencies. Published package formats stay unchanged. The initial scope is stable releases on macOS and glibc Linux, for ARM64 and x64. Launch each target only after its installation tests pass. -## Installation design +## First-run setup -The formula uses a release-generated manifest, dependency lock, and package -checksums: +Add a setup mode for a Homebrew-owned binary without bundled JavaScript: -1. Download the native CLI, pinned pnpm, and locked production dependencies as - [Homebrew resources](https://docs.brew.sh/Formula-Cookbook#language-specific-dependencies). - Include the target's optional native packages. -2. Point a working copy of the lockfile at the verified local tarballs. Retain - its package versions and integrity values. -3. Use a Homebrew Node.js build dependency to run the pinned pnpm with - `--offline --frozen-lockfile --prod --ignore-scripts`. -4. Install the executable into `prefix/bin/vp` and the dependency tree into - `prefix/node_modules`. Add `vpr` and `vpx` links to `vp`. +1. Keep the executable in Homebrew's Cellar. Reuse the shared Node.js and pinned + pnpm bootstrap, then install `vite-plus` at the executable's exact version. +2. Store dependencies in a dedicated directory under `VpDirs.data`, keyed by + CLI version and platform, separate from the script installer's `current` link. +3. Record successful dependency installation in user-owned state. Reuse existing + per-user setup for shims and management preferences. +4. Execute commands through the Homebrew binary using the matching dependency + directory. Later invocations reuse that installation. -Pin pnpm to the version used by the existing -[dependency installer](../crates/vp_setup/src/install.rs). Copy dependency files -from an isolated pnpm store; installed links must stay within the package. -The CLI must work after the build directory and store are removed. +For example: ```text -/vp// -├── bin/ # vp, vpr, vpx -├── node_modules/ # vite-plus and its production dependencies -└── INSTALL_RECEIPT.json # Homebrew ownership record +Homebrew: /vp//bin/vp +User: /cli-packages///node_modules/vite-plus ``` -The existing [external setup](../crates/vp_global_cli/src/self_setup.rs) and -[JavaScript resolver](../crates/vp_global_cli/src/js_executor.rs) support this -layout. First use creates each user's settings, shims, and setup receipt without -copying the package or writing into the Cellar. Upgrades must preserve those -settings, and shims must survive removal of the old package version. +The user path is illustrative; resolve it through `VpDirs`. Shims must use a +stable Homebrew entrypoint and keep working after the old Cellar version is +removed. Setup must not copy the executable or write into the Cellar. -Runtime Node.js selection remains unchanged: system-first mode can use Node.js -on `PATH`, while managed mode can download a separate runtime. The Node.js build -dependency does not override that preference. +After a Homebrew upgrade, the new binary prepares its dependencies on first use. +This is once per user, version, and platform, not once for the machine. Preserve +saved management choices. Serialize concurrent setup attempts and record +completion only after a successful install, so interrupted downloads can retry. -## Custom registries and authentication +The current [self-setup code](../crates/vp_global_cli/src/self_setup.rs) copies +bare external binaries into a managed installation. The +[JavaScript resolver](../crates/vp_global_cli/src/js_executor.rs) expects +JavaScript beside the executable. Both need changes for this mode. Existing +script installations and core installations with bundled JavaScript must retain +their behavior. Automatic setup needs no new public `vp setup` command. -Choose the default registry in this order: +Runtime Node.js selection keeps the existing system-first and managed modes. +Bootstrapping the tools needed for dependency installation must not change the +user's saved runtime preferences. -1. A nonempty `HOMEBREW_NPM_CONFIG_REGISTRY`. -2. `registry` in `~/.npmrc`. -3. `https://registry.npmjs.org`. +## Registry configuration -Honor scoped registry entries such as `@voidzero-dev:registry` ahead of the -default for matching packages. Read the user's `.npmrc`, not a project's file. -These settings belong to the proposed tap; they are not built-in Homebrew -registry settings. - -Homebrew's [launcher](https://github.com/Homebrew/brew/blob/main/bin/brew) -filters out ordinary npm environment variables but preserves `HOMEBREW_*`. -Users can forward an existing registry setting: - -```sh -HOMEBREW_NPM_CONFIG_REGISTRY="${NPM_CONFIG_REGISTRY:-}" \ - brew install voidzero-dev/vite-plus/vp -``` - -Support registry-scoped bearer tokens in `~/.npmrc`, either as literal values or -through `${HOMEBREW_NPM_TOKEN}`. For example, after exporting that variable: +The user runs `vp` outside Homebrew's build sandbox, so pnpm can read `~/.npmrc` +and inherit normal registry and token environment variables. Follow +[pnpm's registry and authentication rules](https://pnpm.io/10.x/npmrc), including +scoped registries and user-level token placeholders: ```ini registry=https://registry.example.com/repository/npm/ -//registry.example.com/repository/npm/:_authToken=${HOMEBREW_NPM_TOKEN} +//registry.example.com/repository/npm/:_authToken=${NPM_TOKEN} ``` -To reuse a token stored in `NPM_TOKEN`, forward its value as `HOMEBREW_NPM_TOKEN` -and update the `.npmrc` placeholder. Homebrew removes `NPM_TOKEN` from its -environment. +Users export `NPM_TOKEN` before running `vp`. They can also use +`NPM_CONFIG_REGISTRY` for a registry override. No Homebrew-specific npm variables +or Homebrew npm downloader are required. Run dependency installation in its own +directory so the calling project's workspace and `.npmrc` do not control it. + +One bootstrap gap remains: the shared [dependency installer](../crates/vp_setup/src/install.rs) +downloads pnpm through Rust before pnpm can read configuration. That download +must honor the user's registry and credentials too. Fix and test this shared +bootstrap path before claiming support for authenticated private registries. +Keep credentials out of logs and receipts, and do not fall back to the public +registry after authentication fails. + +First use requires access to the configured npm registry and any uncached +Node.js/bootstrap downloads. GitHub access alone is sufficient for the tap's +package download, but does not make the full CLI ready for offline use. + +## Commands and migration + +Extend [Homebrew detection](../crates/vp_global_cli/src/homebrew.rs) to identify +the installed formula and tap. `vp upgrade` directs users to +`brew upgrade voidzero-dev/vite-plus/vp`; `vp upgrade --check` directs them to +`brew outdated voidzero-dev/vite-plus/vp`. Keep the ownership guard for explicit +versions, force, and rollback, and keep npm update checks disabled for the CLI. +`vp env doctor` reports the Homebrew executable and per-user dependency location. -A custom download strategy reads credentials when fetching resources, before -Homebrew's [build sandbox](https://github.com/Homebrew/brew/blob/main/Library/Homebrew/sandbox.rb) -restricts access to `~/.npmrc`. It uses package metadata's `dist.tarball` URL and -scopes credentials to each request's host and path, including redirects. -Credentials must stay out of formula metadata, logs, receipts, and installed -files. Preserve checksums and report authentication errors without falling back -to the public registry. The subsequent pnpm installation runs offline and needs -no credentials. +`vp implode` removes user-managed dependencies, runtimes, settings, and shims. +`brew uninstall` removes the executable. Document both steps for complete +removal; the formula must not delete user data from an uninstall hook. -The registry setting covers CLI packages and their dependencies. Homebrew -itself, its dependencies, and managed Node.js downloads have separate network -requirements. Initial authentication support is limited to bearer tokens. +Declare a conflict with core's `vite-plus` formula because both install the same +command names. Migration must refresh shims while preserving preferences, +runtimes, and global packages. Do not use `vp implode` to migrate. Document +switching from script installations, whose shims may take precedence on `PATH`. +Core and script installations remain supported; the tap targets new releases. -## Repository layout +## Repository and releases ```text vite-plus/ -├── HomebrewFormula/ -│ ├── vp.rb -│ └── vp/ -│ ├── package.json -│ ├── pnpm-lock.yaml -│ └── resources.json -├── lib/homebrew/ -│ └── vp_npm_download_strategy.rb +├── HomebrewFormula/vp.rb └── .github/ ├── scripts/update-homebrew-formula.mjs └── workflows/ # release integration and installation tests ``` -Support-file names are illustrative. Keep Ruby helpers outside the formula -directory and commit only dependency metadata, not downloaded packages. Homebrew supports the [`HomebrewFormula/` layout](https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap#creating-a-tap), -but tapping the repository also downloads unrelated project files. A separate +but tapping this repository also downloads unrelated project files. A separate tap repository would reduce that cost at the expense of a separate review process. -## Commands and migration - -Extend the existing [Homebrew detection](../crates/vp_global_cli/src/homebrew.rs) -to identify the installed formula and its tap from the installation path and -receipt. `vp upgrade` must direct users to -`brew upgrade voidzero-dev/vite-plus/vp`, and `vp upgrade --check` to -`brew outdated voidzero-dev/vite-plus/vp`. Explicit versions, force, and rollback -must retain the ownership guard. Keep npm update checks disabled for these -installations, and report the formula identity in `vp env doctor`. - -`vp implode` removes user-managed data; `brew uninstall` removes the package. -Document both steps for complete removal. The formula must not delete user data -from an uninstall hook. - -Declare a conflict with core's `vite-plus` formula because both install the same -command names. Migration must replace the package and refresh user shims while -preserving preferences, runtimes, and global packages. Do not use `vp implode` -to migrate. Document switching from script installations as well, where existing -shims can take precedence over Homebrew's commands. - -Core and script installations remain supported. The tap targets new releases; -backporting it to older versions is outside this proposal. - -## Release and launch checks +After both the GitHub archives and matching npm packages are published, +automation opens a formula update PR with the version, target URLs, and +checksums. A failed update leaves the previous formula available. Use a formula +`revision` for recipe fixes and a new release for CLI changes. A formula-only +merge must not trigger another product release. -After all npm packages for a stable release are available, automation opens a -formula update PR. Update the version, dependency lock, target resources, and -checksums together. The formula on `main` must reference a published release; -installation must not resolve `latest` or regenerate the lockfile. +## Launch checks -A failed update leaves the previous formula available. Use a formula `revision` -for recipe fixes and a new npm release when package contents must change. -A formula-only merge must not trigger another product release. +Test with isolated homes and synthetic credentials: -Before launch, test: +- `brew install` succeeds with npm access blocked and no preinstalled Node.js or + pnpm. The formula does not trigger user setup. +- First use on a fresh home bootstraps dependencies through public and + authenticated private registries, including pnpm's own download. +- Later invocations reuse dependencies; failed and concurrent setup attempts + recover without repeated preference prompts or writes to the Cellar. +- Formatting, linting, and builds use the matching package without a + project-local `vite-plus` masking missing dependencies. +- Homebrew upgrade, removal of the old version, separate users, uninstall, + `vp implode`, and migration preserve the ownership rules above. -- Public and authenticated private registries, scoped configuration, redirects, - and checksum failures, with no credential leaks or unintended public fallback. -- Offline dependency installation and CLI formatting, linting, and builds after - removing staging files and caches, without a project-local `vite-plus`. -- First and later use with a read-only package and separate user homes; upgrades - must preserve preferences and working shims after deleting the old version. -- Actual Homebrew fetch, install, reinstall, upgrade, removal, and migration on - each supported target. - -Run these checks for packaging changes and on the `test: install-e2e` label. -Use isolated homes and synthetic credentials; PR tests receive no publishing -credentials. Update user installation and migration guides when the tap is ready. +Run installation checks for packaging changes and on the `test: install-e2e` +label. PR tests receive no publishing credentials. Update installation and +migration guides when the tap is ready. ## Tradeoffs and open decisions -The npm approach reuses existing releases, but requires Node.js and pnpm during -installation, a locked resource list, and a registry downloader. Complete bundles -on GitHub Releases would simplify extraction but add another packaging format. -Keeping only the native binary in Homebrew would require new ownership and -version coordination for JavaScript installed per user. +The formula stays small and dependency installation shares the script installer's +code. Each user downloads dependencies, and the first invocation after an upgrade +may need network access. Homebrew manages the executable rather than the complete +JavaScript installation. + +Installing dependencies into the Cellar would make them available to all users, +but requires npm configuration handling during Homebrew installation. A complete +GitHub bundle would avoid that step at the cost of another packaging format. Before implementation, decide: 1. Who maintains the tap and release automation? 2. Should launch include all four targets, or start with macOS? -3. Is the proposed build-time Node.js and offline pnpm approach acceptable? -4. Are scoped bearer tokens sufficient for the first release? +3. How should the shared pnpm bootstrap reuse npm authentication configuration? From 0dcceb516df4d477edacaaa72f9282c43e35b651 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 16:32:17 +0800 Subject: [PATCH 11/31] docs: assume network access for Homebrew tap --- rfcs/official-homebrew-tap.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index a521137fe9..5122c90409 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -43,6 +43,9 @@ manages each user's dependencies. Published package formats stay unchanged. The initial scope is stable releases on macOS and glibc Linux, for ARM64 and x64. Launch each target only after its installation tests pass. +Installation assumes network access: GitHub for the tap and binary, and the +configured npm registry and runtime sources for first-run setup. + ## First-run setup Add a setup mode for a Homebrew-owned binary without bundled JavaScript: @@ -107,10 +110,6 @@ bootstrap path before claiming support for authenticated private registries. Keep credentials out of logs and receipts, and do not fall back to the public registry after authentication fails. -First use requires access to the configured npm registry and any uncached -Node.js/bootstrap downloads. GitHub access alone is sufficient for the tap's -package download, but does not make the full CLI ready for offline use. - ## Commands and migration Extend [Homebrew detection](../crates/vp_global_cli/src/homebrew.rs) to identify @@ -154,8 +153,8 @@ merge must not trigger another product release. Test with isolated homes and synthetic credentials: -- `brew install` succeeds with npm access blocked and no preinstalled Node.js or - pnpm. The formula does not trigger user setup. +- `brew install` downloads the release and creates the commands without + preinstalled Node.js or pnpm. The formula does not run npm or user setup. - First use on a fresh home bootstraps dependencies through public and authenticated private registries, including pnpm's own download. - Later invocations reuse dependencies; failed and concurrent setup attempts @@ -172,9 +171,8 @@ migration guides when the tap is ready. ## Tradeoffs and open decisions The formula stays small and dependency installation shares the script installer's -code. Each user downloads dependencies, and the first invocation after an upgrade -may need network access. Homebrew manages the executable rather than the complete -JavaScript installation. +code. Each user downloads and stores their own dependencies. Homebrew manages +the executable; Vite+ manages the per-user JavaScript installation. Installing dependencies into the Cellar would make them available to all users, but requires npm configuration handling during Homebrew installation. A complete From b316ff1252853541e9696171aab577da6ff5813d Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 17:01:39 +0800 Subject: [PATCH 12/31] docs: illustrate Homebrew installation stages --- rfcs/official-homebrew-tap.md | 155 +++++++++++++++++++++++++++------- 1 file changed, 123 insertions(+), 32 deletions(-) diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index 5122c90409..b05ee823e2 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -46,45 +46,136 @@ x64. Launch each target only after its installation tests pass. Installation assumes network access: GitHub for the tap and binary, and the configured npm registry and runtime sources for first-run setup. -## First-run setup +## Installation flow -Add a setup mode for a Homebrew-owned binary without bundled JavaScript: +The snapshots show a fresh installation, then an upgrade from version `A` to +`B`. `` is Homebrew's prefix, such as `/opt/homebrew`. User directories +``, ``, ``, and `` come from `VpDirs` and respect the +user's directory overrides. Dependency paths and receipt names are illustrative. +Each stage shows relevant additions or changes; other files are omitted. -1. Keep the executable in Homebrew's Cellar. Reuse the shared Node.js and pinned - pnpm bootstrap, then install `vite-plus` at the executable's exact version. -2. Store dependencies in a dedicated directory under `VpDirs.data`, keyed by - CLI version and platform, separate from the script installer's `current` link. -3. Record successful dependency installation in user-owned state. Reuse existing - per-user setup for shims and management preferences. -4. Execute commands through the Homebrew binary using the matching dependency - directory. Later invocations reuse that installation. +### 1. Register the tap -For example: +`brew tap` clones the repository. At this point, Homebrew has the formula but +has not installed the CLI. ```text -Homebrew: /vp//bin/vp -User: /cli-packages///node_modules/vite-plus +/ +└── HomebrewFormula/vp.rb ``` -The user path is illustrative; resolve it through `VpDirs`. Shims must use a -stable Homebrew entrypoint and keep working after the old Cellar version is -removed. Setup must not copy the executable or write into the Cellar. - -After a Homebrew upgrade, the new binary prepares its dependencies on first use. -This is once per user, version, and platform, not once for the machine. Preserve -saved management choices. Serialize concurrent setup attempts and record -completion only after a successful install, so interrupted downloads can retry. - -The current [self-setup code](../crates/vp_global_cli/src/self_setup.rs) copies -bare external binaries into a managed installation. The -[JavaScript resolver](../crates/vp_global_cli/src/js_executor.rs) expects -JavaScript beside the executable. Both need changes for this mode. Existing -script installations and core installations with bundled JavaScript must retain -their behavior. Automatic setup needs no new public `vp setup` command. - -Runtime Node.js selection keeps the existing system-first and managed modes. -Bootstrapping the tools needed for dependency installation must not change the -user's saved runtime preferences. +### 2. Install the native CLI + +`brew install` downloads the GitHub archive, installs the CLI into the Cellar, +and links the public commands. It creates no per-user Vite+ installation. + +```text +/ +├── Cellar/vp/A/ +│ ├── bin/ +│ │ ├── vp +│ │ ├── vpr -> vp +│ │ └── vpx -> vp +│ └── INSTALL_RECEIPT.json +└── bin/ + ├── vp -> ../Cellar/vp/A/bin/vp + ├── vpr -> ../Cellar/vp/A/bin/vpr + └── vpx -> ../Cellar/vp/A/bin/vpx +``` + +### 3. Prepare dependencies on first use + +The first `vp` invocation reuses the shared Node.js and pinned pnpm bootstrap. +It installs `vite-plus@A` and production dependencies in the user's data +directory, keyed by CLI version and platform. The Homebrew files stay unchanged. + +```text +/ +├── js_runtime/node// +├── package_manager/pnpm// +└── cli-packages/A// + ├── package.json + ├── pnpm-lock.yaml + └── node_modules/ + ├── vite-plus/ + └── ... # production dependencies +``` + +### 4. Complete user setup + +Setup creates preferences, shell environment files, and shims. It records +completion in user-owned state only after installation succeeds. Example shims +link through Homebrew's stable public command, not a versioned Cellar path. + +```text +/ +├── config.json +└── env # Bash/Zsh; other shell files omitted + +/ +├── vp -> /bin/vp +├── node -> /bin/vp +├── npm -> /bin/vp +└── ... # other tool shims + +/self-setup/ +└── .json +``` + +The executable remains at `/Cellar/vp/A/bin/vp` and loads JavaScript from +`/cli-packages/A//node_modules/vite-plus`. Later invocations +reuse these files. Runtime selection retains the user's system-first or managed +preferences, independently of the tools used to install dependencies. + +### 5. Upgrade through Homebrew + +`brew upgrade` installs version `B` and replaces its public links. User shims +continue to point through those links, even after Homebrew removes version `A`. + +```text +/Cellar/vp/B/ +├── bin/ # new vp, vpr, vpx +└── INSTALL_RECEIPT.json + +/bin/ +├── vp -> ../Cellar/vp/B/bin/vp +├── vpr -> ../Cellar/vp/B/bin/vpr +└── vpx -> ../Cellar/vp/B/bin/vpx +``` + +The first invocation of `B` adds its matching dependencies and completion record. +It preserves the user's preferences and reuses existing bootstrap tools when +applicable. + +```text +/cli-packages/B// +├── package.json +├── pnpm-lock.yaml +└── node_modules/ + ├── vite-plus/ + └── ... + +/self-setup/ +└── .json +``` + +Dependency setup runs once per user, version, and platform. Serialize concurrent +attempts and allow interrupted installs to retry without repeating preference +prompts. Keep these directories separate from the script installer's `current` +link. + +## Required CLI changes + +Add a mode for Homebrew-owned binaries without bundled JavaScript. The current +[self-setup code](../crates/vp_global_cli/src/self_setup.rs) copies bare external +binaries into a managed installation; this mode must retain the Homebrew binary. +The [JavaScript resolver](../crates/vp_global_cli/src/js_executor.rs) must find +its matching per-user dependency directory instead of expecting JavaScript +beside the executable. + +Existing script installations and core installations with bundled JavaScript +must retain their behavior. Automatic setup needs no new public `vp setup` +command. Setup must not write into the Cellar. ## Registry configuration From 8ad6bc53a8efc8ba0c8bd6a3b4004ee73d28c667 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 22 Sep 2026 16:55:39 +0800 Subject: [PATCH 13/31] docs: align Homebrew tap RFC with current setup --- rfcs/official-homebrew-tap.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index b05ee823e2..69c02f9aae 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -104,8 +104,9 @@ directory, keyed by CLI version and platform. The Homebrew files stay unchanged. ### 4. Complete user setup Setup creates preferences, shell environment files, and shims. It records -completion in user-owned state only after installation succeeds. Example shims -link through Homebrew's stable public command, not a versioned Cellar path. +completion in user-owned state only after installation succeeds. All shims +link through Homebrew's stable public command. This example uses managed +Node.js and system-first npm: ```text / @@ -115,8 +116,12 @@ link through Homebrew's stable public command, not a versioned Cellar path. / ├── vp -> /bin/vp ├── node -> /bin/vp +└── ... # vpr, vpx, and other managed shims + +/fallback-bin/ ├── npm -> /bin/vp -└── ... # other tool shims +├── npx -> /bin/vp +└── ... # other system-first shims /self-setup/ └── .json @@ -124,8 +129,10 @@ link through Homebrew's stable public command, not a versioned Cellar path. The executable remains at `/Cellar/vp/A/bin/vp` and loads JavaScript from `/cli-packages/A//node_modules/vite-plus`. Later invocations -reuse these files. Runtime selection retains the user's system-first or managed -preferences, independently of the tools used to install dependencies. +reuse these files. The generated environment follows the existing +[directory layout](directory-layout.md): `` comes first on `PATH`, and +`/fallback-bin` comes last. System tools take precedence over fallback +shims. These preferences are independent of the tools used to install dependencies. ### 5. Upgrade through Homebrew @@ -173,6 +180,11 @@ The [JavaScript resolver](../crates/vp_global_cli/src/js_executor.rs) must find its matching per-user dependency directory instead of expecting JavaScript beside the executable. +Reuse [environment setup](../crates/vp_global_cli/src/commands/env/setup.rs) +to initialize missing package-manager preferences and place shims. Preserve +saved choices when dependencies change. Both shim directories must retain +stable Homebrew targets across upgrades. + Existing script installations and core installations with bundled JavaScript must retain their behavior. Automatic setup needs no new public `vp setup` command. Setup must not write into the Cellar. @@ -254,6 +266,9 @@ Test with isolated homes and synthetic credentials: project-local `vite-plus` masking missing dependencies. - Homebrew upgrade, removal of the old version, separate users, uninstall, `vp implode`, and migration preserve the ownership rules above. +- Mixed management preferences survive upgrades. Commands through both shim + directories, including `pn` and `pnx`, still work after the old Cellar version + is removed. Run installation checks for packaging changes and on the `test: install-e2e` label. PR tests receive no publishing credentials. Update installation and From 1bba437f82974a48e3b4c851a1d03ea72232258f Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 22 Sep 2026 17:17:39 +0800 Subject: [PATCH 14/31] docs: propose Homebrew tap launch and bootstrap decisions --- rfcs/official-homebrew-tap.md | 68 +++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index 69c02f9aae..34599ef9b5 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -41,7 +41,10 @@ user's settings and shims. Homebrew retains ownership of the executable; Vite+ manages each user's dependencies. Published package formats stay unchanged. The initial scope is stable releases on macOS and glibc Linux, for ARM64 and -x64. Launch each target only after its installation tests pass. +x64. The [release build](../.github/workflows/reusable-release-build.yml) +already produces archives for all four targets. Launch each target after its +Homebrew installation tests pass. Treat Intel macOS as best-effort, consistent +with [Homebrew's support tiers](https://docs.brew.sh/Support-Tiers). Installation assumes network access: GitHub for the tap and binary, and the configured npm registry and runtime sources for first-run setup. @@ -85,14 +88,17 @@ and links the public commands. It creates no per-user Vite+ installation. ### 3. Prepare dependencies on first use -The first `vp` invocation reuses the shared Node.js and pinned pnpm bootstrap. -It installs `vite-plus@A` and production dependencies in the user's data -directory, keyed by CLI version and platform. The Homebrew files stay unchanged. +The first `vp` invocation prepares Node.js and uses its bundled npm to download +the pinned pnpm package. Then pnpm installs `vite-plus@A` and production +dependencies in the user's data directory, keyed by CLI version and platform. +The Homebrew files stay unchanged. ```text / ├── js_runtime/node// -├── package_manager/pnpm// +│ ├── bin/node +│ └── lib/node_modules/npm/ # included in the Node.js distribution +├── package_manager/pnpm//pnpm/ └── cli-packages/A// ├── package.json ├── pnpm-lock.yaml @@ -191,10 +197,10 @@ command. Setup must not write into the Cellar. ## Registry configuration -The user runs `vp` outside Homebrew's build sandbox, so pnpm can read `~/.npmrc` -and inherit normal registry and token environment variables. Follow -[pnpm's registry and authentication rules](https://pnpm.io/10.x/npmrc), including -scoped registries and user-level token placeholders: +The user runs `vp` outside Homebrew's build sandbox. Both npm and pnpm read +`~/.npmrc`, or the file selected by `NPM_CONFIG_USERCONFIG`, and inherit registry +and token environment variables. Use [npm-compatible authentication](https://docs.npmjs.com/cli/v11/configuring-npm/npmrc/) +at both stages, including registry-scoped tokens and basic authentication: ```ini registry=https://registry.example.com/repository/npm/ @@ -203,15 +209,21 @@ registry=https://registry.example.com/repository/npm/ Users export `NPM_TOKEN` before running `vp`. They can also use `NPM_CONFIG_REGISTRY` for a registry override. No Homebrew-specific npm variables -or Homebrew npm downloader are required. Run dependency installation in its own -directory so the calling project's workspace and `.npmrc` do not control it. - -One bootstrap gap remains: the shared [dependency installer](../crates/vp_setup/src/install.rs) -downloads pnpm through Rust before pnpm can read configuration. That download -must honor the user's registry and credentials too. Fix and test this shared -bootstrap path before claiming support for authenticated private registries. -Keep credentials out of logs and receipts, and do not fall back to the public -registry after authentication fails. +or Homebrew npm downloader are required. + +Change the shared [dependency installer](../crates/vp_setup/src/install.rs) to +fetch pinned pnpm with Node's bundled npm. Invoke Node and `npm-cli.js` by their +absolute paths. Use [`npm pack`](https://docs.npmjs.com/cli/v11/commands/npm-pack/) +with an exact version, `--ignore-scripts`, `--json`, and `--workspaces=false` in +a temporary directory. npm handles registry metadata, tarball URLs, integrity, +and credentials. Reuse the existing pnpm cache layout and installation lock. +Then use that pnpm for dependency installation. + +Run both stages outside the calling project's workspace. Resolve an explicit +relative `NPM_CONFIG_USERCONFIG` before changing directories, and pass the same +registry override to both stages. Keep credentials out of command arguments, +logs, and receipts. Authentication failures must stop setup without public +registry fallback. pnpm-only credential helpers are outside the initial scope. ## Commands and migration @@ -252,6 +264,10 @@ checksums. A failed update leaves the previous formula available. Use a formula `revision` for recipe fixes and a new release for CLI changes. A formula-only merge must not trigger another product release. +Vite+ release maintainers own the formula and its release automation through +the existing repository review process. Confirm a primary maintainer and a +backup before launch. Formula failures belong in this repository's issue tracker. + ## Launch checks Test with isolated homes and synthetic credentials: @@ -259,7 +275,9 @@ Test with isolated homes and synthetic credentials: - `brew install` downloads the release and creates the commands without preinstalled Node.js or pnpm. The formula does not run npm or user setup. - First use on a fresh home bootstraps dependencies through public and - authenticated private registries, including pnpm's own download. + authenticated private registries, including pnpm's own download. Cover token + and basic authentication, registry and user-config overrides, and rejection + of invalid credentials or corrupted tarballs. - Later invocations reuse dependencies; failed and concurrent setup attempts recover without repeated preference prompts or writes to the Cellar. - Formatting, linting, and builds use the matching package without a @@ -274,18 +292,8 @@ Run installation checks for packaging changes and on the `test: install-e2e` label. PR tests receive no publishing credentials. Update installation and migration guides when the tap is ready. -## Tradeoffs and open decisions +## Tradeoffs The formula stays small and dependency installation shares the script installer's code. Each user downloads and stores their own dependencies. Homebrew manages the executable; Vite+ manages the per-user JavaScript installation. - -Installing dependencies into the Cellar would make them available to all users, -but requires npm configuration handling during Homebrew installation. A complete -GitHub bundle would avoid that step at the cost of another packaging format. - -Before implementation, decide: - -1. Who maintains the tap and release automation? -2. Should launch include all four targets, or start with macOS? -3. How should the shared pnpm bootstrap reuse npm authentication configuration? From f7aec7f2c68c141340903ec2e77001224f2b12ca Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 22 Sep 2026 18:28:02 +0800 Subject: [PATCH 15/31] feat: add official Homebrew tap with per-user setup --- .../scripts/__tests__/homebrew-formula.mjs | 89 ++++ .github/scripts/test-homebrew.mjs | 415 ++++++++++++++++++ .github/scripts/update-homebrew-formula.mjs | 148 +++++++ .github/workflows/release.yml | 52 +++ .github/workflows/test-homebrew.yml | 150 +++++++ HomebrewFormula/vp.rb | 63 +++ .../command_self_setup_external/homebrew.mjs | 313 +++++++++++++ .../snapshots.toml | 6 + .../command_self_setup_external_homebrew.md | 14 +- ...d_self_setup_homebrew_user_dependencies.md | 12 + .../fixtures/download_progress/install.mjs | 22 +- .../installation_progress_failure.md | 4 +- .../installation_progress_interactive.md | 2 +- .../vp_global_cli/src/commands/env/doctor.rs | 3 + .../vp_global_cli/src/commands/env/setup.rs | 30 +- crates/vp_global_cli/src/commands/implode.rs | 8 +- .../vp_global_cli/src/commands/upgrade/mod.rs | 15 +- crates/vp_global_cli/src/homebrew.rs | 146 +++++- crates/vp_global_cli/src/js_executor.rs | 4 + crates/vp_global_cli/src/self_setup.rs | 125 ++++-- .../vp_global_cli/src/self_setup/external.rs | 69 ++- crates/vp_pm_cli/src/lib.rs | 2 +- crates/vp_pm_cli/src/package_manager.rs | 3 + .../src/package_manager/bootstrap.rs | 217 +++++++++ crates/vp_pm_cli/src/request.rs | 5 +- crates/vp_setup/src/install.rs | 50 +-- crates/vp_shared/src/dirs.rs | 6 + docs/guide/global-cli.md | 4 +- docs/guide/homebrew.md | 78 ++++ docs/guide/implode.md | 4 +- docs/guide/upgrade.md | 6 +- rfcs/official-homebrew-tap.md | 26 +- 32 files changed, 1969 insertions(+), 122 deletions(-) create mode 100644 .github/scripts/__tests__/homebrew-formula.mjs create mode 100644 .github/scripts/test-homebrew.mjs create mode 100644 .github/scripts/update-homebrew-formula.mjs create mode 100644 .github/workflows/test-homebrew.yml create mode 100644 HomebrewFormula/vp.rb create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md create mode 100644 crates/vp_pm_cli/src/package_manager/bootstrap.rs create mode 100644 docs/guide/homebrew.md diff --git a/.github/scripts/__tests__/homebrew-formula.mjs b/.github/scripts/__tests__/homebrew-formula.mjs new file mode 100644 index 0000000000..9a0ceb51c8 --- /dev/null +++ b/.github/scripts/__tests__/homebrew-formula.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; + +import { + releaseAssets, + targets, + updateFormula, + verifyRelease, +} from '../update-homebrew-formula.mjs'; + +const formula = await readFile(new URL('../../../HomebrewFormula/vp.rb', import.meta.url), 'utf8'); +const version = '0.3.4'; +const archive = Buffer.from('release fixture'); +const digest = createHash('sha256').update(archive).digest('hex'); +const checksums = targets.map(([, , target]) => `${digest} vp-${target}.tar.gz`).join('\n'); +const assets = releaseAssets(version, checksums); +const release = { + tag_name: `v${version}`, + draft: false, + prerelease: false, + assets: assets.map((asset) => ({ name: asset.name, browser_download_url: asset.url })), +}; + +function download(url) { + return Promise.resolve(new Response(url.endsWith('vp-checksums.txt') ? checksums : archive)); +} + +test('release publication requires the compatible tag and each verified archive', async () => { + assert.deepEqual(await verifyRelease(version, release, download), assets); + await assert.rejects(verifyRelease(version, { ...release, draft: true }, download), /published/); + await assert.rejects( + verifyRelease(version, { ...release, assets: release.assets.slice(1) }, download), + /Missing release asset/, + ); + await assert.rejects( + verifyRelease(version, release, async (url) => + url.includes('bootstrap.rs') ? new Response('', { status: 404 }) : download(url), + ), + /Download failed/, + ); + await assert.rejects( + verifyRelease(version, release, async (url) => + url.endsWith('.tar.gz') ? new Response('corrupt') : download(url), + ), + /Checksum mismatch/, + ); +}); + +test('missing, repeated, and invalid checksums cannot update a formula', () => { + assert.throws( + () => releaseAssets(version, checksums.split('\n').slice(1).join('\n')), + /Expected one checksum/, + ); + assert.throws( + () => releaseAssets(version, `${checksums}\n${checksums}`), + /Expected one checksum/, + ); + assert.throws( + () => releaseAssets(version, checksums.replace(digest, 'invalid')), + /Invalid checksum/, + ); + assert.throws(() => releaseAssets('0.3.4-beta.1', checksums), /stable release/); +}); + +test('updates preserve recipe changes and reset only a previous version revision', () => { + const revised = formula.replace(' license', ' revision 2\n license'); + const result = updateFormula(revised, version, assets); + assert.ok(!result.includes('disable!')); + assert.ok(!result.includes('revision 2')); + assert.ok(result.includes('conflicts_with "vite-plus"')); + assert.ok(result.includes(' def install\n bin.install "vp"')); + const sameVersion = result.replace(' license', ' revision 1\n license'); + assert.equal(updateFormula(sameVersion, version, assets), sameVersion); + assert.throws(() => updateFormula(result, '0.3.3', assets), /downgrade/); + assert.throws( + () => updateFormula(formula.replace('BEGIN RELEASE ASSETS', 'missing'), version, assets), + /Missing release asset block/, + ); + assert.throws( + () => + updateFormula(formula, version, [ + { ...assets[0], url: 'https://example.test/"; system("bad")' }, + ...assets.slice(1), + ]), + /Unsafe formula URL/, + ); +}); diff --git a/.github/scripts/test-homebrew.mjs b/.github/scripts/test-homebrew.mjs new file mode 100644 index 0000000000..6f1704dc67 --- /dev/null +++ b/.github/scripts/test-homebrew.mjs @@ -0,0 +1,415 @@ +// Run only in a disposable prefix. This never uses the user's Homebrew installation. +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { once } from 'node:events'; +import fs from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; + +import { targets, updateFormula } from './update-homebrew-formula.mjs'; + +const { values } = parseArgs({ + options: { + binary: { type: 'string' }, + 'packages-dir': { type: 'string' }, + 'replacement-binary': { type: 'string' }, + 'replacement-version': { type: 'string' }, + 'brew-source': { type: 'string', default: 'https://github.com/Homebrew/brew' }, + }, +}); +assert.ok(values.binary, '--binary is required'); +const repository = fileURLToPath(new URL('../../', import.meta.url)); +const version = ( + await fs.readFile(path.join(repository, 'crates/vp_global_cli/Cargo.toml'), 'utf8') +).match(/^version = "([^"]+)"/m)[1]; +const replacementVersion = values['replacement-version'] ?? version; +const root = await fs.realpath(await fs.mkdtemp(path.join(tmpdir(), 'vp-homebrew-e2e-'))); +const prefix = path.join(root, 'brew'); +const brew = path.join(prefix, 'bin/brew'); +const formulaName = 'viteplus/e2e/vp'; +const platform = `${process.platform}-${process.arch}${process.platform === 'linux' ? '-gnu' : ''}`; +assert.ok(targets.some(([, , , suffix]) => suffix === platform)); +const baseEnv = Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => !/^(VP_|XDG_|HOMEBREW_|npm_|NPM_|PNPM_)/.test(name), + ), +); +const children = []; +const servers = []; +const token = randomBytes(24).toString('hex'); + +async function run(command, args, env = baseEnv, cwd = root, expected = 0) { + const child = spawn(command, args, { env, cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + for (const stream of [child.stdout, child.stderr]) + stream.setEncoding('utf8').on('data', (data) => { + output += data; + }); + const [code, signal] = await once(child, 'close'); + assert.equal(signal, null, output); + assert.ok(!output.includes(token), 'Registry credentials appeared in command output'); + assert.equal(code, expected, `${command} ${args.join(' ')}\n${output}`); + return output; +} + +async function registry(packagesDir) { + const args = [path.join(repository, 'packages/tools/src/local-npm-registry.ts'), '--serve']; + if (packagesDir) args.push('--packages-dir', packagesDir); + const child = spawn(process.execPath, args, { + env: baseEnv, + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + }); + children.push(child); + return await new Promise((resolve, reject) => { + let output = ''; + const timer = setTimeout( + () => reject(new Error(`Registry failed to start: ${output}`)), + 30_000, + ); + child.once('error', reject); + child.once('exit', (code) => { + clearTimeout(timer); + reject(new Error(`Registry exited: ${code} ${output}`)); + }); + child.stderr.on('data', (data) => { + output += data; + }); + child.stdout.setEncoding('utf8').on('data', (data) => { + output += data; + const handshake = output.split('\n').find((line) => line.startsWith('{"registry":')); + if (handshake) { + clearTimeout(timer); + resolve(JSON.parse(handshake).registry); + } + }); + }); +} + +// Emulate an enterprise registry. Credentials terminate here and are never sent upstream. +async function authenticatedRegistry(upstream) { + const requests = new Set(); + const server = createServer(async (request, response) => { + if (request.headers.authorization !== `Bearer ${token}`) + return response.writeHead(401).end('Unauthorized'); + requests.add(request.url); + try { + const remote = request.url.startsWith('/tarball/') + ? decodeURIComponent(request.url.slice('/tarball/'.length)) + : upstream + request.url; + assert.ok( + [new URL(upstream).origin, 'https://registry.npmjs.org'].includes(new URL(remote).origin), + ); + const result = await fetch(remote, { + headers: { accept: request.headers.accept ?? '*/*' }, + signal: AbortSignal.timeout(120_000), + }); + response.statusCode = result.status; + const contentType = result.headers.get('content-type') ?? ''; + response.setHeader('content-type', contentType); + if (contentType.includes('json')) { + const text = JSON.stringify(await result.json(), (key, value) => + key === 'tarball' && typeof value === 'string' + ? `${url}/tarball/${encodeURIComponent(value)}` + : value, + ); + response.end(text); + } else response.end(Buffer.from(await result.arrayBuffer())); + } catch { + response.writeHead(502).end('Registry fixture failed'); + } + }); + servers.push(server); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const url = `http://127.0.0.1:${server.address().port}`; + return { url, requests }; +} + +async function user(name, registryUrl, authenticated = false) { + const home = path.join(root, name); + await fs.mkdir(home); + await fs.writeFile( + path.join(home, '.npmrc'), + `registry=${registryUrl}/\n${authenticated ? `//${new URL(registryUrl).host}/:_authToken=\${NPM_TOKEN}\n` : ''}`, + ); + return { + ...baseEnv, + HOME: home, + VP_HOME: path.join(home, '.vite-plus'), + NPM_TOKEN: token, + VP_NODE_MANAGER: 'yes', + VP_PM_MANAGER: 'no', + VP_PNPM_MANAGER: 'yes', + VP_SELF_SETUP_NO_MODIFY_PATH: '1', + CI: 'true', + PATH: [ + path.join(home, '.vite-plus/bin'), + path.join(prefix, 'bin'), + '/usr/bin', + '/bin', + '/usr/sbin', + '/sbin', + path.join(home, '.vite-plus/fallback-bin'), + ].join(path.delimiter), + }; +} + +const packageDir = (env, value = version) => + path.join(env.VP_HOME, 'cli-packages', value, platform); +const shim = (env, tool) => path.join(env.VP_HOME, 'bin', tool); +const publicVp = path.join(prefix, 'bin/vp'); +const formula = await fs.readFile(path.join(repository, 'HomebrewFormula/vp.rb'), 'utf8'); + +async function formulaFor(binary, value, registryUrl, revision = false) { + const payload = path.join(root, `payload-${value}${revision ? '-revision' : ''}`); + await fs.mkdir(payload); + await fs.copyFile(path.resolve(binary), path.join(payload, 'vp')); + await fs.chmod(path.join(payload, 'vp'), 0o755); + const archive = `${payload}.tar.gz`; + await run('tar', ['-czf', archive, '-C', payload, 'vp']); + const sha256 = createHash('sha256') + .update(await fs.readFile(archive)) + .digest('hex'); + const assets = targets.map(([os, arch]) => ({ + os, + arch, + url: pathToFileURL(archive).href, + sha256, + })); + let result = updateFormula(formula, value, assets); + // Local archive URLs have no GitHub release tag from which Homebrew can infer the version. + result = result.replace(' license', ` version "${value}"\n license`); + if (revision) result = result.replace(' license', ' revision 1\n license'); + // brew test uses a separate home. Point only this disposable formula at the checkout registry. + result = result.replace( + ' test do\n', + ` test do\n ENV["NPM_CONFIG_REGISTRY"] = "${registryUrl}"\n`, + ); + return result; +} + +try { + const upstream = await registry(values['packages-dir'] && path.resolve(values['packages-dir'])); + const privateRegistry = await authenticatedRegistry(upstream); + await run('git', ['clone', '--depth', '1', values['brew-source'], prefix]); + const brewHome = path.join(root, 'brew-home'); + const brewTemp = path.join(root, 'brew-tmp'); + await fs.mkdir(brewHome); + await fs.mkdir(brewTemp); + const noNpm = path.join(root, 'no-npm'); + await fs.mkdir(noNpm); + for (const tool of ['node', 'npm', 'pnpm']) + await fs.writeFile( + path.join(noNpm, tool), + '#!/bin/sh\necho "npm tools must not run during brew install" >&2\nexit 77\n', + { mode: 0o755 }, + ); + const brewEnv = { + ...baseEnv, + HOME: brewHome, + HOMEBREW_TEMP: brewTemp, + HOMEBREW_CACHE: path.join(root, 'brew-cache'), + HOMEBREW_LOGS: path.join(root, 'brew-logs'), + HOMEBREW_NO_AUTO_UPDATE: '1', + HOMEBREW_NO_ANALYTICS: '1', + HOMEBREW_NO_ENV_HINTS: '1', + HOMEBREW_NO_INSTALL_CLEANUP: '1', + PATH: [noNpm, '/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter), + }; + const tapSource = path.join(root, 'tap'); + await fs.mkdir(path.join(tapSource, 'HomebrewFormula'), { recursive: true }); + await fs.writeFile( + path.join(tapSource, 'HomebrewFormula/vp.rb'), + await formulaFor(values.binary, version, upstream), + ); + await run('git', ['init', tapSource]); + await run('git', ['-C', tapSource, 'add', '.']); + await run('git', [ + '-C', + tapSource, + '-c', + 'user.name=Vite+ E2E', + '-c', + 'user.email=e2e@example.test', + '-c', + 'commit.gpgsign=false', + 'commit', + '-m', + 'Test tap', + ]); + await run(brew, ['tap', 'viteplus/e2e', pathToFileURL(tapSource).href], brewEnv); + await run(brew, ['install', '--build-from-source', formulaName], brewEnv); + const oldKeg = path.dirname(path.dirname(await fs.realpath(publicVp))); + const before = createHash('sha256') + .update(await fs.readFile(publicVp)) + .digest('hex'); + assert.ok(!(await fs.readdir(brewHome)).includes('.vite-plus')); + assert.ok(!(await fs.readdir(oldKeg)).includes('node_modules')); + for (const alias of ['vpr', 'vpx']) + assert.equal(await fs.realpath(path.join(prefix, 'bin', alias)), await fs.realpath(publicVp)); + console.log( + 'Homebrew installs the current native binary and aliases without npm tools or user setup.', + ); + + const first = await user('first-user', upstream); + const second = await user('second-user', privateRegistry.url, true); + const project = path.join(root, 'project'); + await fs.mkdir(project); + await fs.writeFile( + path.join(project, 'package.json'), + JSON.stringify({ + name: 'homebrew-e2e', + private: true, + type: 'module', + packageManager: 'pnpm@10.33.0', + }), + ); + await fs.writeFile( + path.join(project, 'index.html'), + '', + ); + await fs.writeFile(path.join(project, 'src.js'), "document.body.textContent = 'Homebrew';\n"); + await fs.chmod(oldKeg, 0o555); + await fs.chmod(path.join(oldKeg, 'bin'), 0o555); + await Promise.all([ + run(publicVp, ['--help'], first, project), + run(publicVp, ['--help'], first, project), + ]); + await run(publicVp, ['--help'], second, project); + assert.ok([...privateRegistry.requests].some((url) => url.startsWith('/pnpm'))); + assert.ok([...privateRegistry.requests].some((url) => url.startsWith('/vite-plus'))); + assert.equal( + createHash('sha256') + .update(await fs.readFile(publicVp)) + .digest('hex'), + before, + ); + assert.ok(!(await fs.readdir(path.join(oldKeg, 'bin'))).includes('.vp-setup-complete')); + assert.ok(!(await fs.readdir(first.VP_HOME)).includes('current')); + const settings = await fs.readFile(path.join(first.VP_HOME, 'config.json'), 'utf8'); + for (const env of [first, second]) + for (const variable of ['VP_NODE_MANAGER', 'VP_PM_MANAGER', 'VP_PNPM_MANAGER']) + delete env[variable]; + const marker = path.join(packageDir(first), '.vp-deps-complete'); + const completed = (await fs.stat(marker)).mtimeMs; + await run(shim(first, 'vp'), ['--help'], first, project); + assert.equal((await fs.stat(marker)).mtimeMs, completed); + for (const args of [ + ['fmt', 'src.js'], + ['fmt', '--check', 'src.js'], + ['lint', 'src.js'], + ['build'], + ]) + await run(publicVp, args, first, project); + assert.ok(!(await fs.readdir(project)).includes('node_modules')); + await fs.access(path.join(project, 'dist/index.html')); + console.log( + 'Public and authenticated setup install matching dependencies; fmt, lint, and build use the global package.', + ); + + await run(publicVp, ['install', '-g', 'typescript@5.8.3'], first, project); + const previous = path.join(first.VP_HOME, 'script-install/bin/vp'); + await fs.mkdir(path.dirname(previous), { recursive: true }); + await fs.copyFile(path.resolve(values.binary), previous); + await fs.symlink('script-install', path.join(first.VP_HOME, 'current')); + for (const name of ['vp', 'tsc']) { + await fs.unlink(shim(first, name)); + await fs.symlink(path.join(first.VP_HOME, 'current/bin/vp'), shim(first, name)); + } + await run(publicVp, ['env', 'setup', '--refresh'], first, project); + assert.equal(await fs.readlink(shim(first, 'tsc')), publicVp); + await fs.rm(path.join(first.VP_HOME, 'script-install'), { recursive: true }); + await run(shim(first, 'tsc'), ['--version'], first, project); + assert.equal(await fs.readFile(path.join(first.VP_HOME, 'config.json'), 'utf8'), settings); + console.log( + 'Migration refreshes global-package shims without replacing preferences or installed packages.', + ); + + let nextRegistry = upstream; + if (replacementVersion !== version) { + assert.ok(values['replacement-binary'] && values['packages-dir']); + const packages = path.join(root, 'next-packages'); + await fs.cp(path.resolve(values['packages-dir']), packages, { recursive: true }); + const cliArchive = (await fs.readdir(packages)).find((name) => name.startsWith('vite-plus-')); + const unpacked = path.join(root, 'next-cli'); + await fs.mkdir(unpacked); + await run('tar', ['-xzf', path.join(packages, cliArchive), '-C', unpacked]); + const manifestPath = path.join(unpacked, 'package/package.json'); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + manifest.version = replacementVersion; + await fs.writeFile(manifestPath, JSON.stringify(manifest)); + await run('tar', ['-czf', path.join(packages, cliArchive), '-C', unpacked, 'package']); + nextRegistry = await registry(packages); + } + await fs.chmod(oldKeg, 0o755); + await fs.chmod(path.join(oldKeg, 'bin'), 0o755); + const tap = (await run(brew, ['--repository', 'viteplus/e2e'], brewEnv)).trim(); + await fs.writeFile( + path.join(tap, 'HomebrewFormula/vp.rb'), + await formulaFor( + values['replacement-binary'] ?? values.binary, + replacementVersion, + nextRegistry, + replacementVersion === version, + ), + ); + await run(brew, ['upgrade', '--build-from-source', formulaName], brewEnv); + await run(brew, ['cleanup', '--prune=all', formulaName], brewEnv); + await assert.rejects(fs.access(oldKeg)); + await fs.writeFile(path.join(first.HOME, '.npmrc'), `registry=${nextRegistry}/\n`); + await run(shim(first, 'vp'), ['--help'], first, project); + for (const name of ['node', 'pn', 'tsc']) + await run(shim(first, name), ['--version'], first, project); + await run(shim(first, 'pnx'), ['--help'], first, project); + await run(path.join(first.VP_HOME, 'fallback-bin/npm'), ['--version'], first, project); + await fs.access(path.join(packageDir(first, replacementVersion), '.vp-deps-complete')); + await run(publicVp, ['build'], first, project); + assert.equal(await fs.readFile(path.join(first.VP_HOME, 'config.json'), 'utf8'), settings); + assert.ok( + (await run(publicVp, ['upgrade'], first, project, 1)).includes(`brew upgrade ${formulaName}`), + ); + assert.ok( + (await run(publicVp, ['upgrade', '--check'], first, project)).includes( + `brew outdated ${formulaName}`, + ), + ); + await run(brew, ['test', formulaName], brewEnv); + if (process.platform === 'linux' && process.arch === 'x64') { + const file = path.join(tap, 'HomebrewFormula/vp.rb'); + const fixture = await fs.readFile(file, 'utf8'); + try { + // Audit the production URLs and release gate, not the local archive override. + await fs.writeFile(file, formula); + await run(brew, ['style', formulaName], brewEnv); + await run(brew, ['audit', '--strict', '--formula', formulaName], brewEnv); + } finally { + await fs.writeFile(file, fixture); + } + } + console.log( + 'Homebrew upgrade and old-keg cleanup preserve both shim directories and saved preferences.', + ); + + await run(publicVp, ['implode', '--yes'], first, project); + await assert.rejects(fs.access(first.VP_HOME)); + await fs.access(publicVp); + await run(brew, ['uninstall', formulaName], brewEnv); + await assert.rejects(fs.access(publicVp)); + await fs.access(path.join(packageDir(second), '.vp-deps-complete')); + console.log( + 'Implode removes one user installation; brew uninstall leaves the other user data intact.', + ); +} finally { + for (const server of servers) { + server.close(); + server.closeAllConnections(); + } + for (const child of children) child.kill(); + // Keep failures available for inspection; the prefix is always task-local. + console.log(`Homebrew e2e files: ${root}`); +} diff --git a/.github/scripts/update-homebrew-formula.mjs b/.github/scripts/update-homebrew-formula.mjs new file mode 100644 index 0000000000..10ddc4d082 --- /dev/null +++ b/.github/scripts/update-homebrew-formula.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { waitForNpmPackagesFromEnv } from './wait-for-npm-packages.ts'; + +export const targets = [ + ['macos', 'arm', 'aarch64-apple-darwin', 'darwin-arm64'], + ['macos', 'intel', 'x86_64-apple-darwin', 'darwin-x64'], + ['linux', 'arm', 'aarch64-unknown-linux-gnu', 'linux-arm64-gnu'], + ['linux', 'intel', 'x86_64-unknown-linux-gnu', 'linux-x64-gnu'], +]; +const repository = 'voidzero-dev/vite-plus'; +const assetBlock = / # BEGIN RELEASE ASSETS\n[\s\S]*? # END RELEASE ASSETS/; +const launchGate = / # The updater removes this gate[^\n]*\n disable! [^\n]*\n\n/; + +function stableVersion(version) { + assert.match( + version, + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/, + 'Expected a stable release version', + ); + return version.split('.').map(Number); +} + +export function releaseAssets(version, checksums) { + stableVersion(version); + return targets.map(([os, arch, target]) => { + const name = `vp-${target}.tar.gz`; + const entries = checksums + .trim() + .split('\n') + .filter((line) => line.trim().split(/\s+/)[1] === name); + assert.equal(entries.length, 1, `Expected one checksum for ${name}`); + const sha256 = entries[0].split(/\s+/)[0]; + assert.match(sha256, /^[a-f0-9]{64}$/, `Invalid checksum for ${name}`); + return { + os, + arch, + name, + sha256, + url: `https://github.com/${repository}/releases/download/v${version}/${name}`, + }; + }); +} + +export function updateFormula(source, version, assets) { + const next = stableVersion(version); + const currentVersion = source.match(/\/releases\/download\/v(\d+\.\d+\.\d+)\//)?.[1]; + const current = stableVersion(currentVersion ?? ''); + const difference = next.map((part, i) => part - current[i]).find((part) => part !== 0) ?? 0; + assert.ok(difference >= 0, 'Refusing to downgrade the formula'); + assert.equal(assets.length, targets.length, 'Expected all four release targets'); + assert.equal( + source.match(new RegExp(assetBlock.source, 'g'))?.length, + 1, + 'Missing release asset block', + ); + const blocks = ['macos', 'linux'] + .map( + (os) => + ` on_${os} do\n${assets + .filter((asset) => asset.os === os) + .map((asset) => { + assert.ok(targets.some(([targetOs, arch]) => targetOs === os && arch === asset.arch)); + assert.match(asset.sha256, /^[a-f0-9]{64}$/); + // URLs are supplied by the release validator, or a local e2e fixture. + assert.ok(!/["\\\n\r#]/.test(asset.url), 'Unsafe formula URL'); + return ` on_${asset.arch} do\n url "${asset.url}"\n sha256 "${asset.sha256}"\n end`; + }) + .join('\n')}\n end`, + ) + .join('\n\n'); + let updated = source + .replace(assetBlock, ` # BEGIN RELEASE ASSETS\n${blocks}\n # END RELEASE ASSETS`) + .replace(launchGate, ''); + if (difference > 0) updated = updated.replace(/^ revision \d+\n/m, ''); + return updated; +} + +export async function verifyRelease(version, release, fetchImpl = fetch) { + stableVersion(version); + assert.equal(release.tag_name, `v${version}`); + assert.ok(!release.draft && !release.prerelease, 'Release must be published and stable'); + const get = async (url) => { + const response = await fetchImpl(url, { signal: AbortSignal.timeout(120_000) }); + assert.ok(response.ok, `Download failed: ${url} (HTTP ${response.status})`); + return response; + }; + // Old tags do not contain the authenticated bootstrap or the bare Homebrew runtime. + // Check the release tag, never main, before enabling installation. + await get( + `https://raw.githubusercontent.com/${repository}/v${version}/crates/vp_pm_cli/src/package_manager/bootstrap.rs`, + ); + await get(`https://raw.githubusercontent.com/${repository}/v${version}/HomebrewFormula/vp.rb`); + const checksumUrl = `https://github.com/${repository}/releases/download/v${version}/vp-checksums.txt`; + const assets = releaseAssets(version, await (await get(checksumUrl)).text()); + for (const asset of assets) { + assert.ok( + release.assets.some( + (published) => + published.name === asset.name && published.browser_download_url === asset.url, + ), + `Missing release asset: ${asset.name}`, + ); + const data = await (await get(asset.url)).arrayBuffer(); + assert.equal( + createHash('sha256').update(Buffer.from(data)).digest('hex'), + asset.sha256, + `Checksum mismatch: ${asset.name}`, + ); + } + return assets; +} + +async function main() { + const [version] = process.argv.slice(2); + stableVersion(version ?? ''); + const release = JSON.parse( + execFileSync('gh', ['api', `repos/${repository}/releases/tags/v${version}`], { + encoding: 'utf8', + }), + ); + const assets = await verifyRelease(version, release); + const packages = [ + 'vite-plus', + '@voidzero-dev/vite-plus-core', + '@voidzero-dev/vite-plus-prompts', + ...targets.map(([, , , platform]) => `@voidzero-dev/vite-plus-${platform}`), + ]; + await waitForNpmPackagesFromEnv(packages.map((name) => ({ name, version }))); + const file = new URL('../../HomebrewFormula/vp.rb', import.meta.url); + const source = await readFile(file, 'utf8'); + const result = updateFormula(source, version, assets); + const guide = new URL('../../docs/guide/homebrew.md', import.meta.url); + const guideSource = await readFile(guide, 'utf8'); + const guideResult = guideSource + .replace(/::: warning Availability\n[\s\S]*?:::\n\n/, '') + .replace('When the tap becomes available, run:', 'Install the tap:'); + if (result !== source) await writeFile(file, result); + if (guideResult !== guideSource) await writeFile(guide, guideResult); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) + await main(); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0014131339..9ba91706f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -336,6 +336,58 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release edit "v${VERSION}" --draft=false --repo "${{ github.repository }}" + update-homebrew: + name: Update Homebrew formula + runs-on: ubuntu-latest + needs: [check, Release] + if: >- + needs.check.outputs.version_changed == 'true' && + !contains(needs.check.outputs.version, '-') + concurrency: + group: update-homebrew-formula + cancel-in-progress: false + permissions: + contents: read + env: + VERSION: ${{ needs.check.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24.18.0' + package-manager-cache: false + + - name: Check publication and update formula + env: + GH_TOKEN: ${{ github.token }} + run: node .github/scripts/update-homebrew-formula.mjs "$VERSION" + + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: app-token + with: + client-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token }} + add-paths: | + HomebrewFormula/vp.rb + docs/guide/homebrew.md + commit-message: 'chore: update Homebrew vp to ${{ env.VERSION }}' + title: 'chore: update Homebrew vp to ${{ env.VERSION }}' + branch: release/homebrew-v${{ env.VERSION }} + base: main + sign-commits: true + body: | + Update the official Homebrew tap to `vp@${{ env.VERSION }}`. + + The formula downloads the native CLI from GitHub Releases. On first use, `vp` installs each user's matching dependencies. + # Deploy viteplus.dev from the release commit after the npm packages and the # GitHub release are published, so the site and the install scripts always # match the released vp. Skipped for prereleases: production docs must keep diff --git a/.github/workflows/test-homebrew.yml b/.github/workflows/test-homebrew.yml new file mode 100644 index 0000000000..44de71183e --- /dev/null +++ b/.github/workflows/test-homebrew.yml @@ -0,0 +1,150 @@ +name: Test Homebrew installation + +permissions: {} + +on: + workflow_dispatch: + pull_request: + types: [opened, synchronize, reopened, labeled] + push: + branches: [main] + paths: + - 'HomebrewFormula/**' + - '.github/scripts/*homebrew*' + - '.github/scripts/__tests__/homebrew-formula.mjs' + - '.github/workflows/test-homebrew.yml' + - '.github/workflows/release.yml' + - 'crates/vp_setup/**' + - 'crates/vp_global_cli/src/self_setup*' + - 'crates/vp_global_cli/src/self_setup/**' + - 'crates/vp_global_cli/src/homebrew.rs' + - 'crates/vp_pm_cli/src/package_manager/bootstrap.rs' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + changes: + if: >- + github.event.action != 'labeled' || github.event.label.name == 'test: install-e2e' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + test: >- + ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'test: install-e2e') || steps.paths.outputs.packaging == 'true' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24.18.0' + package-manager-cache: false + + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: paths + with: + filters: | + packaging: + - 'HomebrewFormula/**' + - '.github/scripts/*homebrew*' + - '.github/scripts/__tests__/homebrew-formula.mjs' + - '.github/workflows/test-homebrew.yml' + - '.github/workflows/release.yml' + - 'crates/vp_setup/**' + - 'crates/vp_global_cli/src/self_setup*' + - 'crates/vp_global_cli/src/self_setup/**' + - 'crates/vp_global_cli/src/homebrew.rs' + - 'crates/vp_pm_cli/src/package_manager/bootstrap.rs' + + - name: Check formula publication rules + run: node --test .github/scripts/__tests__/homebrew-formula.mjs + + install: + name: Homebrew (${{ matrix.target }}) + needs: changes + if: needs.changes.outputs.test == 'true' + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + permissions: + contents: read + packages: read + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: aarch64-apple-darwin + - os: macos-15-intel + target: x86_64-apple-darwin + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + env: + TARGET: ${{ matrix.target }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/clone + + - uses: oxc-project/setup-rust@68c3199c5339f965e6e163924c3c450773eba42b # main (pending v1.0.17) + with: + save-cache: ${{ github.ref_name == 'main' }} + cache-key: homebrew-${{ matrix.target }} + + - uses: oxc-project/setup-node@1f1a5b4450c8905bd1830c3a908d22b960c4559a # main (Node before pnpm; unreleased) + + - uses: ./.github/actions/build-upstream + with: + target: ${{ matrix.target }} + + - name: Pack the current checkout + run: | + mkdir -p "$RUNNER_TEMP/homebrew-packages" + node packages/tools/src/local-npm-registry.ts --pack-to "$RUNNER_TEMP/homebrew-packages" + cp "${CARGO_TARGET_DIR:-target}/$TARGET/release/vp" "$RUNNER_TEMP/vp-under-test" + + - name: Build a second CLI version for upgrade testing + id: replacement + run: | + node --input-type=module <<'JS' + import fs from 'node:fs'; + const file = 'crates/vp_global_cli/Cargo.toml'; + const source = fs.readFileSync(file, 'utf8'); + const version = source.match(/^version = "([^"]+)"/m)[1]; + const [major, minor, patch] = version.split('.').map(Number); + const next = `${major}.${minor}.${patch + 1}`; + fs.writeFileSync(file, source.replace(/^version = "[^"]+"/m, `version = "${next}"`)); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${next}\n`); + JS + cargo build --release -p vp_global_cli --target "$TARGET" + cp "${CARGO_TARGET_DIR:-target}/$TARGET/release/vp" "$RUNNER_TEMP/vp-replacement" + git restore -- Cargo.lock crates/vp_global_cli/Cargo.toml + + - name: Check authentication, retries, and external ownership + env: + TEST_VP_BINARY: ${{ runner.temp }}/vp-under-test + run: | + fixture="$GITHUB_WORKSPACE/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs" + cd "$(mktemp -d)" + node "$fixture" + + - name: Install and upgrade through a disposable Homebrew tap + env: + REPLACEMENT_VERSION: ${{ steps.replacement.outputs.version }} + run: | + node .github/scripts/test-homebrew.mjs \ + --binary "$RUNNER_TEMP/vp-under-test" \ + --packages-dir "$RUNNER_TEMP/homebrew-packages" \ + --replacement-binary "$RUNNER_TEMP/vp-replacement" \ + --replacement-version "$REPLACEMENT_VERSION" diff --git a/HomebrewFormula/vp.rb b/HomebrewFormula/vp.rb new file mode 100644 index 0000000000..b0676f8cd5 --- /dev/null +++ b/HomebrewFormula/vp.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class Vp < Formula + desc "Unified toolchain for the web" + homepage "https://viteplus.dev/" + license "MIT" + + # The updater removes this gate after the first compatible release is published. + disable! date: "2026-09-22", because: "requires a release with per-user Homebrew setup" + + # BEGIN RELEASE ASSETS + on_macos do + on_arm do + url "https://github.com/voidzero-dev/vite-plus/releases/download/v0.3.3/vp-aarch64-apple-darwin.tar.gz" + sha256 "856713a318f1cc3b33c9dface7ca2ef066f18ede8150f37b7f549060d4709834" + end + on_intel do + url "https://github.com/voidzero-dev/vite-plus/releases/download/v0.3.3/vp-x86_64-apple-darwin.tar.gz" + sha256 "75095001972a6974ea1943d25e28dc47bd7a7f77f8387055e70d86a499a778c2" + end + end + + on_linux do + on_arm do + url "https://github.com/voidzero-dev/vite-plus/releases/download/v0.3.3/vp-aarch64-unknown-linux-gnu.tar.gz" + sha256 "b3cafb9545623859910ce06a905da2d3d577fe4c15558795b8ddd09e1fbe9ed1" + end + on_intel do + url "https://github.com/voidzero-dev/vite-plus/releases/download/v0.3.3/vp-x86_64-unknown-linux-gnu.tar.gz" + sha256 "0acf34e3182d20f245b58fb0a4c611329b040f64d9d2b76b91bbd907bc9fc4f8" + end + end + # END RELEASE ASSETS + + conflicts_with "vite-plus", because: "both install vp, vpr, and vpx" + + def install + bin.install "vp" + bin.install_symlink "vp" => "vpr" + bin.install_symlink "vp" => "vpx" + end + + def caveats + <<~EOS + Run vp to install your dependencies and configure your shell. + First use needs access to the Node.js download service and your npm registry. + npm and pnpm read ~/.npmrc or NPM_CONFIG_USERCONFIG, including registry credentials. + EOS + end + + test do + ENV["VP_HOME"] = testpath/"vp-home" + ENV["VP_NODE_MANAGER"] = "yes" + ENV["VP_PM_MANAGER"] = "yes" + ENV["VP_SELF_SETUP_NO_MODIFY_PATH"] = "1" + ENV.prepend_path "PATH", testpath/"vp-home/bin" + ENV.append_path "PATH", testpath/"vp-home/fallback-bin" + assert_match version.to_s, shell_output("#{bin}/vp --version") + assert_match "Homebrew", shell_output("#{bin}/vp env doctor node") + assert_path_exists testpath/"vp-home/cli-packages/#{version}" + refute_path_exists testpath/"vp-home/current" + end +end diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs new file mode 100644 index 0000000000..25490d5909 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs @@ -0,0 +1,313 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import { createServer } from 'node:http'; +import path from 'node:path'; + +const root = path.resolve('homebrew-test'); +const source = + process.env.TEST_VP_BINARY ?? fs.realpathSync(path.join(process.env.VP_HOME, 'bin/vp')); +const node = fs.realpathSync(process.execPath); +const npmRoot = path.resolve(path.dirname(node), '../lib/node_modules/npm'); +assert.ok( + fs.existsSync(path.join(npmRoot, 'bin/npm-cli.js')), + 'fixture needs Node with bundled npm', +); +fs.mkdirSync(root, { recursive: true }); +const originalEnv = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !/^(VP_|XDG_|npm_|NPM_|PNPM_)/.test(key)), +); +const token = 'synthetic-homebrew-token'; +const pin = '10.33.0'; +fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ name: 'homebrew-fixture', packageManager: `pnpm@${pin}` }), +); + +async function run(binary, args, env, cwd = root, status = 0) { + const child = spawn(binary, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = '', + stderr = ''; + child.stdout.setEncoding('utf8').on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.setEncoding('utf8').on('data', (chunk) => { + stderr += chunk; + }); + const [code, signal] = await once(child, 'close'); + assert.equal(signal, null, stderr); + assert.equal(code, status, stdout + stderr); + assert.ok(!stdout.includes(token) && !stderr.includes(token), 'credentials leaked'); + return { stdout, stderr }; +} + +const packageRoot = path.join(root, 'fake-pnpm'); +fs.mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); +fs.writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ + name: 'pnpm', + version: pin, + bin: { pnpm: 'bin/pnpm.cjs', pnpx: 'bin/pnpx.cjs' }, + }), +); +const pnpmCode = ` + const fs = require('node:fs'); + if (process.argv.includes('install')) { + const version = JSON.parse(fs.readFileSync('package.json')).dependencies['vite-plus']; + fetch(process.env.TEST_REGISTRY + '/installed').then(response => { + if (!response.ok) { console.error(process.env.NPM_TOKEN); process.exit(17); } + fs.mkdirSync('node_modules/vite-plus/dist', { recursive: true }); + fs.writeFileSync('node_modules/vite-plus/package.json', JSON.stringify({ name: 'vite-plus', version })); + fs.writeFileSync('node_modules/vite-plus/dist/bin.js', "console.log('homebrew user CLI')"); + }); + } else { console.log('pinned pnpm tool'); } +`; +for (const name of ['pnpm', 'pnpx']) + fs.writeFileSync(path.join(packageRoot, 'bin', `${name}.cjs`), pnpmCode); +const packEnv = { ...originalEnv, HOME: root, npm_config_cache: path.join(root, 'pack-cache') }; +const packed = spawnSync( + node, + [ + path.join(npmRoot, 'bin/npm-cli.js'), + 'pack', + packageRoot, + '--ignore-scripts', + '--json', + '--workspaces=false', + ], + { cwd: root, env: packEnv, encoding: 'utf8' }, +); +assert.equal(packed.status, 0, packed.stderr); +const archive = fs.readFileSync(path.join(root, JSON.parse(packed.stdout)[0].filename)); +const integrity = 'sha512-' + createHash('sha512').update(archive).digest('base64'); +let downloads = 0, + installs = 0, + rejectInstall = false, + corrupt = false; +let authorization = `Bearer ${token}`; +const server = createServer((request, response) => { + if (request.url === '/index.json') + return response.end(JSON.stringify([{ version: 'v99.0.0', lts: 'Fixture' }])); + if (request.url === '/installed') { + installs++; + return response.writeHead(rejectInstall ? 500 : 200).end(); + } + if (request.headers.authorization !== authorization) + return response.writeHead(401).end('Unauthorized'); + if (request.url === '/registry/pnpm') { + return response.end( + JSON.stringify({ + name: 'pnpm', + 'dist-tags': { latest: pin }, + versions: { + [pin]: { + name: 'pnpm', + version: pin, + dist: { tarball: `${url}/registry/custom-pnpm.tgz`, integrity }, + }, + }, + }), + ); + } + if (request.url === '/registry/custom-pnpm.tgz') { + downloads++; + return response.end(corrupt ? Buffer.concat([archive, Buffer.from('corrupt')]) : archive); + } + response.writeHead(404).end(); +}); +server.listen(0, '127.0.0.1'); +await once(server, 'listening'); +const url = `http://127.0.0.1:${server.address().port}`; +const publicVp = path.join(root, 'brew/bin/vp'); + +function keg(version) { + const prefix = path.join(root, 'brew/Cellar/vp', version); + fs.mkdirSync(path.join(prefix, 'bin'), { recursive: true }); + fs.copyFileSync(source, path.join(prefix, 'bin/vp')); + fs.chmodSync(path.join(prefix, 'bin/vp'), 0o555); + fs.writeFileSync( + path.join(prefix, 'INSTALL_RECEIPT.json'), + JSON.stringify({ + homebrew_version: '7.0.2', + source: { tap: 'voidzero-dev/vite-plus', path: '/tap/HomebrewFormula/vp.rb' }, + }), + ); + fs.chmodSync(path.join(prefix, 'bin'), 0o555); + fs.chmodSync(prefix, 0o555); + return prefix; +} + +function environment(name, auth = true) { + const home = path.join(root, name); + const nodeRoot = path.join(home, 'js_runtime/node/99.0.0'); + fs.mkdirSync(path.join(nodeRoot, 'bin'), { recursive: true }); + fs.mkdirSync(path.join(nodeRoot, 'lib/node_modules'), { recursive: true }); + fs.symlinkSync(node, path.join(nodeRoot, 'bin/node')); + fs.symlinkSync(npmRoot, path.join(nodeRoot, 'lib/node_modules/npm')); + fs.writeFileSync( + path.join(home, '.npmrc'), + `registry=${url}/registry/\n${auth ? `//127.0.0.1:${server.address().port}/registry/:_authToken=\${NPM_TOKEN}\n` : ''}`, + ); + return { + ...originalEnv, + HOME: home, + VP_HOME: home, + VP_SELF_SETUP_NO_MODIFY_PATH: '1', + VP_NODE_DIST_MIRROR: url, + VP_NODE_MANAGER: 'yes', + VP_PM_MANAGER: 'no', + VP_PNPM_MANAGER: 'yes', + NPM_TOKEN: token, + TEST_REGISTRY: url, + npm_config_fetch_retries: '0', + npm_config_cache: path.join(home, 'npm-cache'), + PATH: [ + path.join(home, 'bin'), + path.join(root, 'brew/bin'), + path.dirname(node), + '/usr/bin', + '/bin', + path.join(home, 'fallback-bin'), + ].join(path.delimiter), + }; +} + +function userPackage(env) { + const versions = path.join(env.VP_HOME, 'cli-packages'); + const version = fs.readdirSync(versions)[0]; + return path.join(versions, version, fs.readdirSync(path.join(versions, version))[0]); +} + +function unlock(prefix) { + fs.chmodSync(prefix, 0o755); + fs.chmodSync(path.join(prefix, 'bin'), 0o755); +} + +try { + const old = keg('old'); + fs.mkdirSync(path.dirname(publicVp), { recursive: true }); + fs.symlinkSync(path.join(old, 'bin/vp'), publicVp); + const env = environment('user'); + rejectInstall = true; + await run(publicVp, ['--help'], env, root, 1); + const settingsFile = path.join(env.VP_HOME, 'config.json'); + const preferences = fs.readFileSync(settingsFile, 'utf8'); + assert.ok(!fs.existsSync(path.join(userPackage(env), '.vp-deps-complete'))); + const failureLog = fs.readFileSync(path.join(userPackage(env), '../upgrade.log'), 'utf8'); + assert.ok(failureLog.includes('exit code 17') && !failureLog.includes(token)); + for (const name of ['VP_NODE_MANAGER', 'VP_PM_MANAGER', 'VP_PNPM_MANAGER']) delete env[name]; + rejectInstall = false; + const retry = await Promise.all([run(publicVp, ['--help'], env), run(publicVp, ['--help'], env)]); + assert.equal(installs, 2, 'retry must install once across concurrent launches'); + assert.equal(downloads, 1, 'the retry must reuse bootstrapped pnpm'); + assert.ok(retry.every((result) => !result.stderr.includes('manage Node'))); + assert.equal(fs.readFileSync(settingsFile, 'utf8'), preferences); + assert.ok(!fs.existsSync(path.join(old, 'bin/.vp-setup-complete'))); + assert.ok(!fs.existsSync(path.join(env.VP_HOME, 'current'))); + assert.equal(fs.readlinkSync(path.join(env.VP_HOME, 'bin/vp')), publicVp); + assert.equal(fs.readlinkSync(path.join(env.VP_HOME, 'fallback-bin/npm')), publicVp); + console.log( + 'Authenticated first use retries once, preserves preferences, and serializes concurrent setup.', + ); + + const cli = await run(path.join(env.VP_HOME, 'bin/vp'), ['sync-versions', '--json'], env); + assert.equal(cli.stdout.trim(), 'homebrew user CLI'); + const replacement = keg('new'); + fs.unlinkSync(publicVp); + fs.symlinkSync(path.join(replacement, 'bin/vp'), publicVp); + unlock(old); + fs.rmSync(old, { recursive: true }); + await run(path.join(env.VP_HOME, 'bin/vp'), ['--help'], env); + await run(path.join(env.VP_HOME, 'bin/node'), ['--version'], env); + for (const shim of ['pn', 'pnx']) { + const output = await run(path.join(env.VP_HOME, 'bin', shim), ['--version'], env); + assert.equal(output.stdout.trim(), 'pinned pnpm tool'); + } + await run(path.join(env.VP_HOME, 'fallback-bin/npm'), ['--version'], env); + assert.equal(fs.readFileSync(settingsFile, 'utf8'), preferences); + assert.equal(installs, 2); + console.log('CLI, Node, npm, pn, and pnx shims survive Cellar replacement and removal.'); + + const upgrade = await run(publicVp, ['upgrade'], env, root, 1); + assert.ok(upgrade.stderr.includes('brew upgrade voidzero-dev/vite-plus/vp')); + const outdated = await run(publicVp, ['upgrade', '--check'], env); + assert.ok( + (outdated.stdout + outdated.stderr).includes('brew outdated voidzero-dev/vite-plus/vp'), + ); + const doctor = await run(publicVp, ['env', 'doctor', 'node'], env); + assert.ok(doctor.stdout.includes('CLI dependencies')); + const secondUser = environment('second-user'); + // Model migration from a script install with a managed global package. + const previous = path.join(secondUser.VP_HOME, 'current/bin/vp'); + fs.mkdirSync(path.dirname(previous), { recursive: true }); + fs.copyFileSync(source, previous); + fs.mkdirSync(path.join(secondUser.VP_HOME, 'bins')); + const metadata = path.join(secondUser.VP_HOME, 'bins/global-tool.json'); + fs.writeFileSync( + metadata, + JSON.stringify({ + name: 'global-tool', + package: 'global-tool', + version: '1.0.0', + nodeVersion: '99.0.0', + source: 'vp', + }), + ); + fs.mkdirSync(path.join(secondUser.VP_HOME, 'bin')); + const globalShim = path.join(secondUser.VP_HOME, 'bin/global-tool'); + fs.symlinkSync(previous, globalShim); + await run(publicVp, ['--help'], secondUser); + assert.equal(installs, 3); + assert.notEqual(userPackage(env), userPackage(secondUser)); + assert.equal(fs.readlinkSync(globalShim), publicVp); + assert.ok(fs.existsSync(metadata) && fs.existsSync(previous)); + const removed = await run(publicVp, ['implode', '--yes'], env); + assert.ok((removed.stdout + removed.stderr).includes('brew uninstall voidzero-dev/vite-plus/vp')); + assert.ok(!fs.existsSync(env.VP_HOME)); + assert.ok(fs.existsSync(publicVp)); + assert.ok(fs.existsSync(userPackage(secondUser))); + console.log( + 'Ownership commands use the tap name; migration preserves global packages and refreshes their shims.', + ); + console.log('User installations and cleanup stay independent.'); + + const override = environment('registry-override'); + const config = path.join(override.HOME, '.npmrc'); + fs.writeFileSync( + config, + fs.readFileSync(config, 'utf8').replace(`${url}/registry/`, 'http://127.0.0.1:1/unavailable/'), + ); + override.NPM_CONFIG_REGISTRY = `${url}/registry/`; + await run(publicVp, ['--help'], override); + + const userconfig = environment('relative-userconfig'); + const userconfigFile = path.join(userconfig.HOME, 'enterprise.npmrc'); + fs.renameSync(path.join(userconfig.HOME, '.npmrc'), userconfigFile); + userconfig.NPM_CONFIG_USERCONFIG = path.relative(root, userconfigFile); + await run(publicVp, ['--help'], userconfig); + + const basic = environment('basic-auth'); + const encoded = Buffer.from('fixture:synthetic-password').toString('base64'); + fs.writeFileSync( + path.join(basic.HOME, '.npmrc'), + `registry=${url}/registry/\n//127.0.0.1:${server.address().port}/registry/:_auth=${encoded}\n`, + ); + authorization = `Basic ${encoded}`; + await run(publicVp, ['--help'], basic); + authorization = `Bearer ${token}`; + console.log( + 'Registry overrides, relative user configuration, and basic authentication work with bundled npm.', + ); + + await run(publicVp, ['--help'], environment('unauthenticated', false), root, 1); + corrupt = true; + await run(publicVp, ['--help'], environment('corrupt'), root, 1); + console.log('Missing authentication and corrupted pnpm tarballs stop setup.'); + unlock(replacement); +} finally { + server.close(); + server.closeAllConnections(); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml index 1a99fcb201..9b3b2934a7 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml @@ -1,3 +1,9 @@ +[[case]] +name = "command_self_setup_homebrew_user_dependencies" +vp = "global" +skip-platforms = ["windows"] +steps = [{ argv = ["node", "homebrew.mjs"], timeout = 120000 }] + [[case]] name = "command_self_setup_external_bundled" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md index fc840de00b..1f9f927e54 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md @@ -23,7 +23,7 @@ **Exit code:** 1 ``` -error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. +error: Upgrade error: Homebrew manages this installation. Run `brew upgrade fengmk2/core/vite-plus` to update it. ``` ## `VP_HOME=${workspace}/home ./home/bin/vp upgrade --force` @@ -31,7 +31,7 @@ error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite **Exit code:** 1 ``` -error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. +error: Upgrade error: Homebrew manages this installation. Run `brew upgrade fengmk2/core/vite-plus` to update it. ``` ## `VP_HOME=${workspace}/home ./home/bin/vp upgrade 0.0.1 --registry http://127.0.0.1:9` @@ -39,7 +39,7 @@ error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite **Exit code:** 1 ``` -error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. +error: Upgrade error: Homebrew manages this installation. Run `brew upgrade fengmk2/core/vite-plus` to update it. ``` ## `VP_HOME=${workspace}/home ./home/bin/vp upgrade --rollback` @@ -47,7 +47,7 @@ error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite **Exit code:** 1 ``` -error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. +error: Upgrade error: Homebrew manages this installation. Run `brew upgrade fengmk2/core/vite-plus` to update it. ``` ## `VP_HOME=${workspace}/home ./home/bin/vp upgrade --silent` @@ -55,13 +55,13 @@ error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite **Exit code:** 1 ``` -error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. +error: Upgrade error: Homebrew manages this installation. Run `brew upgrade fengmk2/core/vite-plus` to update it. ``` ## `VP_HOME=${workspace}/home ./home/bin/vp upgrade --check` ``` -info: Homebrew manages this installation. Run `brew outdated vite-plus` to check for updates. +info: Homebrew manages this installation. Run `brew outdated fengmk2/core/vite-plus` to check for updates. ``` ## `VP_HOME=${workspace}/home ./home/bin/vp upgrade --check --silent` @@ -113,7 +113,7 @@ Cleanup removes user data and leaves the Homebrew package installed ✓ Removed /home ✓ Vite+ removed its managed files and shell entries from your system. -note: The Homebrew package remains installed. Run `brew uninstall vite-plus` to remove it. +note: The Homebrew package remains installed. Run `brew uninstall fengmk2/core/vite-plus` to remove it. note: To run `vp` again, restart your terminal or run `hash -r` in Bash. The remaining Homebrew package will start setup again. note: Restart your terminal to apply shell changes. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md new file mode 100644 index 0000000000..14b370ff7b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md @@ -0,0 +1,12 @@ +# command_self_setup_homebrew_user_dependencies + +## `node homebrew.mjs` + +``` +Authenticated first use retries once, preserves preferences, and serializes concurrent setup. +CLI, Node, npm, pn, and pnx shims survive Cellar replacement and removal. +Ownership commands use the tap name; migration preserves global packages and refreshes their shims. +User installations and cleanup stay independent. +Registry overrides, relative user configuration, and basic authentication work with bundled npm. +Missing authentication and corrupted pnpm tarballs stop setup. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs index 99d83729f8..bcde7ed9ed 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs @@ -33,6 +33,22 @@ const nodeBin = join(home, 'js_runtime/node/99.0.0/bin'); for (const directory of [nodeBin, user, external]) mkdirSync(directory, { recursive: true }); // Use the runner's real Node for the mock pnpm program, without a network download. symlinkSync(process.execPath, join(nodeBin, 'node')); +// Model Node's bundled npm while retaining deterministic download checkpoints. +const npmBin = join(nodeBin, '../lib/node_modules/npm/bin'); +mkdirSync(npmBin, { recursive: true }); +writeFileSync( + join(npmBin, 'npm-cli.js'), + ` + const fs = require('node:fs'); + fetch(process.env.npm_config_registry + '/pnpm/-/pnpm-10.33.0.tgz') + .then(response => response.arrayBuffer()) + .then(data => { + const filename = 'pnpm-10.33.0.tgz'; + fs.writeFileSync(filename, Buffer.from(data)); + console.log(JSON.stringify([{ name: 'pnpm', version: '10.33.0', filename }])); + }); +`, +); copyFileSync(join(process.env.VP_HOME, 'bin/vp'), join(external, 'vp')); const lockfile = "lockfileVersion: '9.0'\n"; writeFileSync(join(external, 'vp-install-lock.yaml'), lockfile); @@ -191,9 +207,9 @@ try { if (failure) { assert.equal(stdout, ''); const log = readFileSync(join(home, 'upgrade.log'), 'utf8'); - assert.ok(log.includes('pnpm captured stdout')); - assert.ok(log.includes('pnpm captured stderr')); - console.log('Failure log preserves pnpm stdout and stderr.'); + assert.ok(log.includes('exit code 17')); + assert.ok(!log.includes('pnpm captured')); + console.log('Failure log records the exit code without registry output.'); } else if (upgradeHandoff) { assert.ok(stdout.includes('Usage:')); console.log('The first command runs after self-setup completes.'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md index 24eb896c32..d80a6d2f1c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md @@ -7,7 +7,7 @@ ``` Before installation: preserve this output. info: installing vite-plus@... -⠿ Downloading pnpm ... B ( B/s) +⠿ Preparing Node.js and pnpm... ``` **← write-key:** `enter` @@ -36,6 +36,6 @@ info: installing vite-plus@... Before installation: preserve this output. info: installing vite-plus@... error: Setup error: Failed to install production dependencies (exit code: 17). See log for details: /home/upgrade.log -Failure log preserves pnpm stdout and stderr. +Failure log records the exit code without registry output. After installation. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md index ee36674471..e593b21856 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md @@ -7,7 +7,7 @@ ``` Before installation: preserve this output. info: installing vite-plus@... -⠿ Downloading pnpm ... B ( B/s) +⠿ Preparing Node.js and pnpm... ``` **← write-key:** `enter` diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index e19d10f40a..f7e2a3dd08 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -100,6 +100,9 @@ pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result Result { + if let Some(homebrew) = crate::homebrew::current() + && same_file::is_same_file(current_exe, homebrew.binary.as_path()).unwrap_or(false) + && let Some(public) = homebrew.public_binary() + { + return Ok(public.as_path().to_path_buf()); + } let current_exe_canon = tokio::fs::canonicalize(current_exe).await.ok(); let current_vp = crate::commands::global::install::package_shim_target(); if let Some(binary) = ¤t_exe_canon @@ -639,6 +644,29 @@ async fn refresh_package_shims( Ok(()) } +/// Move existing managed package shims to the active installation during migration. +#[cfg(unix)] +async fn refresh_package_shims( + current_exe: &std::path::Path, + bin_dir: &vt_path::AbsolutePath, +) -> Result<(), Error> { + let target = resolve_unix_vp_shim_target(current_exe).await?; + for name in BinConfig::find_all_vp_source().await? { + if crate::commands::global::install::is_protected_shim(&name, true) { + continue; + } + let shim = bin_dir.join(&name); + // Keep foreign files, including npm's direct package links. + if crate::commands::global::install::is_vp_shim_target(&shim) + && tokio::fs::read_link(&shim).await? != target.as_path() + { + tokio::fs::remove_file(&shim).await?; + tokio::fs::symlink(&target, &shim).await?; + } + } + Ok(()) +} + /// Write `.shim` next to a trampoline copy, with the resolved layout. #[cfg(windows)] fn write_shim_pointer_beside(exe_path: &std::path::Path) { diff --git a/crates/vp_global_cli/src/commands/implode.rs b/crates/vp_global_cli/src/commands/implode.rs index 71796fa508..768d229beb 100644 --- a/crates/vp_global_cli/src/commands/implode.rs +++ b/crates/vp_global_cli/src/commands/implode.rs @@ -149,9 +149,11 @@ pub fn execute(yes: bool) -> Result { } fn print_homebrew_uninstall_notice() { - output::note( - "The Homebrew package remains installed. Run `brew uninstall vite-plus` to remove it.", - ); + let formula = + crate::homebrew::current().map_or("vite-plus", |homebrew| homebrew.formula.as_str()); + output::note(&format!( + "The Homebrew package remains installed. Run `brew uninstall {formula}` to remove it.", + )); output::note( "To run `vp` again, restart your terminal or run `hash -r` in Bash. The remaining Homebrew package will start setup again.", ); diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index f2b2540090..21ced3bad7 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -41,17 +41,22 @@ pub async fn execute(options: UpgradeOptions) -> Result { return Ok(ExitStatus::default()); } - if crate::homebrew::owns_current_exe() { + if let Some(homebrew) = crate::homebrew::current() { if options.check && !options.rollback { if !options.silent { - output::info( - "Homebrew manages this installation. Run `brew outdated vite-plus` to check for updates.", - ); + output::info(&format!( + "Homebrew manages this installation. Run `brew outdated {}` to check for updates.", + homebrew.formula, + )); } return Ok(ExitStatus::default()); } return Err(Error::Upgrade( - "Homebrew manages this installation. Run `brew upgrade vite-plus` to update it.".into(), + format!( + "Homebrew manages this installation. Run `brew upgrade {}` to update it.", + homebrew.formula + ) + .into(), )); } diff --git a/crates/vp_global_cli/src/homebrew.rs b/crates/vp_global_cli/src/homebrew.rs index efbac33138..a0f0711e88 100644 --- a/crates/vp_global_cli/src/homebrew.rs +++ b/crates/vp_global_cli/src/homebrew.rs @@ -2,31 +2,149 @@ use std::{path::Path, sync::OnceLock}; +use vt_path::AbsolutePathBuf; + +pub(crate) struct Installation { + pub(crate) binary: AbsolutePathBuf, + pub(crate) formula: String, +} + +pub(crate) fn current() -> Option<&'static Installation> { + static HOMEBREW: OnceLock> = OnceLock::new(); + HOMEBREW + .get_or_init(|| std::env::current_exe().ok().and_then(|binary| installation(&binary))) + .as_ref() +} + pub(crate) fn owns_current_exe() -> bool { - static HOMEBREW: OnceLock = OnceLock::new(); - *HOMEBREW.get_or_init(|| std::env::current_exe().is_ok_and(|binary| owns_binary(&binary))) + current().is_some() } -fn owns_binary(binary: &Path) -> bool { +fn installation(binary: &Path) -> Option { // Resolve both Homebrew's public entrypoint and Vite+'s generated shims. - let Ok(binary) = std::fs::canonicalize(binary) else { return false }; - let Some(bin) = binary.parent().filter(|bin| bin.file_name().is_some_and(|name| name == "bin")) - else { - return false; - }; - let Some(prefix) = bin.parent() else { return false }; - let Ok(data) = std::fs::read(prefix.join("INSTALL_RECEIPT.json")) else { return false }; - let Ok(receipt) = serde_json::from_slice::(&data) else { return false }; - receipt - .get("homebrew_version") + let binary = std::fs::canonicalize(binary).ok()?; + let bin = binary.parent().filter(|bin| bin.file_name().is_some_and(|name| name == "bin"))?; + let prefix = bin.parent()?; + let data = std::fs::read(prefix.join("INSTALL_RECEIPT.json")).ok()?; + let receipt = serde_json::from_slice::(&data).ok()?; + if receipt.get("homebrew_version").and_then(serde_json::Value::as_str).is_none_or(str::is_empty) + { + return None; + } + let source = receipt.get("source"); + let name = source + .and_then(|source| source.get("path")) + .and_then(serde_json::Value::as_str) + .and_then(|path| Path::new(path).file_stem()) + .and_then(|name| name.to_str()) + .filter(|name| valid_component(name)) + .or_else(|| { + let rack = prefix.parent()?; + (rack.parent()?.file_name()? == "Cellar") + .then(|| rack.file_name()?.to_str().filter(|name| valid_component(name)))? + }) + .unwrap_or("vite-plus"); + let tap = source + .and_then(|source| source.get("tap")) .and_then(serde_json::Value::as_str) - .is_some_and(|version| !version.is_empty()) + .filter(|tap| { + let parts = tap.split('/').collect::>(); + parts.len() == 2 && parts.iter().all(|part| valid_component(part)) + }); + let formula = match tap { + Some("homebrew/core") | None => name.to_string(), + Some(tap) => format!("{tap}/{name}"), + }; + Some(Installation { binary: AbsolutePathBuf::new(binary)?, formula }) +} + +fn valid_component(value: &str) -> bool { + value.as_bytes().first().is_some_and(u8::is_ascii_alphanumeric) + && value.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"-_.@+".contains(&byte)) +} + +impl Installation { + /// Homebrew's standard Cellar layout exposes a stable link outside the keg. + pub(crate) fn public_binary(&self) -> Option { + let cellar = self.binary.parent()?.parent()?.parent()?.parent()?; + if cellar.as_path().file_name()? != "Cellar" { + return None; + } + let public = cellar.parent()?.join("bin/vp"); + (std::fs::canonicalize(&public).ok()? == self.binary.as_path()).then_some(public) + } +} + +/// A Homebrew package can ship the JS payload in its prefix (core), or let each +/// user install matching dependencies on first launch (the official tap). +pub(crate) fn user_package_dir() -> Result, crate::error::Error> { + let Some(homebrew) = current() else { return Ok(None) }; + if crate::self_setup::has_bundled_package(homebrew.binary.as_path()) { + return Ok(None); + } + let platform = vp_setup::platform::detect_platform_suffix()?; + Ok(Some(vp_shared::EnvConfig::get().dirs.cli_package(env!("CARGO_PKG_VERSION"), &platform))) } #[cfg(test)] mod tests { use super::*; + fn owns_binary(binary: &Path) -> bool { + installation(binary).is_some() + } + + #[test] + fn identifies_core_and_tap_formulae_from_receipts() { + let root = tempfile::tempdir().unwrap(); + let prefix = root.path().join("Cellar/vp/0.3.4"); + let binary = prefix.join("bin/vp"); + std::fs::create_dir_all(binary.parent().unwrap()).unwrap(); + std::fs::write(&binary, b"vp").unwrap(); + for (tap, path, expected) in [ + ("voidzero-dev/vite-plus", "/tap/HomebrewFormula/vp.rb", "voidzero-dev/vite-plus/vp"), + ("homebrew/core", "/tap/Formula/v/vite-plus.rb", "vite-plus"), + ("fengmk2/core", "/tap/Formula/v/vite-plus.rb", "fengmk2/core/vite-plus"), + ("bad/tap/extra", "/tap/HomebrewFormula/vp.rb", "vp"), + ("bad;command/tap", "/tap/HomebrewFormula/vp.rb", "vp"), + ] { + std::fs::write( + prefix.join("INSTALL_RECEIPT.json"), + serde_json::to_vec(&serde_json::json!({ + "homebrew_version": "7.0.2", "source": { "tap": tap, "path": path } + })) + .unwrap(), + ) + .unwrap(); + let install = installation(&binary).unwrap(); + assert_eq!(install.formula, expected); + assert_eq!(install.binary.as_path(), std::fs::canonicalize(&binary).unwrap()); + } + } + + #[cfg(unix)] + #[test] + fn public_entrypoint_survives_cellar_replacement_without_path() { + let root = tempfile::tempdir().unwrap(); + for version in ["old", "new"] { + let prefix = root.path().join("Cellar/vp").join(version); + std::fs::create_dir_all(prefix.join("bin")).unwrap(); + std::fs::write(prefix.join("bin/vp"), version).unwrap(); + std::fs::write(prefix.join("INSTALL_RECEIPT.json"), br#"{"homebrew_version":"7.0.2"}"#) + .unwrap(); + } + let public = root.path().join("bin/vp"); + std::fs::create_dir_all(public.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink("../Cellar/vp/old/bin/vp", &public).unwrap(); + let old = installation(&public).unwrap(); + let stable = old.public_binary().unwrap(); + std::fs::remove_file(&public).unwrap(); + std::os::unix::fs::symlink("../Cellar/vp/new/bin/vp", &public).unwrap(); + std::fs::remove_dir_all(root.path().join("Cellar/vp/old")).unwrap(); + assert_eq!(std::fs::read_to_string(&stable).unwrap(), "new"); + assert_eq!(installation(&public).unwrap().public_binary().unwrap(), stable); + } + #[test] fn detects_receipt_under_custom_prefix() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index eaf698667b..470f23c38d 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -94,6 +94,10 @@ impl JsExecutor { return AbsolutePathBuf::new(dir.into()).ok_or(Error::JsScriptsDirNotFound); } + if let Some(package) = crate::homebrew::user_package_dir()? { + return Ok(package.join("node_modules/vite-plus/dist")); + } + // 3. Auto-detect from binary location // JS scripts are at ../node_modules/vite-plus/dist relative to the binary directory // e.g., //bin/vp -> //node_modules/vite-plus/dist/ diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index b220b5d88b..0713566161 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -32,7 +32,7 @@ pub(crate) async fn maybe_run() -> Result, Error> { let binary = std::fs::canonicalize(binary)?; let bin = binary.parent().ok_or(Error::CliBinaryNotFound)?; // Once the binary path is resolved, normal commands only check marker existence. - if bin.join(SELF_SETUP_MARKER).try_exists()? { + if !crate::homebrew::owns_current_exe() && bin.join(SELF_SETUP_MARKER).try_exists()? { if let Some(shell) = shell.as_deref() { print_shell_result(shell); return Ok(Some(ExitCode::SUCCESS)); @@ -42,18 +42,37 @@ pub(crate) async fn maybe_run() -> Result, Error> { vp_shared::validate_vp_dir_env().map_err(|error| Error::Other(error.to_string().into()))?; let data = normalize_target(&EnvConfig::get().dirs.data)?; - let external = if dunce::simplified(&binary).starts_with(dunce::simplified(data.as_path())) { + let external = if !crate::homebrew::owns_current_exe() + && dunce::simplified(&binary).starts_with(dunce::simplified(data.as_path())) + { None } else { Some(external::SetupState::new(&binary, local_install_version().as_deref())?) }; let bundled = external.is_some() && has_bundled_package(&binary); + let user_package = crate::homebrew::user_package_dir()?; + let retains_binary = bundled || user_package.is_some(); + let reuse = + shell.is_none() && std::env::var_os(env_vars::VP_SELF_SETUP_REPLACE_EXISTING).is_none(); // Installers explicitly request setup, including same-version reinstalls. - if shell.is_none() - && std::env::var_os(env_vars::VP_SELF_SETUP_REPLACE_EXISTING).is_none() + if reuse + && let Some(state) = &external + && let Some(installed_binary) = + completed_external(state, retains_binary, user_package.as_deref()).await? + { + if installed_binary.as_path() == binary { + return Ok(None); + } + return execute_installed(&installed_binary, false); + } + let lock = if let Some(state) = &external { Some(state.lock().await?) } else { None }; + // Another invocation may have finished while this one waited for setup. + if reuse && let Some(state) = &external - && let Some(installed_binary) = state.completed_binary(bundled).await? + && let Some(installed_binary) = + completed_external(state, retains_binary, user_package.as_deref()).await? { + drop(lock); if installed_binary.as_path() == binary { return Ok(None); } @@ -61,10 +80,11 @@ pub(crate) async fn maybe_run() -> Result, Error> { } // Setup diagnostics must not pollute the original command's machine-readable stdout. output::route_user_output_to_stderr(); - let installed_binary = run(&binary, bundled).await?; + let installed_binary = run(&binary, bundled, user_package.as_deref()).await?; if let Some(state) = external { state.save(&installed_binary).await?; } + drop(lock); output::success("Vite+ setup complete."); if let Some(shell) = shell.as_deref() { print_shell_result(shell); @@ -73,6 +93,17 @@ pub(crate) async fn maybe_run() -> Result, Error> { execute_installed(&installed_binary, true) } +async fn completed_external( + state: &external::SetupState, + retains_binary: bool, + user_package: Option<&AbsolutePath>, +) -> Result, Error> { + if user_package.is_some_and(|package| !external::package_complete(package)) { + return Ok(None); + } + state.completed_binary(retains_binary).await +} + fn execute_installed( binary: &AbsolutePath, just_installed: bool, @@ -110,7 +141,7 @@ fn execute_installed( } } -fn has_bundled_package(binary: &Path) -> bool { +pub(crate) fn has_bundled_package(binary: &Path) -> bool { let Some(prefix) = binary.parent().and_then(Path::parent) else { return false }; let package = prefix.join("node_modules/vite-plus"); // Unix shims can target an external binary; Windows trampolines need the managed layout. @@ -151,13 +182,18 @@ fn print_shell_result(shell: &str) { } /// Setup Vite+ for the first run -async fn run(source: &Path, bundled: bool) -> Result { +async fn run( + source: &Path, + bundled: bool, + user_package: Option<&AbsolutePath>, +) -> Result { let env = EnvConfig::get(); let dirs = &env.dirs; let active_binary = dirs.data.join("current").join("bin").join(VP_BINARY_NAME); let in_place = same_file::is_same_file(source, active_binary.as_path()).unwrap_or(false); // External package managers own their payload. Only set up the user's config and shims. - let deploy = !in_place && !bundled; + let externally_owned = bundled || user_package.is_some(); + let deploy = !in_place && !externally_owned; #[cfg(windows)] if !in_place && ["vp.exe", "vpx.exe", "vpr.exe"] @@ -178,7 +214,7 @@ async fn run(source: &Path, bundled: bool) -> Result { let node_override = shim_mode(env.node_manager); // A package upgrade can change the executable path or expire its receipt. // Preferences belong to the user, not to that particular binary. - let configured = bundled && config::get_config_path()?.as_path().is_file(); + let configured = externally_owned && config::get_config_path()?.as_path().is_file(); // A supplied Node choice skips the combined prompt; upgrades preserve all saved choices. let default_mode = if in_place || configured || node_override.is_some() { None @@ -186,8 +222,8 @@ async fn run(source: &Path, bundled: bool) -> Result { management_default()? }; let version = env!("CARGO_PKG_VERSION"); - let registry = std::env::var(env_vars::NPM_CONFIG_REGISTRY_UPPER) - .or_else(|_| std::env::var(env_vars::NPM_CONFIG_REGISTRY)) + let registry = std::env::var(env_vars::NPM_CONFIG_REGISTRY) + .or_else(|_| std::env::var(env_vars::NPM_CONFIG_REGISTRY_UPPER)) .ok() .filter(|value| !value.is_empty()) .or_else(|| { @@ -212,7 +248,12 @@ async fn run(source: &Path, bundled: bool) -> Result { // 1. Prepare the payload before activating it. Upgrade has already done this in the in-place case. let previous_version = install::read_current_version(&dirs.data).await; - let version_dir = if deploy { + let version_dir = if let Some(package) = user_package { + // Save choices before network work so a failed install does not ask again. + save_management_preferences(node_override, default_mode).await?; + tokio::fs::create_dir_all(package).await?; + package.to_absolute_path_buf() + } else if deploy { let name = install::target_install_dir_name(install_version, previous_version.as_deref(), true); dirs.data.join(name) @@ -222,7 +263,7 @@ async fn run(source: &Path, bundled: bool) -> Result { ) .ok_or(Error::CliBinaryNotFound)? }; - let binary = if bundled { + let binary = if externally_owned { AbsolutePathBuf::new(source.to_path_buf()).ok_or(Error::CliBinaryNotFound)? } else { version_dir.join("bin").join(VP_BINARY_NAME) @@ -247,7 +288,10 @@ async fn run(source: &Path, bundled: bool) -> Result { false }; let needs_deps = needs_dependency_install - || !version_dir.join("node_modules/vite-plus/package.json").as_path().is_file(); + || user_package.map_or_else( + || !version_dir.join("node_modules/vite-plus/package.json").as_path().is_file(), + |package| !external::package_complete(package), + ); // Standalone retries also return the selected runtime when dependencies already exist. let node_version = if !skip_deps && (deploy || needs_deps) { runtime::resolve().await? } else { None }; @@ -264,6 +308,14 @@ async fn run(source: &Path, bundled: bool) -> Result { } install::install_production_deps(&version_dir, registry, true, preferred).await?; } + if user_package.is_some() { + if !external::package_matches_binary(&version_dir) { + return Err(Error::Other( + "Vite+ dependencies do not match this CLI version".into(), + )); + } + tokio::fs::write(version_dir.join(external::PACKAGE_MARKER), b"").await?; + } } if let Some(version) = node_version { runtime::write_resolution(&version).await?; @@ -306,22 +358,9 @@ async fn run(source: &Path, bundled: bool) -> Result { )); } } - let mut settings = config::load_config().await?; - if let Some(mode) = node_override.or(default_mode) { - settings.node_shim_mode = mode; + if user_package.is_none() { + save_management_preferences(node_override, default_mode).await?; } - let pm_mode = shim_mode(env.pm_manager).or(default_mode); - for (family, preference) in [ - (PackageManagerType::Npm, env.npm_manager), - (PackageManagerType::Pnpm, env.pnpm_manager), - (PackageManagerType::Yarn, env.yarn_manager), - (PackageManagerType::Bun, env.bun_manager), - ] { - if let Some(mode) = shim_mode(preference).or(pm_mode) { - settings.set_package_manager_shim_mode(family, mode); - } - } - config::save_config(&settings).await?; } // Existing Windows installs also need the fallback directory in their persistent PATH. @@ -363,12 +402,36 @@ async fn run(source: &Path, bundled: bool) -> Result { } // A failure above leaves the marker absent so a later launch can retry. - if !bundled { + if !externally_owned { tokio::fs::write(version_dir.join("bin").join(SELF_SETUP_MARKER), b"").await?; } Ok(binary) } +async fn save_management_preferences( + node_override: Option, + default_mode: Option, +) -> Result<(), Error> { + let env = EnvConfig::get(); + let mut settings = config::load_config().await?; + if let Some(mode) = node_override.or(default_mode) { + settings.node_shim_mode = mode; + } + let pm_mode = shim_mode(env.pm_manager).or(default_mode); + for (family, preference) in [ + (PackageManagerType::Npm, env.npm_manager), + (PackageManagerType::Pnpm, env.pnpm_manager), + (PackageManagerType::Yarn, env.yarn_manager), + (PackageManagerType::Bun, env.bun_manager), + ] { + if let Some(mode) = shim_mode(preference).or(pm_mode) { + settings.set_package_manager_shim_mode(family, mode); + } + } + config::save_config(&settings).await?; + Ok(()) +} + fn interactive() -> bool { std::env::var_os("CI").is_none() && vp_shared::is_stderr_terminal() } diff --git a/crates/vp_global_cli/src/self_setup/external.rs b/crates/vp_global_cli/src/self_setup/external.rs index 0f1c2ff641..dc4c12d4d9 100644 --- a/crates/vp_global_cli/src/self_setup/external.rs +++ b/crates/vp_global_cli/src/self_setup/external.rs @@ -33,6 +33,26 @@ pub(super) struct SetupState { } impl SetupState { + /// Serialize external setup before reading or changing preferences and shims. + pub(super) async fn lock(&self) -> Result { + let parent = self.path.parent().expect("setup state has a parent"); + tokio::fs::create_dir_all(parent).await?; + let path = parent.join("setup.lock"); + tokio::task::spawn_blocking(move || { + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + file.lock()?; + Ok::<_, std::io::Error>(file) + }) + .await + .map_err(|error| Error::Other(error.to_string().into()))? + .map_err(Into::into) + } + pub(super) fn new(binary: &Path, local_version: Option<&str>) -> Result { let metadata = std::fs::metadata(binary)?; let mut hash = rustc_hash::FxHasher::default(); @@ -54,7 +74,7 @@ impl SetupState { pub(super) async fn completed_binary( &self, - bundled: bool, + retains_binary: bool, ) -> Result, Error> { let data = match tokio::fs::read(&self.path).await { Ok(data) => data, @@ -66,7 +86,7 @@ impl SetupState { if receipt.source != self.source || !receipt.binary.is_file() { return Ok(None); } - let complete = if bundled { + let complete = if retains_binary { receipt.binary == self.source.path } else { receipt.binary != self.source.path @@ -79,17 +99,58 @@ impl SetupState { } pub(super) async fn save(self, binary: &AbsolutePath) -> Result<(), Error> { - tokio::fs::create_dir_all(self.path.parent().ok_or(Error::CliBinaryNotFound)?).await?; + let parent = self.path.parent().ok_or(Error::CliBinaryNotFound)?; + tokio::fs::create_dir_all(parent).await?; let receipt = Receipt { source: self.source, binary: binary.as_path().to_path_buf() }; - tokio::fs::write(&self.path, serde_json::to_vec(&receipt)?).await?; + let temporary = tempfile::NamedTempFile::new_in(parent)?; + tokio::fs::write(temporary.path(), serde_json::to_vec(&receipt)?).await?; + temporary.persist(&self.path).map_err(|error| error.error)?; Ok(()) } } +pub(super) const PACKAGE_MARKER: &str = ".vp-deps-complete"; + +pub(super) fn package_complete(package: &AbsolutePath) -> bool { + package.join(PACKAGE_MARKER).as_path().is_file() && package_matches_binary(package) +} + +pub(super) fn package_matches_binary(package: &AbsolutePath) -> bool { + package.join("node_modules/vite-plus/dist/bin.js").as_path().is_file() + && std::fs::read(package.join("node_modules/vite-plus/package.json")) + .ok() + .and_then(|data| serde_json::from_slice::(&data).ok()) + .is_some_and(|manifest| { + manifest.get("version").and_then(serde_json::Value::as_str) + == Some(env!("CARGO_PKG_VERSION")) + }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn dependency_completion_requires_the_matching_package_and_entrypoint() { + EnvConfig::scoped(|env| { + let directory = env.dirs.cli_package(env!("CARGO_PKG_VERSION"), "test-platform"); + let package = directory.join("node_modules/vite-plus"); + std::fs::create_dir_all(package.join("dist")).unwrap(); + let manifest = package.join("package.json"); + std::fs::write( + &manifest, + serde_json::json!({"version": env!("CARGO_PKG_VERSION")}).to_string(), + ) + .unwrap(); + std::fs::write(package.join("dist/bin.js"), b"// CLI").unwrap(); + assert!(!package_complete(&directory)); + std::fs::write(directory.join(PACKAGE_MARKER), b"").unwrap(); + assert!(package_complete(&directory)); + std::fs::write(&manifest, br#"{"version":"0.0.0"}"#).unwrap(); + assert!(!package_complete(&directory)); + }); + } + #[tokio::test] async fn bundled_receipt_is_per_user_and_invalidated_when_source_changes() { EnvConfig::scoped_async(|env| async move { diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index 12ad5fa286..c76297b198 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -27,7 +27,7 @@ pub use error::Error; pub use lifecycle_env::LifecycleEnvContext; pub use package_manager::{ EnvironmentPackageManagerResolution, PackageManager, PackageManagerBuilder, - PackageManagerResolution, PackageManagerSource, PackageManagerType, download_package_manager, + PackageManagerResolution, PackageManagerSource, PackageManagerType, bootstrap_pnpm, configure_npm_command, download_package_manager, download_package_manager_for_cwd, ensure_package_manager_bin, fetch_package_manager_versions, fetch_package_manager_versions_for_cwd, get_package_manager_type_and_version, is_package_manager_dist_tag, package_manager_bin_path, package_manager_install_dir, diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 4880a9c83d..d49af6bcab 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -1,6 +1,8 @@ #![allow(clippy::disallowed_macros)] #![cfg_attr(test, allow(clippy::print_stdout))] +mod bootstrap; + use std::{ collections::HashMap, env, fmt, @@ -10,6 +12,7 @@ use std::{ time::Duration, }; +pub use bootstrap::{bootstrap_pnpm, configure_npm_command}; use semver::Version; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; diff --git a/crates/vp_pm_cli/src/package_manager/bootstrap.rs b/crates/vp_pm_cli/src/package_manager/bootstrap.rs new file mode 100644 index 0000000000..d79d5e5ba6 --- /dev/null +++ b/crates/vp_pm_cli/src/package_manager/bootstrap.rs @@ -0,0 +1,217 @@ +//! Fetch the setup pnpm with Node's npm so registry authentication follows npmrc. + +use std::path::Component; + +use serde::Deserialize; +use tokio::process::Command; +use vp_error::Error; +use vp_shared::{EnvConfig, env_vars}; +use vt_path::{AbsolutePath, AbsolutePathBuf}; +use vt_str::Str; + +use super::{ + PackageManagerType, create_shim_files, find_extracted_package_dir, + is_package_manager_install_complete, open_lock_file, remove_dir_all_force, +}; + +/// Keep npm and pnpm on the same explicit registry and user config when their +/// working directory differs from the caller's. Credentials stay in npm's +/// normal configuration and inherited environment, never in command arguments. +pub fn configure_npm_command(command: &mut Command, registry: Option<&str>) -> Result<(), Error> { + if let Some(registry) = registry { + command.env(env_vars::NPM_CONFIG_REGISTRY, registry); + command.env(env_vars::NPM_CONFIG_REGISTRY_UPPER, registry); + } + if let Some(path) = std::env::var_os("npm_config_userconfig") + .or_else(|| std::env::var_os("NPM_CONFIG_USERCONFIG")) + .filter(|path| !path.is_empty()) + { + let path = vt_path::current_dir()?.join(path); + command.env("npm_config_userconfig", path.as_path()); + command.env("NPM_CONFIG_USERCONFIG", path.as_path()); + } + Ok(()) +} + +#[derive(Deserialize)] +struct PackedPackage { + name: Str, + version: Str, + filename: Str, +} + +fn packed_filename(output: &[u8], version: &str) -> Result { + let packages: Vec = serde_json::from_slice(output)?; + let [package] = packages.as_slice() else { + return Err(Error::InvalidArgument("npm pack must return one pnpm archive".into())); + }; + let mut components = std::path::Path::new(package.filename.as_str()).components(); + if package.name != "pnpm" + || package.version != version + || !matches!(components.next(), Some(Component::Normal(_))) + || components.next().is_some() + || !package.filename.ends_with(".tgz") + { + return Err(Error::InvalidArgument("npm pack returned an unexpected pnpm archive".into())); + } + Ok(package.filename.clone()) +} + +/// Bootstrap a pinned JavaScript pnpm package with the npm included in a managed +/// Node distribution. Reuse the normal cache and lock so later vp commands see +/// the same completed package-manager installation. +pub async fn bootstrap_pnpm( + node: &AbsolutePath, + version: &str, + registry: Option<&str>, +) -> Result { + let parsed = semver::Version::parse(version) + .map_err(|_| Error::InvalidArgument("pnpm bootstrap requires an exact version".into()))?; + if parsed.major >= 12 { + return Err(Error::InvalidArgument( + "pnpm bootstrap requires a JavaScript pnpm release".into(), + )); + } + let target = EnvConfig::get().dirs.data.join("package_manager/pnpm").join(version); + let install = target.join("pnpm"); + let complete = || { + Ok::<_, Error>( + is_package_manager_install_complete(&install, "pnpm")? + && install.join("bin/pnpm.cjs").as_path().is_file(), + ) + }; + if complete()? { + return Ok(install); + } + + let parent = target.parent().expect("pnpm cache has a parent"); + tokio::fs::create_dir_all(parent).await?; + let lock_path = parent.join(vt_str::format!("{version}.lock")); + // File::lock can block; keep it off the async executor during concurrent setup. + let _lock = tokio::task::spawn_blocking(move || { + let file = open_lock_file(lock_path.as_path())?; + file.lock()?; + Ok::<_, std::io::Error>(file) + }) + .await??; + if complete()? { + return Ok(install); + } + + let temporary = tempfile::tempdir_in(parent)?; + let work = AbsolutePathBuf::new(temporary.path().to_path_buf()).expect("absolute tempdir"); + let node_bin = + node.parent().ok_or_else(|| Error::CannotFindBinaryPath("Node directory".into()))?; + let npm = node_bin.join(if cfg!(windows) { + "node_modules/npm/bin/npm-cli.js" + } else { + "../lib/node_modules/npm/bin/npm-cli.js" + }); + if !npm.as_path().is_file() { + return Err(Error::CannotFindBinaryPath( + "npm bundled with the managed Node.js runtime".into(), + )); + } + // Establish a package root even if the user chose a data directory under a workspace. + tokio::fs::write(work.join("package.json"), br#"{"name":"vp-bootstrap","private":true}"#) + .await?; + let mut command = Command::new(node.as_path()); + command + .arg(npm.as_path()) + .args([ + "pack", + &vt_str::format!("pnpm@{version}"), + "--ignore-scripts", + "--json", + "--workspaces=false", + "--logs-max=0", + ]) + .current_dir(&work); + configure_npm_command(&mut command, registry)?; + let output = command.output().await?; + if !output.status.success() { + // Registry error bodies can contain credentials. Do not log subprocess output. + return Err(Error::InvalidArgument(vt_str::format!( + "Failed to download pnpm {version} (exit code {}). Check your npm registry and authentication configuration.", + vp_shared::exit_code_from_status(output.status) + ))); + } + let archive = work.join(packed_filename(&output.stdout, version)?); + let extracted = work.join("extracted"); + let destination = extracted.clone(); + tokio::task::spawn_blocking(move || crate::request::extract_tgz(&archive, &destination)) + .await??; + let package = find_extracted_package_dir(extracted.as_path())?; + let staged = work.join("installation"); + tokio::fs::create_dir(&staged).await?; + tokio::fs::rename(package, staged.join("pnpm")).await?; + let staged_install = staged.join("pnpm"); + create_shim_files(PackageManagerType::Pnpm, staged_install.join("bin")).await?; + if !is_package_manager_install_complete(&staged_install, "pnpm")? + || !staged_install.join("bin/pnpm.cjs").as_path().is_file() + { + return Err(Error::CannotFindBinaryPath("pnpm bootstrap archive has no CLI".into())); + } + remove_dir_all_force(&target).await?; + tokio::fs::rename(staged, &target).await?; + Ok(install) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_wrong_versions_and_archive_paths() { + let output = |name: &str, version: &str, filename: &str| { + serde_json::to_vec( + &serde_json::json!([{ "name": name, "version": version, "filename": filename }]), + ) + .unwrap() + }; + assert_eq!( + packed_filename(&output("pnpm", "10.33.0", "pnpm-10.33.0.tgz"), "10.33.0").unwrap(), + "pnpm-10.33.0.tgz" + ); + for (name, version, filename) in [ + ("npm", "10.33.0", "pnpm.tgz"), + ("pnpm", "10.32.0", "pnpm.tgz"), + ("pnpm", "10.33.0", "../pnpm.tgz"), + ("pnpm", "10.33.0", "/pnpm.tgz"), + ("pnpm", "10.33.0", "dir/pnpm.tgz"), + ("pnpm", "10.33.0", ""), + ] { + assert!(packed_filename(&output(name, version, filename), "10.33.0").is_err()); + } + assert!(packed_filename(b"[]", "10.33.0").is_err()); + assert!(packed_filename(b"invalid JSON", "10.33.0").is_err()); + } + + #[test] + fn keeps_explicit_userconfig_relative_to_the_caller() { + EnvConfig::with_vars( + [ + ("npm_config_userconfig", None), + ("NPM_CONFIG_USERCONFIG", Some("config/enterprise.npmrc")), + ], + |_| { + let mut command = Command::new("node"); + configure_npm_command(&mut command, Some("https://registry.example.test/")) + .unwrap(); + let expected = vt_path::current_dir().unwrap().join("config/enterprise.npmrc"); + for (name, value) in command.as_std().get_envs() { + match name.to_str().unwrap() { + "npm_config_userconfig" | "NPM_CONFIG_USERCONFIG" => { + assert_eq!(value.unwrap(), expected.as_path()) + } + "npm_config_registry" | "NPM_CONFIG_REGISTRY" => { + assert_eq!(value.unwrap(), "https://registry.example.test/") + } + other => panic!("unexpected environment setting {other}"), + } + } + assert_eq!(command.as_std().get_args().len(), 0); + }, + ); + } +} diff --git a/crates/vp_pm_cli/src/request.rs b/crates/vp_pm_cli/src/request.rs index 0d690fe8e4..5501f23b87 100644 --- a/crates/vp_pm_cli/src/request.rs +++ b/crates/vp_pm_cli/src/request.rs @@ -389,7 +389,10 @@ impl HttpClient { } } -fn extract_tgz(tgz_file: impl AsRef, target_dir: impl AsRef) -> Result<(), Error> { +pub(crate) fn extract_tgz( + tgz_file: impl AsRef, + target_dir: impl AsRef, +) -> Result<(), Error> { let tgz_file = tgz_file.as_ref(); let target_dir = target_dir.as_ref(); tracing::debug!("Extract tgz: {:?} to {:?}", tgz_file, target_dir); diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 65626b0eea..3e82104808 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -14,7 +14,7 @@ use std::{ use flate2::read::GzDecoder; use tar::Archive; use vp_js_runtime::{JsRuntimeType, NodeProvider, download_runtime}; -use vp_pm_cli::{PackageManagerType, download_package_manager}; +use vp_pm_cli::{PackageManagerType, configure_npm_command, download_package_manager}; use vp_shared::EnvConfig; use vt_path::{AbsolutePath, AbsolutePathBuf}; @@ -171,7 +171,7 @@ fn format_install_failure_message(exit_code: i32, log_path: Option<&AbsolutePath format!("Failed to install production dependencies (exit code: {exit_code}){log_msg}") } -/// Write stdout and stderr from a failed install to `upgrade.log`. +/// Record the exit code without registry output, which can contain credentials. /// /// The log is written to the **parent** of `version_dir` (i.e. `/upgrade.log`) /// so it survives the cleanup that removes `version_dir` on failure. @@ -179,15 +179,14 @@ fn format_install_failure_message(exit_code: i32, log_path: Option<&AbsolutePath /// Returns the log file path on success, or `None` if writing failed. pub async fn write_upgrade_log( version_dir: &AbsolutePath, - stdout: &[u8], - stderr: &[u8], + exit_code: i32, ) -> Option { // Write to parent dir so the log survives version_dir cleanup on failure let parent = version_dir.as_path().parent()?; let log_path = AbsolutePathBuf::new(parent.join("upgrade.log"))?; - let stdout_str = String::from_utf8_lossy(stdout); - let stderr_str = String::from_utf8_lossy(stderr); - let content = format!("=== stdout ===\n{stdout_str}\n=== stderr ===\n{stderr_str}"); + let content = format!( + "pnpm install failed with exit code {exit_code}.\nCheck npm registry access and authentication in your user configuration.\nRegistry output is not saved because it can contain credentials.\n" + ); match tokio::fs::write(&log_path, &content).await { Ok(()) => Some(log_path), Err(e) => { @@ -199,8 +198,8 @@ pub async fn write_upgrade_log( /// Install production dependencies with a compatible managed Node.js and pinned pnpm. /// -/// Spawns: `node /bin/pnpm.cjs install --ignore-workspace [--registry ]` -/// with `CI=true`. On failure, writes stdout+stderr to the parent directory's `upgrade.log`. +/// Spawns: `node /bin/pnpm.cjs install --ignore-workspace` +/// with `CI=true`. On failure, records the exit code in the parent directory's `upgrade.log`. pub async fn install_production_deps( version_dir: &AbsolutePath, registry: Option<&str>, @@ -232,7 +231,8 @@ pub async fn install_production_deps( .await?; if !output.status.success() { - let log_path = write_upgrade_log(version_dir, &output.stdout, &output.stderr).await; + let exit_code = vp_shared::exit_code_from_status(output.status); + let log_path = write_upgrade_log(version_dir, exit_code).await; return Err(Error::Setup( format_install_failure_message( vp_shared::exit_code_from_status(output.status), @@ -363,9 +363,8 @@ async fn run_pnpm_install( .env("NPM_CONFIG_PEERS_SUFFIX_MAX_LENGTH", "1000"); } - if let Some(registry_url) = registry { - cmd.env(vp_shared::env_vars::NPM_CONFIG_REGISTRY, registry_url); - } + configure_npm_command(&mut cmd, registry) + .map_err(|error| Error::Setup(error.to_string().into()))?; let output = cmd.output().await?; Ok(output) @@ -997,10 +996,7 @@ mod tests { let version_dir = AbsolutePathBuf::new(temp.path().join("0.1.15").to_path_buf()).unwrap(); tokio::fs::create_dir(&version_dir).await.unwrap(); - let stdout = b"some stdout output"; - let stderr = b"error: something went wrong"; - - let result = write_upgrade_log(&version_dir, stdout, stderr).await; + let result = write_upgrade_log(&version_dir, 17).await; assert!(result.is_some(), "write_upgrade_log should return log path"); let log_path = result.unwrap(); @@ -1013,30 +1009,14 @@ mod tests { assert!(log_path.as_path().exists(), "upgrade.log should exist"); let content = tokio::fs::read_to_string(&log_path).await.unwrap(); - assert!(content.contains("=== stdout ==="), "log should have stdout section"); - assert!(content.contains("some stdout output"), "log should contain stdout"); - assert!(content.contains("=== stderr ==="), "log should have stderr section"); - assert!(content.contains("error: something went wrong"), "log should contain stderr"); + assert!(content.contains("exit code 17")); + assert!(content.contains("Registry output is not saved")); // Log should survive version_dir removal tokio::fs::remove_dir_all(&version_dir).await.unwrap(); assert!(log_path.as_path().exists(), "upgrade.log should survive version_dir cleanup"); } - #[tokio::test] - async fn test_write_upgrade_log_handles_empty_output() { - let temp = tempfile::tempdir().unwrap(); - let version_dir = AbsolutePathBuf::new(temp.path().join("0.1.15").to_path_buf()).unwrap(); - tokio::fs::create_dir(&version_dir).await.unwrap(); - - let result = write_upgrade_log(&version_dir, b"", b"").await; - assert!(result.is_some()); - - let content = tokio::fs::read_to_string(result.unwrap()).await.unwrap(); - assert!(content.contains("=== stdout ===")); - assert!(content.contains("=== stderr ===")); - } - #[tokio::test] async fn test_write_release_age_overrides_creates_npmrc() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/vp_shared/src/dirs.rs b/crates/vp_shared/src/dirs.rs index 08c8acbb4a..ddb3ef538b 100644 --- a/crates/vp_shared/src/dirs.rs +++ b/crates/vp_shared/src/dirs.rs @@ -124,6 +124,12 @@ impl VpDirs { self.data.join("fallback-bin") } + /// JavaScript dependencies for a CLI whose executable is owned by Homebrew. + #[must_use] + pub fn cli_package(&self, version: &str, platform: &str) -> AbsolutePathBuf { + self.data.join("cli-packages").join(version).join(platform) + } + /// Return the resolution mode that selected these roots. #[must_use] pub const fn layout(&self) -> VpDirsLayout { diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index 820fc36c14..d47b7c818d 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -426,7 +426,7 @@ For example, `VP_VERSION=1.0.0 vp-setup.exe --version 2.0.0` installs version 2. ### Homebrew -For a Homebrew installation, the first `vp` command sets up your shell, shims, and environment-management preferences. It reuses Homebrew's binary and bundled JavaScript. Setup stores its completion state in your user directories and does not need write access to the Homebrew prefix. +For a Homebrew installation, the first `vp` command sets up your shell, shims, and environment-management preferences. It reuses Homebrew's binary and any bundled JavaScript. A native-only formula installs matching JavaScript dependencies in your data directory. Setup stores its completion state in your user directories and does not need write access to the Homebrew prefix. Later commands reuse that setup while the installed binary remains unchanged. Generated shims follow Homebrew's public `vp` entrypoint when Homebrew replaces a version. Setup preserves your saved management preferences during this replacement. @@ -442,6 +442,8 @@ Use Homebrew to [upgrade](/guide/upgrade#homebrew) or [remove](/guide/implode#ho `vp env doctor` identifies the Homebrew installation and its binary path. It checks the `vp` command and the user shim directory on `PATH` separately. If only the shim directory is missing, follow its shell setup instructions to enable the shims. +See the [official tap guide](/guide/homebrew) for availability, registry configuration, and migration instructions. + ## Use It Without a Local Package The global installation is enough for runtime, package-manager, and task-runner workflows: diff --git a/docs/guide/homebrew.md b/docs/guide/homebrew.md new file mode 100644 index 0000000000..8635be5cff --- /dev/null +++ b/docs/guide/homebrew.md @@ -0,0 +1,78 @@ +# Homebrew tap + +::: warning Availability +The official tap is not available yet. Its formula stays disabled until the first release with per-user Homebrew setup is published. +Use the [script installer](/guide/global-cli) or Homebrew core in the meantime. +::: + +The official tap installs the native `vp` CLI from GitHub Releases on macOS and glibc Linux, for ARM64 and x64. +Homebrew needs no Node.js, pnpm, or npm registry access to install it. + +When the tap becomes available, run: + +```bash +brew tap voidzero-dev/vite-plus https://github.com/voidzero-dev/vite-plus +brew install voidzero-dev/vite-plus/vp +vp +``` + +The explicit repository URL is required. The formula is part of the Vite+ repository. + +On first use, `vp` downloads Node.js LTS, pinned pnpm, and matching CLI dependencies. +It stores dependencies in `/cli-packages//` and sets up your preferences and shims. +`` comes from the [Vite+ directory configuration](/guide/global-cli#installation-variables). +The native executable stays under Homebrew's control. Later invocations reuse the completed setup. +`vp env doctor` shows both locations. + +## Custom npm registry + +First-run setup reads `~/.npmrc`, including npm-compatible authentication: + +```ini +registry=https://registry.example.com/repository/npm/ +//registry.example.com/repository/npm/:_authToken=${NPM_TOKEN} +``` + +Export `NPM_TOKEN` before running `vp`. Both npm, which downloads pnpm, and pnpm, which installs dependencies, use this configuration. +You can select another file with `NPM_CONFIG_USERCONFIG` or override the registry with `NPM_CONFIG_REGISTRY`. +Relative user-config paths resolve from your current directory. No Homebrew-specific npm variables are required. + +Authentication failures stop setup. Fix the configuration and run `vp` again; setup preserves your management choices. +Failure logs record the exit code without registry output, which can contain credentials. + +## Switch an existing installation + +For a Homebrew core installation, remove the core formula first: + +```bash +brew uninstall vite-plus +``` + +Install the tap with the commands above. Then invoke Homebrew's executable directly, so an existing script-install shim cannot take precedence: + +```bash +"$(brew --prefix)/bin/vp" env setup --refresh +hash -r +``` + +Use the same directory overrides as your existing installation. +Setup preserves your preferences, runtimes, and global packages, and updates their shims to Homebrew's public `vp` command. +Do not run `vp implode` to migrate: it removes that user data. + +## Upgrade or remove + +```bash +brew upgrade voidzero-dev/vite-plus/vp +``` + +The first invocation of the new version installs its matching dependencies and preserves your preferences. +`vp upgrade` detects the owning formula and shows the appropriate Homebrew command. + +For complete removal, remove your user data before uninstalling the executable: + +```bash +vp implode +brew uninstall voidzero-dev/vite-plus/vp +``` + +`brew uninstall` alone keeps user data. Each user can remove their own data with `vp implode` before the shared executable is removed. diff --git a/docs/guide/implode.md b/docs/guide/implode.md index da0c51feda..eeab087b68 100644 --- a/docs/guide/implode.md +++ b/docs/guide/implode.md @@ -33,6 +33,8 @@ vp implode brew uninstall vite-plus ``` -The confirmation prompt explains that the Homebrew package will remain installed. After cleanup, `vp implode` directs you to `brew uninstall vite-plus`. +For the [official tap](/guide/homebrew), use `brew uninstall voidzero-dev/vite-plus/vp` instead. + +The confirmation prompt explains that the Homebrew package will remain installed. After cleanup, `vp implode` shows the uninstall command for the owning formula. Restart your terminal before you run `vp` again. In Bash, you can run `hash -r` instead to clear cached command paths. If the Homebrew package is still installed, the next `vp` command starts first-run setup again. diff --git a/docs/guide/upgrade.md b/docs/guide/upgrade.md index c775b2d918..2b77307038 100644 --- a/docs/guide/upgrade.md +++ b/docs/guide/upgrade.md @@ -87,7 +87,7 @@ Older versions are pruned automatically after each upgrade. The active version a ### Homebrew -Homebrew owns its installed binary and JavaScript package. Update them with: +Homebrew owns its installed binary. Update the core formula with: ```bash brew upgrade vite-plus @@ -95,7 +95,9 @@ brew upgrade vite-plus `vp upgrade` detects Homebrew installations and directs you to this command without downloading or installing another version. This also applies to `--force`, a specific version, and `--rollback`. Use Homebrew to manage these installations. -`vp upgrade --check` directs you to `brew outdated vite-plus`. Automatic npm update checks and notices are disabled for Homebrew installations. +For the [official tap](/guide/homebrew), use `brew upgrade voidzero-dev/vite-plus/vp`. First use installs the new version's per-user dependencies. + +`vp upgrade --check` directs you to `brew outdated` with the installed formula name. Automatic npm update checks and notices are disabled for Homebrew installations. ## Preview Builds diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index 34599ef9b5..b5038065d3 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -1,6 +1,7 @@ # RFC: Vite+-managed Homebrew tap -Status: Draft for discussion. This RFC does not change installation behavior. +Status: Implementation proposed. The formula stays disabled until a compatible +release is published and its formula update is approved. ## Motivation @@ -91,9 +92,12 @@ and links the public commands. It creates no per-user Vite+ installation. The first `vp` invocation prepares Node.js and uses its bundled npm to download the pinned pnpm package. Then pnpm installs `vite-plus@A` and production dependencies in the user's data directory, keyed by CLI version and platform. -The Homebrew files stay unchanged. +Setup saves management choices before downloads so a failed attempt can retry +without asking again. The Homebrew files stay unchanged. ```text +/config.json # saved before downloads + / ├── js_runtime/node// │ ├── bin/node @@ -109,7 +113,7 @@ The Homebrew files stay unchanged. ### 4. Complete user setup -Setup creates preferences, shell environment files, and shims. It records +Setup completes preferences and creates shell environment files and shims. It records completion in user-owned state only after installation succeeds. All shims link through Homebrew's stable public command. This example uses managed Node.js and system-first npm: @@ -177,14 +181,13 @@ attempts and allow interrupted installs to retry without repeating preference prompts. Keep these directories separate from the script installer's `current` link. -## Required CLI changes +## CLI behavior -Add a mode for Homebrew-owned binaries without bundled JavaScript. The current -[self-setup code](../crates/vp_global_cli/src/self_setup.rs) copies bare external -binaries into a managed installation; this mode must retain the Homebrew binary. -The [JavaScript resolver](../crates/vp_global_cli/src/js_executor.rs) must find -its matching per-user dependency directory instead of expecting JavaScript -beside the executable. +The [self-setup code](../crates/vp_global_cli/src/self_setup.rs) retains Homebrew-owned +binaries without bundled JavaScript and prepares their per-user dependencies. +The [JavaScript resolver](../crates/vp_global_cli/src/js_executor.rs) uses that +matching dependency directory. Other bare external binaries still deploy to a +managed installation. Reuse [environment setup](../crates/vp_global_cli/src/commands/env/setup.rs) to initialize missing package-manager preferences and place shims. Preserve @@ -264,6 +267,9 @@ checksums. A failed update leaves the previous formula available. Use a formula `revision` for recipe fixes and a new release for CLI changes. A formula-only merge must not trigger another product release. +The updater checks the release tag for the new bootstrap and formula before +removing the initial disable gate. Existing releases cannot enable the tap. + Vite+ release maintainers own the formula and its release automation through the existing repository review process. Confirm a primary maintainer and a backup before launch. Formula failures belong in this repository's issue tracker. From 455a9fb7a4da781f5612e4e8f064137f3c6f6138 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 22 Sep 2026 18:38:40 +0800 Subject: [PATCH 16/31] fix: support native hosts in Homebrew installation tests --- .../download-rolldown-binaries/action.yml | 24 +++++++------------ .github/scripts/test-homebrew.mjs | 22 ++++++++++------- crates/vp_global_cli/src/homebrew.rs | 1 + crates/vp_global_cli/src/js_executor.rs | 5 ++-- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/actions/download-rolldown-binaries/action.yml b/.github/actions/download-rolldown-binaries/action.yml index f56621a969..abd2c8d68d 100644 --- a/.github/actions/download-rolldown-binaries/action.yml +++ b/.github/actions/download-rolldown-binaries/action.yml @@ -20,32 +20,26 @@ runs: - name: Install previous release shell: bash run: | - # Always download the binding that matches the runner OS (not the + # Always download the binding that matches the running Node (not the # Rust cross-compilation target). The rolldown binding is loaded by # Node.js on the host to run JS build steps, not cross-compiled. - if ${{ runner.os == 'Windows' }}; then - export TARGET="win32-x64-msvc" - elif ${{ runner.os == 'Linux' }}; then - export TARGET="linux-x64-gnu" - elif ${{ runner.os == 'macOS' }}; then - export TARGET="darwin-arm64" - fi + rolldown_host=$(node -p 'process.platform + "-" + process.arch + (process.platform === "linux" ? "-gnu" : process.platform === "win32" ? "-msvc" : "")') # Pin to the version from checked-out rolldown source to avoid mismatch # between JS code (built from source) and native binary (downloaded from npm). # Falls back to npm latest only when rolldown source isn't cloned yet # (e.g., the standalone download-previous-rolldown-binaries job). if [ -f "./rolldown/packages/rolldown/package.json" ]; then - export VERSION=$(node -p "require('./rolldown/packages/rolldown/package.json').version") - echo "Using rolldown version from source: ${VERSION}" + rolldown_version=$(node -p "require('./rolldown/packages/rolldown/package.json').version") + echo "Using rolldown version from source: ${rolldown_version}" else - export VERSION=$(npm view --json rolldown | jq -r '.version') - echo "Warning: rolldown source not found, using npm latest: ${VERSION}" + rolldown_version=$(npm view --json rolldown | jq -r '.version') + echo "Warning: rolldown source not found, using npm latest: ${rolldown_version}" fi - npm pack "@rolldown/binding-${TARGET}@${VERSION}" - tar -xzf "rolldown-binding-${TARGET}-${VERSION}.tgz" + npm pack "@rolldown/binding-${rolldown_host}@${rolldown_version}" + tar -xzf "rolldown-binding-${rolldown_host}-${rolldown_version}.tgz" if [ -d "./rolldown/packages/rolldown/src" ]; then - cp "./package/rolldown-binding.${TARGET}.node" ./rolldown/packages/rolldown/src + cp "./package/rolldown-binding.${rolldown_host}.node" ./rolldown/packages/rolldown/src ls ./rolldown/packages/rolldown/src fi env: diff --git a/.github/scripts/test-homebrew.mjs b/.github/scripts/test-homebrew.mjs index 6f1704dc67..f5a0c1b716 100644 --- a/.github/scripts/test-homebrew.mjs +++ b/.github/scripts/test-homebrew.mjs @@ -335,15 +335,19 @@ try { assert.ok(values['replacement-binary'] && values['packages-dir']); const packages = path.join(root, 'next-packages'); await fs.cp(path.resolve(values['packages-dir']), packages, { recursive: true }); - const cliArchive = (await fs.readdir(packages)).find((name) => name.startsWith('vite-plus-')); - const unpacked = path.join(root, 'next-cli'); - await fs.mkdir(unpacked); - await run('tar', ['-xzf', path.join(packages, cliArchive), '-C', unpacked]); - const manifestPath = path.join(unpacked, 'package/package.json'); - const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); - manifest.version = replacementVersion; - await fs.writeFile(manifestPath, JSON.stringify(manifest)); - await run('tar', ['-czf', path.join(packages, cliArchive), '-C', unpacked, 'package']); + for (const archive of await fs.readdir(packages)) { + if (!archive.endsWith('.tgz')) continue; + const unpacked = await fs.mkdtemp(path.join(root, 'next-package-')); + await run('tar', ['-xzf', path.join(packages, archive), '-C', unpacked]); + const manifestPath = path.join(unpacked, 'package/package.json'); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + manifest.version = replacementVersion; + if (manifest.name === 'vite-plus') { + manifest.dependencies.vite = `npm:@voidzero-dev/vite-plus-core@${replacementVersion}`; + } + await fs.writeFile(manifestPath, JSON.stringify(manifest)); + await run('tar', ['-czf', path.join(packages, archive), '-C', unpacked, 'package']); + } nextRegistry = await registry(packages); } await fs.chmod(oldKeg, 0o755); diff --git a/crates/vp_global_cli/src/homebrew.rs b/crates/vp_global_cli/src/homebrew.rs index a0f0711e88..c6f3c0e4fa 100644 --- a/crates/vp_global_cli/src/homebrew.rs +++ b/crates/vp_global_cli/src/homebrew.rs @@ -63,6 +63,7 @@ fn valid_component(value: &str) -> bool { && value.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"-_.@+".contains(&byte)) } +#[cfg(unix)] impl Installation { /// Homebrew's standard Cellar layout exposes a stable link outside the keg. pub(crate) fn public_binary(&self) -> Option { diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 470f23c38d..1dc8586b0d 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -82,7 +82,8 @@ impl JsExecutor { /// Resolution order: /// 1. Explicitly provided `scripts_dir` /// 2. `VP_GLOBAL_CLI_JS_SCRIPTS_DIR` environment variable - /// 3. Auto-detect from binary location (../dist relative to binary) + /// 3. Per-user dependencies for a bare Homebrew installation + /// 4. Bundled dependencies relative to the binary pub fn get_scripts_dir(&self) -> Result { // 1. Use explicitly provided scripts_dir if let Some(dir) = &self.scripts_dir { @@ -98,7 +99,7 @@ impl JsExecutor { return Ok(package.join("node_modules/vite-plus/dist")); } - // 3. Auto-detect from binary location + // 4. Auto-detect from binary location // JS scripts are at ../node_modules/vite-plus/dist relative to the binary directory // e.g., //bin/vp -> //node_modules/vite-plus/dist/ let exe_path = std::env::current_exe().map_err(|_| Error::JsScriptsDirNotFound)?; From 3b168ef9578b82b771f120b961f540e64c398809 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 22 Sep 2026 18:57:36 +0800 Subject: [PATCH 17/31] fix: stabilize Homebrew paths and installation checks --- .../scripts/__tests__/homebrew-formula.mjs | 6 +-- .github/scripts/test-homebrew.mjs | 53 +++++++++++++------ .github/scripts/update-homebrew-formula.mjs | 15 ++++-- .../command_self_setup_external/homebrew.mjs | 6 ++- crates/vp_global_cli/src/js_executor.rs | 6 +++ 5 files changed, 62 insertions(+), 24 deletions(-) diff --git a/.github/scripts/__tests__/homebrew-formula.mjs b/.github/scripts/__tests__/homebrew-formula.mjs index 9a0ceb51c8..3753eeae18 100644 --- a/.github/scripts/__tests__/homebrew-formula.mjs +++ b/.github/scripts/__tests__/homebrew-formula.mjs @@ -27,7 +27,7 @@ function download(url) { return Promise.resolve(new Response(url.endsWith('vp-checksums.txt') ? checksums : archive)); } -test('release publication requires the compatible tag and each verified archive', async () => { +await test('release publication requires the compatible tag and each verified archive', async () => { assert.deepEqual(await verifyRelease(version, release, download), assets); await assert.rejects(verifyRelease(version, { ...release, draft: true }, download), /published/); await assert.rejects( @@ -48,7 +48,7 @@ test('release publication requires the compatible tag and each verified archive' ); }); -test('missing, repeated, and invalid checksums cannot update a formula', () => { +await test('missing, repeated, and invalid checksums cannot update a formula', () => { assert.throws( () => releaseAssets(version, checksums.split('\n').slice(1).join('\n')), /Expected one checksum/, @@ -64,7 +64,7 @@ test('missing, repeated, and invalid checksums cannot update a formula', () => { assert.throws(() => releaseAssets('0.3.4-beta.1', checksums), /stable release/); }); -test('updates preserve recipe changes and reset only a previous version revision', () => { +await test('updates preserve recipe changes and reset only a previous version revision', () => { const revised = formula.replace(' license', ' revision 2\n license'); const result = updateFormula(revised, version, assets); assert.ok(!result.includes('disable!')); diff --git a/.github/scripts/test-homebrew.mjs b/.github/scripts/test-homebrew.mjs index f5a0c1b716..acee49d41e 100644 --- a/.github/scripts/test-homebrew.mjs +++ b/.github/scripts/test-homebrew.mjs @@ -5,7 +5,7 @@ import { createHash, randomBytes } from 'node:crypto'; import { once } from 'node:events'; import fs from 'node:fs/promises'; import { createServer } from 'node:http'; -import { tmpdir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parseArgs } from 'node:util'; @@ -27,7 +27,9 @@ const version = ( await fs.readFile(path.join(repository, 'crates/vp_global_cli/Cargo.toml'), 'utf8') ).match(/^version = "([^"]+)"/m)[1]; const replacementVersion = values['replacement-version'] ?? version; -const root = await fs.realpath(await fs.mkdtemp(path.join(tmpdir(), 'vp-homebrew-e2e-'))); +// Linux's build sandbox permits writes under /tmp, so keep Homebrew itself outside it. +const testParent = process.platform === 'linux' ? homedir() : tmpdir(); +const root = await fs.realpath(await fs.mkdtemp(path.join(testParent, 'vp-homebrew-e2e-'))); const prefix = path.join(root, 'brew'); const brew = path.join(prefix, 'bin/brew'); const formulaName = 'viteplus/e2e/vp'; @@ -45,10 +47,11 @@ const token = randomBytes(24).toString('hex'); async function run(command, args, env = baseEnv, cwd = root, expected = 0) { const child = spawn(command, args, { env, cwd, stdio: ['ignore', 'pipe', 'pipe'] }); let output = ''; - for (const stream of [child.stdout, child.stderr]) + for (const stream of [child.stdout, child.stderr]) { stream.setEncoding('utf8').on('data', (data) => { output += data; }); + } const [code, signal] = await once(child, 'close'); assert.equal(signal, null, output); assert.ok(!output.includes(token), 'Registry credentials appeared in command output'); @@ -58,7 +61,9 @@ async function run(command, args, env = baseEnv, cwd = root, expected = 0) { async function registry(packagesDir) { const args = [path.join(repository, 'packages/tools/src/local-npm-registry.ts'), '--serve']; - if (packagesDir) args.push('--packages-dir', packagesDir); + if (packagesDir) { + args.push('--packages-dir', packagesDir); + } const child = spawn(process.execPath, args, { env: baseEnv, cwd: root, @@ -94,8 +99,10 @@ async function registry(packagesDir) { async function authenticatedRegistry(upstream) { const requests = new Set(); const server = createServer(async (request, response) => { - if (request.headers.authorization !== `Bearer ${token}`) - return response.writeHead(401).end('Unauthorized'); + if (request.headers.authorization !== `Bearer ${token}`) { + response.writeHead(401).end('Unauthorized'); + return; + } requests.add(request.url); try { const remote = request.url.startsWith('/tarball/') @@ -118,7 +125,9 @@ async function authenticatedRegistry(upstream) { : value, ); response.end(text); - } else response.end(Buffer.from(await result.arrayBuffer())); + } else { + response.end(Buffer.from(await result.arrayBuffer())); + } } catch { response.writeHead(502).end('Registry fixture failed'); } @@ -184,7 +193,9 @@ async function formulaFor(binary, value, registryUrl, revision = false) { let result = updateFormula(formula, value, assets); // Local archive URLs have no GitHub release tag from which Homebrew can infer the version. result = result.replace(' license', ` version "${value}"\n license`); - if (revision) result = result.replace(' license', ' revision 1\n license'); + if (revision) { + result = result.replace(' license', ' revision 1\n license'); + } // brew test uses a separate home. Point only this disposable formula at the checkout registry. result = result.replace( ' test do\n', @@ -203,12 +214,13 @@ try { await fs.mkdir(brewTemp); const noNpm = path.join(root, 'no-npm'); await fs.mkdir(noNpm); - for (const tool of ['node', 'npm', 'pnpm']) + for (const tool of ['node', 'npm', 'pnpm']) { await fs.writeFile( path.join(noNpm, tool), '#!/bin/sh\necho "npm tools must not run during brew install" >&2\nexit 77\n', { mode: 0o755 }, ); + } const brewEnv = { ...baseEnv, HOME: brewHome, @@ -250,8 +262,9 @@ try { .digest('hex'); assert.ok(!(await fs.readdir(brewHome)).includes('.vite-plus')); assert.ok(!(await fs.readdir(oldKeg)).includes('node_modules')); - for (const alias of ['vpr', 'vpx']) + for (const alias of ['vpr', 'vpx']) { assert.equal(await fs.realpath(path.join(prefix, 'bin', alias)), await fs.realpath(publicVp)); + } console.log( 'Homebrew installs the current native binary and aliases without npm tools or user setup.', ); @@ -292,9 +305,11 @@ try { assert.ok(!(await fs.readdir(path.join(oldKeg, 'bin'))).includes('.vp-setup-complete')); assert.ok(!(await fs.readdir(first.VP_HOME)).includes('current')); const settings = await fs.readFile(path.join(first.VP_HOME, 'config.json'), 'utf8'); - for (const env of [first, second]) - for (const variable of ['VP_NODE_MANAGER', 'VP_PM_MANAGER', 'VP_PNPM_MANAGER']) + for (const env of [first, second]) { + for (const variable of ['VP_NODE_MANAGER', 'VP_PM_MANAGER', 'VP_PNPM_MANAGER']) { delete env[variable]; + } + } const marker = path.join(packageDir(first), '.vp-deps-complete'); const completed = (await fs.stat(marker)).mtimeMs; await run(shim(first, 'vp'), ['--help'], first, project); @@ -304,8 +319,9 @@ try { ['fmt', '--check', 'src.js'], ['lint', 'src.js'], ['build'], - ]) + ]) { await run(publicVp, args, first, project); + } assert.ok(!(await fs.readdir(project)).includes('node_modules')); await fs.access(path.join(project, 'dist/index.html')); console.log( @@ -336,7 +352,9 @@ try { const packages = path.join(root, 'next-packages'); await fs.cp(path.resolve(values['packages-dir']), packages, { recursive: true }); for (const archive of await fs.readdir(packages)) { - if (!archive.endsWith('.tgz')) continue; + if (!archive.endsWith('.tgz')) { + continue; + } const unpacked = await fs.mkdtemp(path.join(root, 'next-package-')); await run('tar', ['-xzf', path.join(packages, archive), '-C', unpacked]); const manifestPath = path.join(unpacked, 'package/package.json'); @@ -367,8 +385,9 @@ try { await assert.rejects(fs.access(oldKeg)); await fs.writeFile(path.join(first.HOME, '.npmrc'), `registry=${nextRegistry}/\n`); await run(shim(first, 'vp'), ['--help'], first, project); - for (const name of ['node', 'pn', 'tsc']) + for (const name of ['node', 'pn', 'tsc']) { await run(shim(first, name), ['--version'], first, project); + } await run(shim(first, 'pnx'), ['--help'], first, project); await run(path.join(first.VP_HOME, 'fallback-bin/npm'), ['--version'], first, project); await fs.access(path.join(packageDir(first, replacementVersion), '.vp-deps-complete')); @@ -413,7 +432,9 @@ try { server.close(); server.closeAllConnections(); } - for (const child of children) child.kill(); + for (const child of children) { + child.kill(); + } // Keep failures available for inspection; the prefix is always task-local. console.log(`Homebrew e2e files: ${root}`); } diff --git a/.github/scripts/update-homebrew-formula.mjs b/.github/scripts/update-homebrew-formula.mjs index 10ddc4d082..db6a7d3898 100644 --- a/.github/scripts/update-homebrew-formula.mjs +++ b/.github/scripts/update-homebrew-formula.mjs @@ -77,7 +77,9 @@ export function updateFormula(source, version, assets) { let updated = source .replace(assetBlock, ` # BEGIN RELEASE ASSETS\n${blocks}\n # END RELEASE ASSETS`) .replace(launchGate, ''); - if (difference > 0) updated = updated.replace(/^ revision \d+\n/m, ''); + if (difference > 0) { + updated = updated.replace(/^ revision \d+\n/m, ''); + } return updated; } @@ -140,9 +142,14 @@ async function main() { const guideResult = guideSource .replace(/::: warning Availability\n[\s\S]*?:::\n\n/, '') .replace('When the tap becomes available, run:', 'Install the tap:'); - if (result !== source) await writeFile(file, result); - if (guideResult !== guideSource) await writeFile(guide, guideResult); + if (result !== source) { + await writeFile(file, result); + } + if (guideResult !== guideSource) { + await writeFile(guide, guideResult); + } } -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { await main(); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs index 25490d5909..f6ea580903 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs @@ -61,7 +61,7 @@ const pnpmCode = ` if (!response.ok) { console.error(process.env.NPM_TOKEN); process.exit(17); } fs.mkdirSync('node_modules/vite-plus/dist', { recursive: true }); fs.writeFileSync('node_modules/vite-plus/package.json', JSON.stringify({ name: 'vite-plus', version })); - fs.writeFileSync('node_modules/vite-plus/dist/bin.js', "console.log('homebrew user CLI')"); + fs.writeFileSync('node_modules/vite-plus/dist/bin.js', "require('node:fs').writeFileSync(process.env.TEST_CLI_BINARY, process.env.VP_CLI_BIN); console.log('homebrew user CLI')"); }); } else { console.log('pinned pnpm tool'); } `; @@ -162,6 +162,7 @@ function environment(name, auth = true) { VP_PNPM_MANAGER: 'yes', NPM_TOKEN: token, TEST_REGISTRY: url, + TEST_CLI_BINARY: path.join(home, 'vp-path'), npm_config_fetch_retries: '0', npm_config_cache: path.join(home, 'npm-cache'), PATH: [ @@ -215,11 +216,14 @@ try { const cli = await run(path.join(env.VP_HOME, 'bin/vp'), ['sync-versions', '--json'], env); assert.equal(cli.stdout.trim(), 'homebrew user CLI'); + const savedBinary = fs.readFileSync(env.TEST_CLI_BINARY, 'utf8'); + assert.equal(savedBinary, publicVp); const replacement = keg('new'); fs.unlinkSync(publicVp); fs.symlinkSync(path.join(replacement, 'bin/vp'), publicVp); unlock(old); fs.rmSync(old, { recursive: true }); + await run(savedBinary, ['--help'], env); await run(path.join(env.VP_HOME, 'bin/vp'), ['--help'], env); await run(path.join(env.VP_HOME, 'bin/node'), ['--version'], env); for (const shim of ['pn', 'pnx']) { diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 1dc8586b0d..275818c412 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -119,6 +119,12 @@ impl JsExecutor { /// This is passed to JS scripts via `VP_CLI_BIN` environment variable /// so they can invoke vp commands when needed. fn get_bin_path() -> Result { + #[cfg(unix)] + if let Some(public) = crate::homebrew::current().and_then(|install| install.public_binary()) + { + // A running JS command can invoke vp after Homebrew removes its original keg. + return Ok(public); + } let exe_path = std::env::current_exe().map_err(|_| Error::CliBinaryNotFound)?; AbsolutePathBuf::new(exe_path).ok_or(Error::CliBinaryNotFound) } From cc4545c99b8c013d2888e95eb7684c1e534d9371 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 22 Sep 2026 19:17:56 +0800 Subject: [PATCH 18/31] test: keep formula checks valid across releases --- .github/scripts/__tests__/homebrew-formula.mjs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/scripts/__tests__/homebrew-formula.mjs b/.github/scripts/__tests__/homebrew-formula.mjs index 3753eeae18..88960badee 100644 --- a/.github/scripts/__tests__/homebrew-formula.mjs +++ b/.github/scripts/__tests__/homebrew-formula.mjs @@ -11,7 +11,9 @@ import { } from '../update-homebrew-formula.mjs'; const formula = await readFile(new URL('../../../HomebrewFormula/vp.rb', import.meta.url), 'utf8'); -const version = '0.3.4'; +const currentVersion = formula.match(/\/releases\/download\/v(\d+\.\d+\.\d+)\//)[1]; +const [major, minor, patch] = currentVersion.split('.').map(Number); +const version = `${major}.${minor}.${patch + 1}`; const archive = Buffer.from('release fixture'); const digest = createHash('sha256').update(archive).digest('hex'); const checksums = targets.map(([, , target]) => `${digest} vp-${target}.tar.gz`).join('\n'); @@ -65,7 +67,9 @@ await test('missing, repeated, and invalid checksums cannot update a formula', ( }); await test('updates preserve recipe changes and reset only a previous version revision', () => { - const revised = formula.replace(' license', ' revision 2\n license'); + const revised = formula + .replace(/^ revision \d+\n/m, '') + .replace(' license', ' revision 2\n license'); const result = updateFormula(revised, version, assets); assert.ok(!result.includes('disable!')); assert.ok(!result.includes('revision 2')); @@ -73,7 +77,7 @@ await test('updates preserve recipe changes and reset only a previous version re assert.ok(result.includes(' def install\n bin.install "vp"')); const sameVersion = result.replace(' license', ' revision 1\n license'); assert.equal(updateFormula(sameVersion, version, assets), sameVersion); - assert.throws(() => updateFormula(result, '0.3.3', assets), /downgrade/); + assert.throws(() => updateFormula(result, currentVersion, assets), /downgrade/); assert.throws( () => updateFormula(formula.replace('BEGIN RELEASE ASSETS', 'missing'), version, assets), /Missing release asset block/, From 31deeaf4e7233fbeb1e1b47325b0fb18e711df35 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 22 Sep 2026 19:27:46 +0800 Subject: [PATCH 19/31] test: keep Homebrew prefixes outside system temp dirs --- .github/scripts/test-homebrew.mjs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/scripts/test-homebrew.mjs b/.github/scripts/test-homebrew.mjs index acee49d41e..5ce0d8b645 100644 --- a/.github/scripts/test-homebrew.mjs +++ b/.github/scripts/test-homebrew.mjs @@ -5,7 +5,7 @@ import { createHash, randomBytes } from 'node:crypto'; import { once } from 'node:events'; import fs from 'node:fs/promises'; import { createServer } from 'node:http'; -import { homedir, tmpdir } from 'node:os'; +import { homedir } from 'node:os'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parseArgs } from 'node:util'; @@ -27,9 +27,8 @@ const version = ( await fs.readFile(path.join(repository, 'crates/vp_global_cli/Cargo.toml'), 'utf8') ).match(/^version = "([^"]+)"/m)[1]; const replacementVersion = values['replacement-version'] ?? version; -// Linux's build sandbox permits writes under /tmp, so keep Homebrew itself outside it. -const testParent = process.platform === 'linux' ? homedir() : tmpdir(); -const root = await fs.realpath(await fs.mkdtemp(path.join(testParent, 'vp-homebrew-e2e-'))); +// Build sandboxes permit writes in system temporary directories. Keep Homebrew outside them. +const root = await fs.realpath(await fs.mkdtemp(path.join(homedir(), 'vp-homebrew-e2e-'))); const prefix = path.join(root, 'brew'); const brew = path.join(prefix, 'bin/brew'); const formulaName = 'viteplus/e2e/vp'; From 6ddf97dc0316d22a61b0dd0ae04a3567f83d63fc Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 22 Sep 2026 21:32:54 +0800 Subject: [PATCH 20/31] test: support prerelease versions in Homebrew e2e --- .github/scripts/test-homebrew.mjs | 3 ++- .github/workflows/test-homebrew.yml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/scripts/test-homebrew.mjs b/.github/scripts/test-homebrew.mjs index 5ce0d8b645..924fc0ecde 100644 --- a/.github/scripts/test-homebrew.mjs +++ b/.github/scripts/test-homebrew.mjs @@ -189,7 +189,8 @@ async function formulaFor(binary, value, registryUrl, revision = false) { url: pathToFileURL(archive).href, sha256, })); - let result = updateFormula(formula, value, assets); + // Publication requires a stable version; this test can use a prerelease checkout. + let result = updateFormula(formula, value.split(/[-+]/, 1)[0], assets); // Local archive URLs have no GitHub release tag from which Homebrew can infer the version. result = result.replace(' license', ` version "${value}"\n license`); if (revision) { diff --git a/.github/workflows/test-homebrew.yml b/.github/workflows/test-homebrew.yml index 44de71183e..094ae0a07d 100644 --- a/.github/workflows/test-homebrew.yml +++ b/.github/workflows/test-homebrew.yml @@ -122,7 +122,7 @@ jobs: const file = 'crates/vp_global_cli/Cargo.toml'; const source = fs.readFileSync(file, 'utf8'); const version = source.match(/^version = "([^"]+)"/m)[1]; - const [major, minor, patch] = version.split('.').map(Number); + const [major, minor, patch] = version.split(/[-+]/, 1)[0].split('.').map(Number); const next = `${major}.${minor}.${patch + 1}`; fs.writeFileSync(file, source.replace(/^version = "[^"]+"/m, `version = "${next}"`)); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${next}\n`); From 03174091bbfb3d87d1c35c488f5d84dc5d891f1f Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 22 Sep 2026 23:05:58 +0800 Subject: [PATCH 21/31] fix: preserve Homebrew entrypoints and repair package shims --- .../snapshots.toml | 12 ++ .../command_self_setup_homebrew_collision.md | 9 ++ .../command_self_setup_homebrew_migration.md | 9 ++ .../verify-refresh.mjs | 135 +++++++++++++++++- .../vp_global_cli/src/commands/env/setup.rs | 20 ++- .../src/commands/env/setup/unix.rs | 2 +- 6 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_collision.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_migration.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml index 9b3b2934a7..1829290518 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml @@ -4,6 +4,18 @@ vp = "global" skip-platforms = ["windows"] steps = [{ argv = ["node", "homebrew.mjs"], timeout = 120000 }] +[[case]] +name = "command_self_setup_homebrew_collision" +vp = "global" +skip-platforms = ["windows"] +steps = [["node", "verify-refresh.mjs", "homebrew-collision"]] + +[[case]] +name = "command_self_setup_homebrew_migration" +vp = "global" +skip-platforms = ["windows"] +steps = [["node", "verify-refresh.mjs", "homebrew-migration"]] + [[case]] name = "command_self_setup_external_bundled" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_collision.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_collision.md new file mode 100644 index 0000000000..561d0ffec6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_collision.md @@ -0,0 +1,9 @@ +# command_self_setup_homebrew_collision + +## `node verify-refresh.mjs homebrew-collision` + +``` +direct: first setup and refresh preserve Homebrew's entrypoint +directory-alias: first setup and refresh preserve Homebrew's entrypoint +fallback-alias: first setup and refresh preserve Homebrew's entrypoint +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_migration.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_migration.md new file mode 100644 index 0000000000..745787d3dc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_migration.md @@ -0,0 +1,9 @@ +# command_self_setup_homebrew_migration + +## `node verify-refresh.mjs homebrew-migration` + +``` +First launch repairs owned links after core keg removal +Explicit refresh repairs owned links after core keg removal +Foreign files, directories, package links, npm-owned links, and untracked links stay unchanged +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs index 5389615478..6699743686 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs @@ -56,7 +56,11 @@ function createEnvironment(directory) { function run(binary, args, cwd, env, expectedStatus = 0) { const result = spawnSync(binary, args, { cwd, env, encoding: 'utf8', timeout: 30000 }); - assert.equal(result.status, expectedStatus, result.error?.message ?? result.stdout + result.stderr); + assert.equal( + result.status, + expectedStatus, + result.error?.message ?? result.stdout + result.stderr, + ); return result.stdout; } @@ -189,6 +193,125 @@ function verifyReplacement(source) { console.log('mixed preferences survive a new package path'); } +function verifyHomebrewCollision(source) { + for (const layout of ['direct', 'directory-alias', 'fallback-alias']) { + for (const refresh of [false, true]) { + const directory = path.resolve(`${layout}-${refresh}`); + const prefix = path.join(directory, 'brew/Cellar/vite-plus/old'); + createBundle(source, prefix, 'bundled CLI'); + fs.writeFileSync(path.join(prefix, 'INSTALL_RECEIPT.json'), '{"homebrew_version":"7.0.2"}'); + const publicBin = path.join(directory, 'brew/bin'); + const publicBinary = path.join(publicBin, 'vp'); + const link = '../Cellar/vite-plus/old/bin/vp'; + fs.mkdirSync(publicBin, { recursive: true }); + fs.symlinkSync(link, publicBinary); + const env = { ...createEnvironment(directory), ...mixed }; + const data = path.join(directory, 'data'); + fs.mkdirSync(data); + const splitEnv = { + ...env, + VP_HOME: undefined, + VP_BIN_DIR: path.join(directory, 'user-bin'), + VP_DATA_DIR: data, + VP_CACHE_DIR: path.join(directory, 'cache'), + }; + if (refresh) run(publicBinary, ['--help'], directory, splitEnv); + let bin = publicBin; + if (layout === 'directory-alias') { + bin = path.join(directory, 'bin-alias'); + fs.symlinkSync(publicBin, bin); + } else if (layout === 'fallback-alias') { + bin = splitEnv.VP_BIN_DIR; + fs.rmSync(path.join(data, 'fallback-bin'), { recursive: true, force: true }); + fs.symlinkSync(publicBin, path.join(data, 'fallback-bin')); + } + const result = spawnSync(publicBinary, refresh ? ['env', 'setup', '--refresh'] : ['--help'], { + cwd: directory, + env: { ...splitEnv, VP_BIN_DIR: bin }, + encoding: 'utf8', + timeout: 30000, + }); + assert.equal(fs.readlinkSync(publicBinary), link, 'Homebrew entrypoint was replaced'); + assert.equal(result.status, 1, result.error?.message ?? result.stdout + result.stderr); + assert.ok(result.stderr.includes('shim directories'), result.stderr); + run(publicBinary, ['--help'], directory, env); + } + console.log(`${layout}: first setup and refresh preserve Homebrew's entrypoint`); + } +} + +function verifyHomebrewMigration(source) { + for (const refresh of [false, true]) { + const directory = path.resolve(`migration-${refresh}`); + const old = path.join(directory, 'brew/Cellar/vite-plus/old'); + const next = path.join(directory, 'brew/Cellar/vp/new'); + const previous = createBundle(source, old, 'old CLI'); + const replacement = createBundle(source, next, 'new CLI'); + for (const prefix of [old, next]) { + fs.writeFileSync(path.join(prefix, 'INSTALL_RECEIPT.json'), '{"homebrew_version":"7.0.2"}'); + } + const publicBinary = path.join(directory, 'brew/bin/vp'); + fs.mkdirSync(path.dirname(publicBinary), { recursive: true }); + fs.symlinkSync(previous, publicBinary); + const env = { ...createEnvironment(directory), ...mixed }; + run(publicBinary, ['--help'], directory, env); + fs.unlinkSync(publicBinary); + fs.symlinkSync(replacement, publicBinary); + if (refresh) run(publicBinary, ['--help'], directory, env); + + const bin = path.join(env.VP_HOME, 'bin'); + const configs = path.join(env.VP_HOME, 'bins'); + fs.mkdirSync(configs); + const links = { + absolute: previous, + relative: path.relative(bin, previous), + foreign: path.join(directory, 'system/bin/node'), + dangling: path.join(directory, 'missing/tool'), + npm: previous, + untracked: previous, + }; + for (const name of [...Object.keys(links), 'file', 'directory', 'absent']) { + if (name !== 'untracked') { + fs.writeFileSync( + path.join(configs, `${name}.json`), + JSON.stringify({ + name, + package: 'fixture', + version: '1.0.0', + nodeVersion: process.versions.node, + source: name === 'npm' ? 'npm' : 'vp', + }), + ); + } + } + for (const [name, target] of Object.entries(links)) + fs.symlinkSync(target, path.join(bin, name)); + fs.writeFileSync(path.join(bin, 'file'), 'foreign executable'); + fs.mkdirSync(path.join(bin, 'directory')); + fs.rmSync(old, { recursive: true }); + for (const name of ['absolute', 'relative']) assert.ok(!fs.existsSync(path.join(bin, name))); + + run(publicBinary, refresh ? ['env', 'setup', '--refresh'] : ['--help'], directory, env); + for (const name of ['absolute', 'relative']) { + assert.equal(fs.readlinkSync(path.join(bin, name)), publicBinary); + assert.equal(fs.realpathSync(path.join(bin, name)), fs.realpathSync(replacement)); + assert.ok(fs.existsSync(path.join(configs, `${name}.json`))); + } + for (const name of ['foreign', 'dangling', 'npm', 'untracked']) { + assert.equal(fs.readlinkSync(path.join(bin, name)), links[name]); + } + assert.equal(fs.readFileSync(path.join(bin, 'file'), 'utf8'), 'foreign executable'); + assert.ok(fs.statSync(path.join(bin, 'directory')).isDirectory()); + assert.ok(!fs.existsSync(path.join(bin, 'absent'))); + console.log( + `${refresh ? 'Explicit refresh' : 'First launch'} repairs owned links after core keg removal`, + ); + } + console.log( + 'Foreign files, directories, package links, npm-owned links, and untracked links stay unchanged', + ); +} + function main() { // These actions run between commands in the same Bash session. if (action === 'switch') { @@ -201,7 +324,15 @@ function main() { return; } - const source = path.join(process.env.VP_HOME, 'bin/vp'); + const source = process.env.TEST_VP_BINARY ?? path.join(process.env.VP_HOME, 'bin/vp'); + if (action === 'homebrew-collision') { + verifyHomebrewCollision(source); + return; + } + if (action === 'homebrew-migration') { + verifyHomebrewMigration(source); + return; + } if (action === 'preferences') { verifyPreferences(source); return; diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 1240583e95..abb84796da 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -469,6 +469,18 @@ pub(crate) async fn resolve_unix_vp_shim_target( && same_file::is_same_file(current_exe, homebrew.binary.as_path()).unwrap_or(false) && let Some(public) = homebrew.public_binary() { + let dirs = &vp_shared::EnvConfig::get().dirs; + if unix::passes_through_shims(public.as_path(), dirs.bin.as_path()) + || unix::passes_through_shims(public.as_path(), dirs.fallback_bin().as_path()) + { + return Err(Error::ConfigError( + format!( + "Vite+ shim directories must not contain the Homebrew entrypoint {}. Choose separate VP_BIN_DIR and VP_DATA_DIR paths.", + public.as_path().display() + ) + .into(), + )); + } return Ok(public.as_path().to_path_buf()); } let current_exe_canon = tokio::fs::canonicalize(current_exe).await.ok(); @@ -656,10 +668,10 @@ async fn refresh_package_shims( continue; } let shim = bin_dir.join(&name); - // Keep foreign files, including npm's direct package links. - if crate::commands::global::install::is_vp_shim_target(&shim) - && tokio::fs::read_link(&shim).await? != target.as_path() - { + // The saved Vp source and a link to vp identify an owned package shim even + // after its old installation is removed. Keep foreign files and direct package links. + let Ok(previous) = tokio::fs::read_link(&shim).await else { continue }; + if previous.file_name().is_some_and(|name| name == "vp") && previous != target { tokio::fs::remove_file(&shim).await?; tokio::fs::symlink(&target, &shim).await?; } diff --git a/crates/vp_global_cli/src/commands/env/setup/unix.rs b/crates/vp_global_cli/src/commands/env/setup/unix.rs index 8314cbeaff..ac7e2b9385 100644 --- a/crates/vp_global_cli/src/commands/env/setup/unix.rs +++ b/crates/vp_global_cli/src/commands/env/setup/unix.rs @@ -34,7 +34,7 @@ pub(super) fn external_shim_target(binary: &Path) -> Option { // Canonical equality alone would accept aliases back to our own vp/node shims, // creating a cycle as soon as setup replaces them. Check every link in the chain. -fn passes_through_shims(candidate: &Path, bin: &Path) -> bool { +pub(super) fn passes_through_shims(candidate: &Path, bin: &Path) -> bool { let bin = std::fs::canonicalize(bin).unwrap_or_else(|_| bin.to_path_buf()); let mut path = candidate.to_path_buf(); for _ in 0..40 { From dffa93d52fa80a0ae9c5f6c2b8b688eca06339c1 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 23 Sep 2026 18:59:15 +0800 Subject: [PATCH 22/31] feat: select Homebrew tap preview builds with an environment variable --- .../scripts/__tests__/homebrew-formula.mjs | 2 + .github/scripts/__tests__/homebrew-preview.rb | 115 ++++++++++++++++++ .github/scripts/test-homebrew.mjs | 5 + .github/workflows/test-homebrew.yml | 2 + HomebrewFormula/vp.rb | 83 ++++++++++++- docs/guide/homebrew.md | 21 +++- rfcs/official-homebrew-tap.md | 14 ++- 7 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 .github/scripts/__tests__/homebrew-preview.rb diff --git a/.github/scripts/__tests__/homebrew-formula.mjs b/.github/scripts/__tests__/homebrew-formula.mjs index 88960badee..cfc7710c5e 100644 --- a/.github/scripts/__tests__/homebrew-formula.mjs +++ b/.github/scripts/__tests__/homebrew-formula.mjs @@ -75,6 +75,8 @@ await test('updates preserve recipe changes and reset only a previous version re assert.ok(!result.includes('revision 2')); assert.ok(result.includes('conflicts_with "vite-plus"')); assert.ok(result.includes(' def install\n bin.install "vp"')); + assert.ok(result.includes('ENV["HOMEBREW_VP_PR_VERSION"].presence')); + assert.ok(result.includes('Preview.resolve(preview_ref, platform)')); const sameVersion = result.replace(' license', ' revision 1\n license'); assert.equal(updateFormula(sameVersion, version, assets), sameVersion); assert.throws(() => updateFormula(result, currentVersion, assets), /downgrade/); diff --git a/.github/scripts/__tests__/homebrew-preview.rb b/.github/scripts/__tests__/homebrew-preview.rb new file mode 100644 index 0000000000..6957d19256 --- /dev/null +++ b/.github/scripts/__tests__/homebrew-preview.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +# Run with `brew ruby` inside the disposable Homebrew e2e prefix. +require "formulary" + +def check(condition, message) + raise message unless condition +end + +def rejects(message) + yield +rescue StandardError => e + raise unless e.message.include?(message) +else + raise "Expected failure: #{message}" +end + +source = Pathname.new(__dir__).join("../../../HomebrewFormula/vp.rb").realpath +load_formula = lambda do + Formulary.clear_cache + Formulary.from_contents("vp", source, source.read) +end + +# Metadata requests are fixtures. Archive downloads use Homebrew's real cache +# and resource implementation, with bytes placed at the expected cache key. +metadata = nil +requests = [] +commit = "a" * 40 +commit_key = "voidzero-dev:vite-plus:#{commit}" +Utils::Curl.define_singleton_method(:curl_output) do |*args, **_options| + requests << args.last + raise "Unexpected metadata request" unless metadata + + Struct.new(:stdout) do + def assert_success!; end + end.new(JSON.generate(metadata)) +end +Utils::Curl.define_singleton_method(:curl_headers) do |*args, **_options| + requests << args.last + { responses: [{ headers: { "x-commit-key" => commit_key } }] } +end + +ENV.delete("HOMEBREW_VP_PR_VERSION") +stable = load_formula.call +check(stable.stable.url.start_with?("https://github.com/voidzero-dev/vite-plus/releases/"), "Stable URL changed") +check(requests.empty?, "Stable formula requested preview metadata") +preview = stable.class.const_get(:Preview) + +seed = lambda do |platform, sha, bytes = "preview archive"| + version = "0.0.0-commit.#{sha}" + package = "@voidzero-dev/vite-plus-cli-#{platform}" + shasum = Digest::SHA1.hexdigest(bytes) + url = "https://registry-bridge.viteplus.dev/tarballs/#{package}/#{version}/#{shasum}.tgz" + archive = HOMEBREW_CACHE/"downloads/#{Digest::SHA256.hexdigest(url)}--#{shasum}.tgz" + archive.dirname.mkpath + archive.write(bytes) + cached_metadata = HOMEBREW_CACHE/"vp-preview/#{sha}-#{platform}.json" + cached_metadata.unlink if cached_metadata.exist? + metadata = { + "name" => package, "version" => version, + "dist" => { "tarball" => url, "shasum" => shasum, + "integrity" => "sha512-#{Digest::SHA512.base64digest(bytes)}" }, + } + archive +end + +%w[../main main 0 12x abcdef0].each do |ref| + rejects("PR number or a full commit SHA") { preview.resolve(ref, "darwin-arm64") } +end +check(requests.empty?, "Invalid selectors made network requests") + +commit_key = "" +rejects("No published Vite+ preview") { preview.resolve("2740", "darwin-arm64") } +commit_key = "voidzero-dev:vite-plus:#{commit}" + +%w[darwin-arm64 darwin-x64 linux-arm64-gnu linux-x64-gnu].each do |platform| + archive = seed.call(platform, commit) + result = preview.resolve("2740", platform) + check(result[:version] == "0.0.0-commit.#{commit}", "PR did not resolve to a commit") + check(result[:sha256] == Digest::SHA256.file(archive).hexdigest, "Wrong SHA-256") + check(ENV["HOMEBREW_VP_PR_VERSION"] == commit, "Build subprocess was not pinned") + count = requests.length + check(preview.resolve(commit, platform) == result, "Cached preview changed") + check(requests.length == count, "Pinned build requested metadata again") +end +puts "PR resolution, all four platforms, integrity verification, and offline build subprocesses pass." + +platform = "#{OS.mac? ? "darwin" : "linux"}-#{Hardware::CPU.arm? ? "arm64" : "x64"}#{"-gnu" if OS.linux?}" +expected = preview.resolve(commit, platform) +ENV["HOMEBREW_VP_PR_VERSION"] = commit.upcase +selected = load_formula.call +check(selected.version.to_s == "0.0.0-commit.#{commit}", "Formula did not select the preview") +check(!selected.disabled?, "Preview kept the stable launch gate") +check(selected.stable.url == expected[:url], "Wrong formula URL") +check(selected.stable.checksum.to_s == Digest::SHA256.hexdigest("preview archive"), "Formula lost its checksum") + +seed.call(platform, "b" * 40) +metadata["version"] = "0.3.3" +rejects("does not match") { preview.resolve("b" * 40, platform) } +seed.call(platform, "c" * 40) +metadata["dist"]["tarball"] = "https://example.test/untrusted.tgz" +rejects("invalid download URL or checksum") { preview.resolve("c" * 40, platform) } +seed.call(platform, "d" * 40) +metadata["dist"]["integrity"] = "sha512-invalid" +rejects("invalid download URL or checksum") { preview.resolve("d" * 40, platform) } +archive = seed.call(platform, "e" * 40) +archive.write("corrupted archive") +rejects("checksum mismatch") { preview.resolve("e" * 40, platform) } +check(!archive.exist?, "Corrupted download stayed in the cache") +puts "Wrong versions, foreign URLs, invalid integrity, and corrupt archives are rejected." + +ENV.delete("HOMEBREW_VP_PR_VERSION") +restored = load_formula.call +check(restored.version == stable.version && restored.stable.url == stable.stable.url, "Stable selection did not return") +puts "Removing the selector restores the stable formula." diff --git a/.github/scripts/test-homebrew.mjs b/.github/scripts/test-homebrew.mjs index 924fc0ecde..f272d76303 100644 --- a/.github/scripts/test-homebrew.mjs +++ b/.github/scripts/test-homebrew.mjs @@ -233,6 +233,11 @@ try { HOMEBREW_NO_INSTALL_CLEANUP: '1', PATH: [noNpm, '/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter), }; + await run( + brew, + ['ruby', path.join(repository, '.github/scripts/__tests__/homebrew-preview.rb')], + brewEnv, + ); const tapSource = path.join(root, 'tap'); await fs.mkdir(path.join(tapSource, 'HomebrewFormula'), { recursive: true }); await fs.writeFile( diff --git a/.github/workflows/test-homebrew.yml b/.github/workflows/test-homebrew.yml index 094ae0a07d..fd8542fa35 100644 --- a/.github/workflows/test-homebrew.yml +++ b/.github/workflows/test-homebrew.yml @@ -12,6 +12,7 @@ on: - 'HomebrewFormula/**' - '.github/scripts/*homebrew*' - '.github/scripts/__tests__/homebrew-formula.mjs' + - '.github/scripts/__tests__/homebrew-preview.rb' - '.github/workflows/test-homebrew.yml' - '.github/workflows/release.yml' - 'crates/vp_setup/**' @@ -57,6 +58,7 @@ jobs: - 'HomebrewFormula/**' - '.github/scripts/*homebrew*' - '.github/scripts/__tests__/homebrew-formula.mjs' + - '.github/scripts/__tests__/homebrew-preview.rb' - '.github/workflows/test-homebrew.yml' - '.github/workflows/release.yml' - 'crates/vp_setup/**' diff --git a/HomebrewFormula/vp.rb b/HomebrewFormula/vp.rb index b0676f8cd5..76e95b5f0c 100644 --- a/HomebrewFormula/vp.rb +++ b/HomebrewFormula/vp.rb @@ -1,12 +1,83 @@ # frozen_string_literal: true +require "digest" + class Vp < Formula + # Keep this helper in the formula: Homebrew also loads the copy saved in the keg. + module Preview + REGISTRY = "https://registry-bridge.viteplus.dev" + + def self.resolve(ref, platform) + unless ref.match?(/\A(?:[1-9]\d*|[a-fA-F0-9]{40})\z/) + raise ArgumentError, "HOMEBREW_VP_PR_VERSION must be a PR number or a full commit SHA" + end + + sha = ref.downcase + unless sha.match?(/\A[a-f0-9]{40}\z/) + headers = Utils::Curl.curl_headers( + "--max-time", "30", "#{REGISTRY}/voidzero-dev/vite-plus@#{ref}", + wanted_headers: ["x-commit-key"] + ) + key = headers.fetch(:responses).last.fetch(:headers).fetch("x-commit-key", "") + sha = key[/\Avoidzero-dev:vite-plus:([a-f0-9]{40})\z/, 1] + raise "No published Vite+ preview for PR #{ref}; apply the preview-build label first" unless sha + end + + preview_version = "0.0.0-commit.#{sha}" + package = "@voidzero-dev/vite-plus-cli-#{platform}" + # Build subprocesses must use the same commit and need no metadata network access. + cache = HOMEBREW_CACHE/"vp-preview/#{sha}-#{platform}.json" + metadata = if cache.file? + JSON.parse(cache.read) + else + result = Utils::Curl.curl_output( + "--fail", "--silent", "--show-error", "--max-time", "30", + "#{REGISTRY}/#{package}/#{preview_version}" + ) + result.assert_success! + JSON.parse(result.stdout) + end + if metadata["name"] != package || metadata["version"] != preview_version + raise "Preview metadata does not match #{package}@#{preview_version}" + end + + dist = metadata.fetch("dist") + shasum = dist.fetch("shasum") + download_url = "#{REGISTRY}/tarballs/#{package}/#{preview_version}/#{shasum}.tgz" + integrity = dist.fetch("integrity") + if !shasum.match?(/\A[a-f0-9]{40}\z/) || dist["tarball"] != download_url || + !integrity.match?(%r{\Asha512-[A-Za-z0-9+/]{86}==\z}) + raise "Preview metadata has an invalid download URL or checksum" + end + + resource = Resource.new("vp-preview") do + url download_url + version preview_version + end + # npm publishes SHA-512. Verify it before deriving Homebrew's SHA-256; + # the normal formula fetch reuses this archive from Homebrew's download cache. + archive = resource.cached_download + resource.fetch(verify_download_integrity: false, quiet: true) unless archive.file? + if "sha512-#{Digest::SHA512.file(archive).base64digest}" != integrity + resource.clear_cache + raise "Vite+ preview checksum mismatch" + end + unless cache.file? + cache.dirname.mkpath + cache.atomic_write(JSON.generate(metadata)) + end + ENV["HOMEBREW_VP_PR_VERSION"] = sha + { version: preview_version, url: download_url, sha256: Digest::SHA256.file(archive).hexdigest } + end + end desc "Unified toolchain for the web" homepage "https://viteplus.dev/" license "MIT" + preview_ref = ENV["HOMEBREW_VP_PR_VERSION"].presence + # The updater removes this gate after the first compatible release is published. - disable! date: "2026-09-22", because: "requires a release with per-user Homebrew setup" + disable! date: "2026-09-22", because: "requires a release with per-user Homebrew setup" unless preview_ref # BEGIN RELEASE ASSETS on_macos do @@ -32,6 +103,16 @@ class Vp < Formula end # END RELEASE ASSETS + if preview_ref + os = OS.mac? ? "darwin" : "linux" + arch = Hardware::CPU.arm? ? "arm64" : "x64" + platform = "#{os}-#{arch}#{"-gnu" if OS.linux?}" + preview = Preview.resolve(preview_ref, platform) + url preview.fetch(:url) + version preview.fetch(:version) + sha256 preview.fetch(:sha256) + end + conflicts_with "vite-plus", because: "both install vp, vpr, and vpx" def install diff --git a/docs/guide/homebrew.md b/docs/guide/homebrew.md index 8635be5cff..2f435f43ae 100644 --- a/docs/guide/homebrew.md +++ b/docs/guide/homebrew.md @@ -1,7 +1,7 @@ # Homebrew tap ::: warning Availability -The official tap is not available yet. Its formula stays disabled until the first release with per-user Homebrew setup is published. +Stable installation stays disabled until the first release with per-user Homebrew setup is published. Preview builds can be tested with the selector below. Use the [script installer](/guide/global-cli) or Homebrew core in the meantime. ::: @@ -76,3 +76,22 @@ brew uninstall voidzero-dev/vite-plus/vp ``` `brew uninstall` alone keeps user data. Each user can remove their own data with `vp implode` before the shared executable is removed. + +## Test a preview build + +Set `HOMEBREW_VP_PR_VERSION` to a PR number or full commit SHA. The PR must have a published `preview-build` that includes Homebrew tap support. + +```bash +HOMEBREW_VP_PR_VERSION="" brew install voidzero-dev/vite-plus/vp +HOMEBREW_VP_PR_VERSION="" brew test voidzero-dev/vite-plus/vp +``` + +Use `brew reinstall` instead of `brew install` to replace an installed build. Use a full SHA to keep installation and verification on the same commit. +`brew test` uses temporary user data and does not change your shell configuration. + +Homebrew downloads only the native preview package from the registry bridge and verifies its checksum. It resolves PR numbers to the latest published commit, which can differ from the current PR head. +First launch installs the matching dependencies from the bridge. An explicit `NPM_CONFIG_REGISTRY` must point to a registry that also serves those preview packages. + +The selector is specific to this tap. It does not change other Homebrew formulae, and no npm client is needed during `brew install`. +To test changes to the formula itself, first check out the PR in the tap repository. Set `HOMEBREW_NO_AUTO_UPDATE=1` for these commands to keep that checkout. +To return to a stable release once available, run `brew reinstall voidzero-dev/vite-plus/vp` without the selector. diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index b5038065d3..9a897e4de6 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -1,7 +1,7 @@ # RFC: Vite+-managed Homebrew tap -Status: Implementation proposed. The formula stays disabled until a compatible -release is published and its formula update is approved. +Status: Implementation proposed. Stable installation stays disabled until a +compatible release is published and its formula update is approved. ## Motivation @@ -270,6 +270,12 @@ merge must not trigger another product release. The updater checks the release tag for the new bootstrap and formula before removing the initial disable gate. Existing releases cannot enable the tap. +For early testing, `HOMEBREW_VP_PR_VERSION` selects a published preview by PR +number or full commit SHA. The formula downloads the native package from the +registry bridge and verifies its integrity. First use installs matching preview +dependencies. The [Homebrew guide](../docs/guide/homebrew.md#test-a-preview-build) +describes this opt-in path; normal installation continues to use GitHub Releases. + Vite+ release maintainers own the formula and its release automation through the existing repository review process. Confirm a primary maintainer and a backup before launch. Formula failures belong in this repository's issue tracker. @@ -300,6 +306,6 @@ migration guides when the tap is ready. ## Tradeoffs -The formula stays small and dependency installation shares the script installer's -code. Each user downloads and stores their own dependencies. Homebrew manages +Dependency installation shares the script installer's code. Each user downloads +and stores their own dependencies. Homebrew manages the executable; Vite+ manages the per-user JavaScript installation. From b00371927fce9bf5465b26d6aa978ffd19dc147e Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 24 Sep 2026 10:32:31 +0800 Subject: [PATCH 23/31] docs: require Homebrew formula updates for stable releases --- .claude/skills/release-manager/SKILL.md | 19 +++++++++++++++++-- .github/workflows/prepare_release.yml | 4 ++++ .github/workflows/reusable-release-build.yml | 2 ++ CONTRIBUTING.md | 2 ++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.claude/skills/release-manager/SKILL.md b/.claude/skills/release-manager/SKILL.md index 61a19ddeda..70cb92450d 100644 --- a/.claude/skills/release-manager/SKILL.md +++ b/.claude/skills/release-manager/SKILL.md @@ -25,6 +25,7 @@ When given a release PR (URL or number), do not start from step 1. First audit t - Does `main` have commits the release branch lacks? (`git log origin/release/vX.Y.Z..origin/main`, step 5) - What is CI status? (`gh pr checks `, step 5) - Already merged? Check the Release workflow (`gh run list --workflow Release --repo voidzero-dev/vite-plus`) and whether the GitHub release body is still the generated stub, then continue at step 7 or 8. +- For a published stable release, is its Homebrew formula update merged and does `HomebrewFormula/vp.rb` select that release? (steps 2 and 8) Report the detected state before making changes, so the previous release manager's work is not redone or overwritten. @@ -35,7 +36,7 @@ Before post-release work, fetch `origin/main` and read its copy of this skill (` 1. `Prepare Release` workflow bumps versions and opens the release PR (`release/vX.Y.Z` -> `main`). 2. Release manager: sync `binding/index.cjs` and the release versions in the documentation and prompts, write the changelog PR description, offer the preview-build smoke test (recommend it when the release has more than 10 commits since the previous tag), get CI green. 3. Merging the PR pushes a `packages/cli/package.json` change to `main`, which triggers `release.yml`: build, manual approval gate, npm publish, GitHub release, Docker image, Discord notification. -4. Release manager: polish the GitHub release notes, verify installs, announce. +4. Release manager: complete the Homebrew formula update for stable releases, polish the GitHub release notes, verify installs, announce. Canonical sources: `.github/workflows/prepare_release.yml`, `.github/workflows/release.yml`, `.github/workflows/publish-preview.yml`. @@ -45,7 +46,7 @@ Canonical sources: `.github/workflows/prepare_release.yml`, `.github/workflows/r gh workflow run prepare_release.yml --repo voidzero-dev/vite-plus -f version=X.Y.Z ``` -The workflow bumps `packages/cli/package.json`, `packages/core/package.json`, `packages/cli/binding/Cargo.toml`, and `crates/vp_global_cli/Cargo.toml`, refreshes `Cargo.lock`, and opens a PR titled `release: vX.Y.Z` from branch `release/vX.Y.Z`. The PR body ends with `Merging this PR will trigger the release workflow.` and that line must survive every later edit. +The workflow bumps `packages/cli/package.json`, `packages/core/package.json`, `packages/prompts/package.json`, `packages/cli/binding/Cargo.toml`, and `crates/vp_global_cli/Cargo.toml`, refreshes `Cargo.lock`, and opens a PR titled `release: vX.Y.Z` from branch `release/vX.Y.Z`. The PR body ends with `Merging this PR will trigger the release workflow.` and that line must survive every later edit. ## 2. Sync release versions (required every release) @@ -97,6 +98,14 @@ Commit these updates on the release branch with the binding sync or in a separat Only these release-version sync commits go directly on the release branch. Everything else goes through `main` (see step 5). +### Homebrew tap + +Every stable version change, including a manual bump or a changed release target, also requires an update to `HomebrewFormula/vp.rb`. Keep its current release URLs and checksums during preparation: the new archive checksums do not exist yet. + +After publication, `release.yml` runs `.github/scripts/update-homebrew-formula.mjs` and opens `release/homebrew-vX.Y.Z`. The script verifies all four release archives and matching npm packages before updating the formula. Review and merge that PR through the normal review process; an open update PR does not complete the tap release. Follow step 8 for verification and recovery. + +Prereleases and preview builds do not advance the stable formula. Test a published preview with `HOMEBREW_VP_PR_VERSION=` as described in `docs/guide/homebrew.md`. + ## 3. Write the release PR description The release tag does not exist yet, so read release files from the PR head branch and generate notes against `main`: @@ -395,6 +404,7 @@ Auto-merge being enabled is not a completed merge. Confirm `mergedAt` and the me 5. `publish-docker`: multi-arch toolchain image to `ghcr.io/voidzero-dev/vite-plus`, after npm publish (the image installs vp from npm). 6. `deploy-docs`: deploys the production docs after a stable release is published. It is skipped for prereleases. When a prerelease is published to `latest` and its notes or CLI messages link to docs that production does not serve yet, ask the release manager whether to run `gh workflow run deploy-docs.yml --ref ` once the `Release` job is publishing. Use the `vX.Y.Z` tag, or `main` while it still points at the release commit, and confirm the run's head SHA. The `vp` version that builds the docs does not need to match the release: the site and its install scripts come from the checked-out commit. The job authenticates with the `VOID_TOKEN` repository secret. When that token has expired, the job fails with `` `VOID_TOKEN` is invalid or expired `` and `discord-notify` is skipped. Ask someone with access to rotate the secret, then run `gh run rerun --failed` to rerun the docs deployment and the Discord notification. 7. `discord-notify`: announces to Discord after Docker publishing and docs deployment succeed (docs are skipped for prereleases). +8. `update-homebrew`: after a stable release is published, verifies its archives and npm packages, then opens the Homebrew formula update PR. **When the `Release` job fails partway, fix the cause, then use "Re-run failed jobs", never "Re-run all jobs".** A full rerun repeats `check`, which now sees the new version on `unpkg.com/vite-plus@latest` and skips the whole release. A failed-jobs rerun reuses `check` and the build artifacts, and every npm publish step is idempotent: `.github/scripts/publish-npm-package.ts` skips versions the registry already serves (`@X.Y.Z is already published; skipping upload.`), so only the unpublished packages upload. Confirm that from the rerun log. The rerun waits on the `release` environment again, and whoever started the rerun cannot approve it. @@ -463,6 +473,10 @@ Local clients can also mislead in the other direction. npm and pnpm cache a look 2. **Verify**: + For each stable release, confirm that `release/homebrew-vX.Y.Z` merged and `HomebrewFormula/vp.rb` has the target version in all four release URLs, with their matching checksums. If the job failed, fix the publication problem and rerun `update-homebrew`. For manual recovery, run `node .github/scripts/update-homebrew-formula.mjs X.Y.Z` from current `main` and submit the resulting formula and guide changes for review. Do not replace version strings while retaining old checksums. + + In an isolated Homebrew installation, update the tap, install or upgrade `voidzero-dev/vite-plus/vp`, and run `brew test voidzero-dev/vite-plus/vp`. Unset `HOMEBREW_VP_PR_VERSION` for stable verification. Confirm `"$(brew --prefix)/bin/vp" --version` reports the target version, so a script-install shim cannot mask the result. Keep the tap release pending if the formula update or these checks are incomplete. + ```bash npm view vite-plus version # X.Y.Z npm view @voidzero-dev/vite-plus-core version # X.Y.Z @@ -554,6 +568,7 @@ After the release ships and announcements are approved or confirmed complete, re - [ ] Smoke test offered to the release manager at both levels (local sweep and fork-PR CI), with the commit count stated and a recommendation to run it when that count is above 10; if accepted, forks synced to upstream first, preview build published, and the full ecosystem-ci catalog verified via `test-pkg-pr-new-migrate` (following TESTING.md), with every failure triaged and regressions ruled out against the previous release - [ ] CI green; any fixes landed via separate PRs to main, merged back, and added to the changelog - [ ] Release PR merged; `release` environment approved by someone other than the merger; npm + GitHub release + Docker image all published +- [ ] For a stable release, `HomebrewFormula/vp.rb` updated to the target version and verified archive checksums; formula PR merged; Homebrew installation and `brew test` verified without the preview selector - [ ] GitHub release notes polished (release manager approved before applying), retitled, and validated; Installation ends with the Docker usage block and is followed by the Create a New App section; the prerelease flag matches the version - [ ] Installs verified (npm versions + latest tag, `vp upgrade`, `vp --version` output inside the ghcr Docker image) - [ ] Announcements handed over in chat (Discord and any requested X drafts), or confirmed complete by the release manager diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml index 48dc36ed96..db7fd48e18 100644 --- a/.github/workflows/prepare_release.yml +++ b/.github/workflows/prepare_release.yml @@ -35,6 +35,8 @@ jobs: - uses: oxc-project/setup-node@1f1a5b4450c8905bd1830c3a908d22b960c4559a # main (Node before pnpm; unreleased) + # release.yml updates HomebrewFormula/vp.rb after the release archives and + # their checksums are published. Do not point the tap at unpublished assets. - name: Bump versions env: VERSION: ${{ inputs.version }} @@ -81,5 +83,7 @@ jobs: body: | Release vite-plus v${{ inputs.version }}. + For a stable release, complete the `HomebrewFormula/vp.rb` update after publication by merging the generated `release/homebrew-v${{ inputs.version }}` PR. + Merging this PR will trigger the release workflow. assignees: ${{ github.actor }} diff --git a/.github/workflows/reusable-release-build.yml b/.github/workflows/reusable-release-build.yml index aca6f03482..01d59394c5 100644 --- a/.github/workflows/reusable-release-build.yml +++ b/.github/workflows/reusable-release-build.yml @@ -72,6 +72,8 @@ jobs: # the version that gets published/installed (the release version, or # 0.0.0-commit. for preview builds). Anchored to the line-start # `version = "..."` so crate dependency versions are never touched. + # Stable Homebrew formula updates run in release.yml after publication; + # preview stamps use HOMEBREW_VP_PR_VERSION without changing that formula. for f in crates/vp_global_cli/Cargo.toml packages/cli/binding/Cargo.toml; do # shellcheck disable=SC2016 # JavaScript expands process.env.VERSION. node -e 'const fs=require("fs"),f=process.argv[1];fs.writeFileSync(f,fs.readFileSync(f,"utf8").replace(/^version = "[^"]*"/m,`version = "${process.env.VERSION}"`))' "$f" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a79a9c18a..7d06e5f442 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -194,6 +194,8 @@ The full case/step/interaction reference (including the `vpt` helper tool and mi Keep pull requests small and focused. Split changes that can be reviewed and merged independently into separate PRs. If one change depends on another, submit the dependent PR after its prerequisite has merged. +Every stable version change, including manual bumps, also requires an update to `HomebrewFormula/vp.rb`. After publication, the `update-homebrew` job verifies the release archives and npm packages, then opens `release/homebrew-vX.Y.Z` with matching URLs and checksums. Complete its review and merge before marking the tap release complete. Keep the previous formula during release preparation; prereleases and preview builds do not advance it. See the [release-manager instructions](.claude/skills/release-manager/SKILL.md#homebrew-tap) for verification and recovery. + ## Pull upstream dependencies > [!NOTE] From 25bcc558b1c251527bcf64b17961e2e304bb4976 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 24 Sep 2026 10:46:39 +0800 Subject: [PATCH 24/31] refactor: distinguish Homebrew sources and document the official tap --- .../snapshots.toml | 6 ++ .../command_self_setup_homebrew_sources.md | 46 ++++++++++++++ .../verify-refresh.mjs | 60 +++++++++++++++++++ .../vp_global_cli/src/commands/env/doctor.rs | 9 ++- crates/vp_global_cli/src/commands/implode.rs | 16 ++--- .../vp_global_cli/src/commands/upgrade/mod.rs | 36 +++++++---- crates/vp_global_cli/src/homebrew.rs | 49 ++++++++++++--- docs/guide/global-cli.md | 4 +- docs/guide/homebrew.md | 12 +--- docs/guide/implode.md | 6 +- docs/guide/upgrade.md | 8 +-- rfcs/official-homebrew-tap.md | 5 ++ 12 files changed, 207 insertions(+), 50 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_sources.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml index 1829290518..2636303980 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml @@ -1,3 +1,9 @@ +[[case]] +name = "command_self_setup_homebrew_sources" +vp = "global" +skip-platforms = ["windows"] +steps = [["node", "verify-refresh.mjs", "homebrew-sources"]] + [[case]] name = "command_self_setup_homebrew_user_dependencies" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_sources.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_sources.md new file mode 100644 index 0000000000..2642b58c80 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_sources.md @@ -0,0 +1,46 @@ +# command_self_setup_homebrew_sources + +## `node verify-refresh.mjs homebrew-sources` + +``` +core installation + CLI source Homebrew Core + CLI formula vite-plus +error: Upgrade error: Homebrew Core manages this installation. Run `brew upgrade vite-plus` to update it. + +To switch to the official Vite+ tap while keeping your settings: + + brew uninstall vite-plus + brew tap voidzero-dev/vite-plus https://github.com/voidzero-dev/vite-plus + brew install voidzero-dev/vite-plus/vp + "$(brew --prefix)/bin/vp" env setup --refresh + hash -r +info: Homebrew Core manages this installation. Run `brew outdated vite-plus` to check for updates. + +To switch to the official Vite+ tap while keeping your settings: + + brew uninstall vite-plus + brew tap voidzero-dev/vite-plus https://github.com/voidzero-dev/vite-plus + brew install voidzero-dev/vite-plus/vp + "$(brew --prefix)/bin/vp" env setup --refresh + hash -r +The Homebrew Core package remains installed. Run `brew uninstall vite-plus` to remove it. +official installation + CLI source Vite+ Homebrew tap + CLI formula voidzero-dev/vite-plus/vp +error: Upgrade error: Vite+ Homebrew tap manages this installation. Run `brew upgrade voidzero-dev/vite-plus/vp` to update it. +info: Vite+ Homebrew tap manages this installation. Run `brew outdated voidzero-dev/vite-plus/vp` to check for updates. +The Vite+ Homebrew tap package remains installed. Run `brew uninstall voidzero-dev/vite-plus/vp` to remove it. +other installation + CLI source Homebrew + CLI formula example/tools/vp +error: Upgrade error: Homebrew manages this installation. Run `brew upgrade example/tools/vp` to update it. +info: Homebrew manages this installation. Run `brew outdated example/tools/vp` to check for updates. +The Homebrew package remains installed. Run `brew uninstall example/tools/vp` to remove it. +unknown installation + CLI source Homebrew + CLI formula vite-plus +error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. +info: Homebrew manages this installation. Run `brew outdated vite-plus` to check for updates. +The Homebrew package remains installed. Run `brew uninstall vite-plus` to remove it. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs index 6699743686..f2be79c15d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs @@ -68,6 +68,62 @@ function readSettings(env) { return JSON.parse(fs.readFileSync(path.join(env.VP_HOME, 'config.json'), 'utf8')); } +function verifyHomebrewSources(source) { + for (const [label, tap, name, sourceLabel] of [ + ['core', 'homebrew/core', 'vite-plus', 'Homebrew Core'], + ['official', 'voidzero-dev/vite-plus', 'vp', 'Vite+ Homebrew tap'], + ['other', 'example/tools', 'vp', 'Homebrew'], + ['unknown', null, 'vite-plus', 'Homebrew'], + ]) { + const directory = path.resolve(`source-${label}`); + const prefix = path.join(directory, 'Cellar', name, 'test'); + const binary = createBundle(source, prefix, 'bundled CLI'); + const formula = tap && tap !== 'homebrew/core' ? `${tap}/${name}` : name; + fs.writeFileSync( + path.join(prefix, 'INSTALL_RECEIPT.json'), + JSON.stringify({ + homebrew_version: '7.0.2', + source: { tap, path: `/tap/Formula/${name}.rb` }, + }), + ); + const env = createEnvironment(directory); + run(binary, ['--help'], directory, { ...env, VP_NODE_MANAGER: 'no', VP_PM_MANAGER: 'no' }); + const invoke = (args, expectedStatus = 0) => { + const result = spawnSync(binary, args, { cwd: directory, env, encoding: 'utf8' }); + const text = (result.stdout + result.stderr).replace(/\u001b\[[0-9;]*m/g, '').trim(); + assert.equal(result.status, expectedStatus, text); + return text; + }; + console.log(`${label} installation`); + const doctor = invoke(['env', 'doctor', 'node']); + assert.ok(doctor.includes(sourceLabel), doctor); + console.log( + doctor + .split('\n') + .filter((line) => /CLI source|CLI formula/.test(line)) + .join('\n'), + ); + for (const [args, status, action] of [ + [['upgrade'], 1, 'upgrade'], + [['upgrade', '--check'], 0, 'outdated'], + ]) { + const text = invoke(args, status); + assert.ok(text.includes(`${sourceLabel} manages this installation`), text); + assert.ok(text.includes(`brew ${action} ${formula}`), text); + assert.equal(text.includes('To switch to the official Vite+ tap'), label === 'core', text); + console.log(text); + } + assert.equal(invoke(['upgrade', '--check', '--silent']), ''); + assert.ok(!invoke(['upgrade', '--silent'], 1).includes('To switch')); + const removed = invoke(['implode', '--yes']); + const notice = `The ${sourceLabel} package remains installed. Run \`brew uninstall ${formula}\` to remove it.`; + assert.ok(removed.includes(notice), removed); + assert.ok(fs.existsSync(binary)); + assert.ok(!fs.existsSync(env.VP_HOME)); + console.log(notice); + } +} + function verifyDoctor(source) { const directory = path.resolve('doctor'); const prefix = path.join(directory, 'cellar/vite-plus/0.3.2'); @@ -325,6 +381,10 @@ function main() { } const source = process.env.TEST_VP_BINARY ?? path.join(process.env.VP_HOME, 'bin/vp'); + if (action === 'homebrew-sources') { + verifyHomebrewSources(source); + return; + } if (action === 'homebrew-collision') { verifyHomebrewCollision(source); return; diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index f7e2a3dd08..98833639c4 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -95,11 +95,10 @@ pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result Result { } fn print_homebrew_uninstall_notice() { - let formula = - crate::homebrew::current().map_or("vite-plus", |homebrew| homebrew.formula.as_str()); + let Some(homebrew) = crate::homebrew::current() else { return }; output::note(&format!( - "The Homebrew package remains installed. Run `brew uninstall {formula}` to remove it.", + "The {} package remains installed. Run `brew uninstall {}` to remove it.", + homebrew.source_label(), + homebrew.formula, )); output::note( "To run `vp` again, restart your terminal or run `hash -r` in Bash. The remaining Homebrew package will start setup again.", @@ -331,10 +332,11 @@ fn confirm_implode( )); } - if crate::homebrew::owns_current_exe() { - output::warn( - "This will remove Vite+-managed data, shims, and shell entries. The Homebrew package will remain installed.", - ); + if let Some(homebrew) = crate::homebrew::current() { + output::warn(&format!( + "This will remove Vite+-managed data, shims, and shell entries. The {} package will remain installed.", + homebrew.source_label(), + )); } else { output::warn("This will completely remove vite-plus from your system!"); } diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index 21ced3bad7..2819a4ac59 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -42,22 +42,34 @@ pub async fn execute(options: UpgradeOptions) -> Result { } if let Some(homebrew) = crate::homebrew::current() { - if options.check && !options.rollback { + let check = options.check && !options.rollback; + let action = if check { "outdated" } else { "upgrade" }; + let purpose = if check { "check for updates" } else { "update it" }; + let mut message = format!( + "{} manages this installation. Run `brew {action} {}` to {purpose}.", + homebrew.source_label(), + homebrew.formula, + ); + if homebrew.source == crate::homebrew::Source::Core && !options.silent { + message.push_str(&format!( + concat!( + "\n\nTo switch to the official Vite+ tap while keeping your settings:\n\n", + " brew uninstall {}\n", + " brew tap voidzero-dev/vite-plus https://github.com/voidzero-dev/vite-plus\n", + " brew install voidzero-dev/vite-plus/vp\n", + " \"$(brew --prefix)/bin/vp\" env setup --refresh\n", + " hash -r", + ), + homebrew.formula, + )); + } + if check { if !options.silent { - output::info(&format!( - "Homebrew manages this installation. Run `brew outdated {}` to check for updates.", - homebrew.formula, - )); + output::info(&message); } return Ok(ExitStatus::default()); } - return Err(Error::Upgrade( - format!( - "Homebrew manages this installation. Run `brew upgrade {}` to update it.", - homebrew.formula - ) - .into(), - )); + return Err(Error::Upgrade(message.into())); } let config = vp_shared::EnvConfig::get(); diff --git a/crates/vp_global_cli/src/homebrew.rs b/crates/vp_global_cli/src/homebrew.rs index c6f3c0e4fa..ac797bb0db 100644 --- a/crates/vp_global_cli/src/homebrew.rs +++ b/crates/vp_global_cli/src/homebrew.rs @@ -7,6 +7,24 @@ use vt_path::AbsolutePathBuf; pub(crate) struct Installation { pub(crate) binary: AbsolutePathBuf, pub(crate) formula: String, + pub(crate) source: Source, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Source { + Core, + OfficialTap, + Other, +} + +impl Installation { + pub(crate) fn source_label(&self) -> &'static str { + match self.source { + Source::Core => "Homebrew Core", + Source::OfficialTap => "Vite+ Homebrew tap", + Source::Other => "Homebrew", + } + } } pub(crate) fn current() -> Option<&'static Installation> { @@ -55,7 +73,12 @@ fn installation(binary: &Path) -> Option { Some("homebrew/core") | None => name.to_string(), Some(tap) => format!("{tap}/{name}"), }; - Some(Installation { binary: AbsolutePathBuf::new(binary)?, formula }) + let source = match tap { + Some("homebrew/core") => Source::Core, + Some("voidzero-dev/vite-plus") if name == "vp" => Source::OfficialTap, + _ => Source::Other, + }; + Some(Installation { binary: AbsolutePathBuf::new(binary)?, formula, source }) } fn valid_component(value: &str) -> bool { @@ -102,12 +125,22 @@ mod tests { let binary = prefix.join("bin/vp"); std::fs::create_dir_all(binary.parent().unwrap()).unwrap(); std::fs::write(&binary, b"vp").unwrap(); - for (tap, path, expected) in [ - ("voidzero-dev/vite-plus", "/tap/HomebrewFormula/vp.rb", "voidzero-dev/vite-plus/vp"), - ("homebrew/core", "/tap/Formula/v/vite-plus.rb", "vite-plus"), - ("fengmk2/core", "/tap/Formula/v/vite-plus.rb", "fengmk2/core/vite-plus"), - ("bad/tap/extra", "/tap/HomebrewFormula/vp.rb", "vp"), - ("bad;command/tap", "/tap/HomebrewFormula/vp.rb", "vp"), + for (tap, path, expected, source) in [ + ( + "voidzero-dev/vite-plus", + "/tap/HomebrewFormula/vp.rb", + "voidzero-dev/vite-plus/vp", + Source::OfficialTap, + ), + ("homebrew/core", "/tap/Formula/v/vite-plus.rb", "vite-plus", Source::Core), + ( + "fengmk2/core", + "/tap/Formula/v/vite-plus.rb", + "fengmk2/core/vite-plus", + Source::Other, + ), + ("bad/tap/extra", "/tap/HomebrewFormula/vp.rb", "vp", Source::Other), + ("bad;command/tap", "/tap/HomebrewFormula/vp.rb", "vp", Source::Other), ] { std::fs::write( prefix.join("INSTALL_RECEIPT.json"), @@ -119,6 +152,7 @@ mod tests { .unwrap(); let install = installation(&binary).unwrap(); assert_eq!(install.formula, expected); + assert_eq!(install.source, source); assert_eq!(install.binary.as_path(), std::fs::canonicalize(&binary).unwrap()); } } @@ -164,6 +198,7 @@ mod tests { } std::fs::write(receipt, r#"{"homebrew_version":"7.0.2"}"#).unwrap(); assert!(owns_binary(&binary)); + assert_eq!(installation(&binary).unwrap().source, Source::Other); // A separate managed installation stays independent of Homebrew. let managed = temp.path().join("managed/0.3.2/bin/vp"); diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index d47b7c818d..a9372fe563 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -426,7 +426,7 @@ For example, `VP_VERSION=1.0.0 vp-setup.exe --version 2.0.0` installs version 2. ### Homebrew -For a Homebrew installation, the first `vp` command sets up your shell, shims, and environment-management preferences. It reuses Homebrew's binary and any bundled JavaScript. A native-only formula installs matching JavaScript dependencies in your data directory. Setup stores its completion state in your user directories and does not need write access to the Homebrew prefix. +The [official Homebrew tap](/guide/homebrew) installs the native CLI. The first `vp` command installs matching JavaScript dependencies in your data directory and sets up your shell, shims, and environment-management preferences. Setup stores its completion state in your user directories and does not need write access to the Homebrew prefix. Later commands reuse that setup while the installed binary remains unchanged. Generated shims follow Homebrew's public `vp` entrypoint when Homebrew replaces a version. Setup preserves your saved management preferences during this replacement. @@ -440,7 +440,7 @@ After setup, use `vp env off` to change this preference. Commands that need miss Use Homebrew to [upgrade](/guide/upgrade#homebrew) or [remove](/guide/implode#homebrew) its package. -`vp env doctor` identifies the Homebrew installation and its binary path. It checks the `vp` command and the user shim directory on `PATH` separately. If only the shim directory is missing, follow its shell setup instructions to enable the shims. +`vp env doctor` identifies the official tap, formula name, and binary path. It checks the `vp` command and the user shim directory on `PATH` separately. If only the shim directory is missing, follow its shell setup instructions to enable the shims. See the [official tap guide](/guide/homebrew) for availability, registry configuration, and migration instructions. diff --git a/docs/guide/homebrew.md b/docs/guide/homebrew.md index 2f435f43ae..c23fc937c6 100644 --- a/docs/guide/homebrew.md +++ b/docs/guide/homebrew.md @@ -2,7 +2,7 @@ ::: warning Availability Stable installation stays disabled until the first release with per-user Homebrew setup is published. Preview builds can be tested with the selector below. -Use the [script installer](/guide/global-cli) or Homebrew core in the meantime. +Use the [script installer](/guide/global-cli) in the meantime. ::: The official tap installs the native `vp` CLI from GitHub Releases on macOS and glibc Linux, for ARM64 and x64. @@ -40,13 +40,7 @@ Relative user-config paths resolve from your current directory. No Homebrew-spec Authentication failures stop setup. Fix the configuration and run `vp` again; setup preserves your management choices. Failure logs record the exit code without registry output, which can contain credentials. -## Switch an existing installation - -For a Homebrew core installation, remove the core formula first: - -```bash -brew uninstall vite-plus -``` +## Switch from the script installer Install the tap with the commands above. Then invoke Homebrew's executable directly, so an existing script-install shim cannot take precedence: @@ -66,7 +60,7 @@ brew upgrade voidzero-dev/vite-plus/vp ``` The first invocation of the new version installs its matching dependencies and preserves your preferences. -`vp upgrade` detects the owning formula and shows the appropriate Homebrew command. +`vp upgrade` identifies the official tap and directs you to `brew upgrade voidzero-dev/vite-plus/vp`. For complete removal, remove your user data before uninstalling the executable: diff --git a/docs/guide/implode.md b/docs/guide/implode.md index eeab087b68..4971f4dc85 100644 --- a/docs/guide/implode.md +++ b/docs/guide/implode.md @@ -26,15 +26,13 @@ vp implode --yes ## Homebrew -Run `vp implode` first to remove Vite+-managed runtimes, global packages, configuration, shims, and shell entries. Then remove the Homebrew package: +For the [official Homebrew tap](/guide/homebrew), run `vp implode` first to remove Vite+-managed runtimes, global packages, configuration, shims, and shell entries. Then remove the Homebrew package: ```bash vp implode -brew uninstall vite-plus +brew uninstall voidzero-dev/vite-plus/vp ``` -For the [official tap](/guide/homebrew), use `brew uninstall voidzero-dev/vite-plus/vp` instead. - The confirmation prompt explains that the Homebrew package will remain installed. After cleanup, `vp implode` shows the uninstall command for the owning formula. Restart your terminal before you run `vp` again. In Bash, you can run `hash -r` instead to clear cached command paths. If the Homebrew package is still installed, the next `vp` command starts first-run setup again. diff --git a/docs/guide/upgrade.md b/docs/guide/upgrade.md index 2b77307038..3d1d9224f7 100644 --- a/docs/guide/upgrade.md +++ b/docs/guide/upgrade.md @@ -87,17 +87,17 @@ Older versions are pruned automatically after each upgrade. The active version a ### Homebrew -Homebrew owns its installed binary. Update the core formula with: +The [official Homebrew tap](/guide/homebrew) owns its installed binary. Update it with: ```bash -brew upgrade vite-plus +brew upgrade voidzero-dev/vite-plus/vp ``` `vp upgrade` detects Homebrew installations and directs you to this command without downloading or installing another version. This also applies to `--force`, a specific version, and `--rollback`. Use Homebrew to manage these installations. -For the [official tap](/guide/homebrew), use `brew upgrade voidzero-dev/vite-plus/vp`. First use installs the new version's per-user dependencies. +First use installs the new version's per-user dependencies and preserves your management preferences. -`vp upgrade --check` directs you to `brew outdated` with the installed formula name. Automatic npm update checks and notices are disabled for Homebrew installations. +`vp upgrade --check` directs you to `brew outdated voidzero-dev/vite-plus/vp`. Automatic npm update checks and notices are disabled for Homebrew installations. ## Preview Builds diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index 9a897e4de6..137ef97ba3 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -237,6 +237,11 @@ the installed formula and tap. `vp upgrade` directs users to versions, force, and rollback, and keep npm update checks disabled for the CLI. `vp env doctor` reports the Homebrew executable and per-user dependency location. +Use the installation receipt to distinguish the official tap, Homebrew Core, +and other taps. Upgrade and removal notices name the owning source and formula; +Core's upgrade notice also explains how to switch to the official tap while +preserving user data. Public Homebrew guides describe the official tap. + `vp implode` removes user-managed dependencies, runtimes, settings, and shims. `brew uninstall` removes the executable. Document both steps for complete removal; the formula must not delete user data from an uninstall hook. From 194ddab7315aedb027d184d86710c8f9a681a5d7 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 24 Sep 2026 10:57:13 +0800 Subject: [PATCH 25/31] fix(ci): keep unrelated labels from canceling install previews --- .github/workflows/publish-preview.yml | 3 ++- .github/workflows/test-homebrew.yml | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-preview.yml b/.github/workflows/publish-preview.yml index 8454f6c98e..aec20f2319 100644 --- a/.github/workflows/publish-preview.yml +++ b/.github/workflows/publish-preview.yml @@ -28,7 +28,8 @@ on: types: [labeled] concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + # Other label events skip this workflow and must not cancel a preview build. + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.label.name }} cancel-in-progress: true jobs: diff --git a/.github/workflows/test-homebrew.yml b/.github/workflows/test-homebrew.yml index fd8542fa35..e2d91a71e2 100644 --- a/.github/workflows/test-homebrew.yml +++ b/.github/workflows/test-homebrew.yml @@ -22,7 +22,9 @@ on: - 'crates/vp_pm_cli/src/package_manager/bootstrap.rs' concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + # A skipped run for an unrelated label must not cancel installation tests. + group: >- + ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ github.event.action == 'labeled' && github.event.label.name != 'test: install-e2e' && github.event.label.name || 'install' }} cancel-in-progress: true defaults: From 88cd28892cdb61cbef88f79129bed19ad2af84e6 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 21 Oct 2026 11:55:23 +0800 Subject: [PATCH 26/31] refactor: reuse shared installer for Homebrew tap setup --- .../scripts/__tests__/homebrew-formula.mjs | 4 +- .github/scripts/test-homebrew.mjs | 87 +++++-- .github/scripts/update-homebrew-formula.mjs | 7 +- .github/workflows/test-homebrew.yml | 19 +- HomebrewFormula/vp.rb | 5 +- .../command_self_setup_external/archive.mjs | 23 ++ .../command_self_setup_external/homebrew.mjs | 93 ++++---- ...d_self_setup_homebrew_user_dependencies.md | 4 +- .../fixtures/download_progress/install.mjs | 29 +-- .../download_progress/peer-config.mjs | 13 +- .../installation_progress_failure.md | 2 +- .../installation_progress_interactive.md | 2 +- crates/vp_global_cli/src/homebrew.rs | 13 +- crates/vp_global_cli/src/self_setup.rs | 9 +- crates/vp_pm_cli/src/lib.rs | 14 +- crates/vp_pm_cli/src/package_manager.rs | 12 +- .../src/package_manager/bootstrap.rs | 221 +++--------------- crates/vp_pm_cli/src/request.rs | 5 +- crates/vp_setup/src/install.rs | 73 ++++-- docs/guide/homebrew.md | 9 +- rfcs/official-homebrew-tap.md | 48 ++-- 21 files changed, 343 insertions(+), 349 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/archive.mjs diff --git a/.github/scripts/__tests__/homebrew-formula.mjs b/.github/scripts/__tests__/homebrew-formula.mjs index cfc7710c5e..1e15851136 100644 --- a/.github/scripts/__tests__/homebrew-formula.mjs +++ b/.github/scripts/__tests__/homebrew-formula.mjs @@ -38,7 +38,7 @@ await test('release publication requires the compatible tag and each verified ar ); await assert.rejects( verifyRelease(version, release, async (url) => - url.includes('bootstrap.rs') ? new Response('', { status: 404 }) : download(url), + url.endsWith('HomebrewFormula/vp.rb') ? new Response('', { status: 404 }) : download(url), ), /Download failed/, ); @@ -74,7 +74,7 @@ await test('updates preserve recipe changes and reset only a previous version re assert.ok(!result.includes('disable!')); assert.ok(!result.includes('revision 2')); assert.ok(result.includes('conflicts_with "vite-plus"')); - assert.ok(result.includes(' def install\n bin.install "vp"')); + assert.ok(result.includes(' def install\n libexec.install "vp", "vp-install-lock.yaml"')); assert.ok(result.includes('ENV["HOMEBREW_VP_PR_VERSION"].presence')); assert.ok(result.includes('Preview.resolve(preview_ref, platform)')); const sameVersion = result.replace(' license', ' revision 1\n license'); diff --git a/.github/scripts/test-homebrew.mjs b/.github/scripts/test-homebrew.mjs index f272d76303..35e74e3591 100644 --- a/.github/scripts/test-homebrew.mjs +++ b/.github/scripts/test-homebrew.mjs @@ -10,6 +10,11 @@ import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parseArgs } from 'node:util'; +import { + generateInstallLockfile, + INSTALL_LOCKFILE, + INSTALL_PNPM_VERSION, +} from '../../packages/cli/generate-install-lockfile.ts'; import { targets, updateFormula } from './update-homebrew-formula.mjs'; const { values } = parseArgs({ @@ -59,17 +64,30 @@ async function run(command, args, env = baseEnv, cwd = root, expected = 0) { } async function registry(packagesDir) { - const args = [path.join(repository, 'packages/tools/src/local-npm-registry.ts'), '--serve']; - if (packagesDir) { - args.push('--packages-dir', packagesDir); + const script = path.join(repository, 'packages/tools/src/local-npm-registry.ts'); + if (!packagesDir) { + packagesDir = path.join(root, 'packed-checkout'); + await run(process.execPath, [script, '--pack-to', packagesDir]); } + // Standard npm tarball paths keep the release lockfile portable across registries. + const normalized = await fs.mkdtemp(path.join(root, 'registry-packages-')); + for (const archive of await fs.readdir(packagesDir)) { + if (!archive.endsWith('.tgz')) { + continue; + } + const file = path.join(packagesDir, archive); + const manifest = JSON.parse(await run('tar', ['-xOf', file, 'package/package.json'])); + const name = `${manifest.name.split('/').at(-1)}-${manifest.version}.tgz`; + await fs.copyFile(file, path.join(normalized, name)); + } + const args = [script, '--serve', '--packages-dir', normalized]; const child = spawn(process.execPath, args, { env: baseEnv, cwd: root, stdio: ['ignore', 'pipe', 'pipe'], }); children.push(child); - return await new Promise((resolve, reject) => { + const upstream = await new Promise((resolve, reject) => { let output = ''; const timer = setTimeout( () => reject(new Error(`Registry failed to start: ${output}`)), @@ -92,25 +110,21 @@ async function registry(packagesDir) { } }); }); + return (await mirrorRegistry(upstream)).url; } -// Emulate an enterprise registry. Credentials terminate here and are never sent upstream. -async function authenticatedRegistry(upstream) { +// Keep tarballs on the mirror so pnpm generates registry-independent lockfile entries. +// The private mirror also checks credentials without forwarding them upstream. +async function mirrorRegistry(upstream, authenticated = false) { const requests = new Set(); const server = createServer(async (request, response) => { - if (request.headers.authorization !== `Bearer ${token}`) { + if (authenticated && request.headers.authorization !== `Bearer ${token}`) { response.writeHead(401).end('Unauthorized'); return; } requests.add(request.url); try { - const remote = request.url.startsWith('/tarball/') - ? decodeURIComponent(request.url.slice('/tarball/'.length)) - : upstream + request.url; - assert.ok( - [new URL(upstream).origin, 'https://registry.npmjs.org'].includes(new URL(remote).origin), - ); - const result = await fetch(remote, { + const result = await fetch(upstream + request.url, { headers: { accept: request.headers.accept ?? '*/*' }, signal: AbortSignal.timeout(120_000), }); @@ -118,11 +132,16 @@ async function authenticatedRegistry(upstream) { const contentType = result.headers.get('content-type') ?? ''; response.setHeader('content-type', contentType); if (contentType.includes('json')) { - const text = JSON.stringify(await result.json(), (key, value) => - key === 'tarball' && typeof value === 'string' - ? `${url}/tarball/${encodeURIComponent(value)}` - : value, - ); + const text = JSON.stringify(await result.json(), (key, value) => { + if (key !== 'tarball' || typeof value !== 'string') { + return value; + } + const remote = new URL(value); + assert.ok( + [new URL(upstream).origin, 'https://registry.npmjs.org'].includes(remote.origin), + ); + return url + remote.pathname; + }); response.end(text); } else { response.end(Buffer.from(await result.arrayBuffer())); @@ -178,8 +197,21 @@ async function formulaFor(binary, value, registryUrl, revision = false) { await fs.mkdir(payload); await fs.copyFile(path.resolve(binary), path.join(payload, 'vp')); await fs.chmod(path.join(payload, 'vp'), 0o755); + const lockfile = await generateInstallLockfile( + value, + registryUrl, + async (directory, args, env) => { + await run( + 'npm', + ['exec', '--yes', `--package=pnpm@${INSTALL_PNPM_VERSION}`, '--', 'pnpm', ...args], + { ...baseEnv, ...env, HOME: root, CI: 'true' }, + directory, + ); + }, + ); + await fs.writeFile(path.join(payload, INSTALL_LOCKFILE), lockfile); const archive = `${payload}.tar.gz`; - await run('tar', ['-czf', archive, '-C', payload, 'vp']); + await run('tar', ['-czf', archive, '-C', payload, 'vp', INSTALL_LOCKFILE]); const sha256 = createHash('sha256') .update(await fs.readFile(archive)) .digest('hex'); @@ -206,7 +238,7 @@ async function formulaFor(binary, value, registryUrl, revision = false) { try { const upstream = await registry(values['packages-dir'] && path.resolve(values['packages-dir'])); - const privateRegistry = await authenticatedRegistry(upstream); + const privateRegistry = await mirrorRegistry(upstream, true); await run('git', ['clone', '--depth', '1', values['brew-source'], prefix]); const brewHome = path.join(root, 'brew-home'); const brewTemp = path.join(root, 'brew-tmp'); @@ -294,6 +326,7 @@ try { await fs.writeFile(path.join(project, 'src.js'), "document.body.textContent = 'Homebrew';\n"); await fs.chmod(oldKeg, 0o555); await fs.chmod(path.join(oldKeg, 'bin'), 0o555); + await fs.chmod(path.join(oldKeg, 'libexec'), 0o555); await Promise.all([ run(publicVp, ['--help'], first, project), run(publicVp, ['--help'], first, project), @@ -309,6 +342,17 @@ try { ); assert.ok(!(await fs.readdir(path.join(oldKeg, 'bin'))).includes('.vp-setup-complete')); assert.ok(!(await fs.readdir(first.VP_HOME)).includes('current')); + const publishedLockfile = await fs.readFile(path.join(oldKeg, 'libexec', INSTALL_LOCKFILE)); + for (const env of [first, second]) { + assert.deepEqual( + await fs.readFile(path.join(packageDir(env), 'bin', INSTALL_LOCKFILE)), + publishedLockfile, + ); + assert.deepEqual( + await fs.readFile(path.join(packageDir(env), 'node_modules/.vp-install-complete')), + createHash('sha256').update(publishedLockfile).digest(), + ); + } const settings = await fs.readFile(path.join(first.VP_HOME, 'config.json'), 'utf8'); for (const env of [first, second]) { for (const variable of ['VP_NODE_MANAGER', 'VP_PM_MANAGER', 'VP_PNPM_MANAGER']) { @@ -375,6 +419,7 @@ try { } await fs.chmod(oldKeg, 0o755); await fs.chmod(path.join(oldKeg, 'bin'), 0o755); + await fs.chmod(path.join(oldKeg, 'libexec'), 0o755); const tap = (await run(brew, ['--repository', 'viteplus/e2e'], brewEnv)).trim(); await fs.writeFile( path.join(tap, 'HomebrewFormula/vp.rb'), diff --git a/.github/scripts/update-homebrew-formula.mjs b/.github/scripts/update-homebrew-formula.mjs index db6a7d3898..572f37947d 100644 --- a/.github/scripts/update-homebrew-formula.mjs +++ b/.github/scripts/update-homebrew-formula.mjs @@ -92,11 +92,8 @@ export async function verifyRelease(version, release, fetchImpl = fetch) { assert.ok(response.ok, `Download failed: ${url} (HTTP ${response.status})`); return response; }; - // Old tags do not contain the authenticated bootstrap or the bare Homebrew runtime. - // Check the release tag, never main, before enabling installation. - await get( - `https://raw.githubusercontent.com/${repository}/v${version}/crates/vp_pm_cli/src/package_manager/bootstrap.rs`, - ); + // The formula is added with the per-user Homebrew runtime. Check the release + // tag, never main, before enabling installation. await get(`https://raw.githubusercontent.com/${repository}/v${version}/HomebrewFormula/vp.rb`); const checksumUrl = `https://github.com/${repository}/releases/download/v${version}/vp-checksums.txt`; const assets = releaseAssets(version, await (await get(checksumUrl)).text()); diff --git a/.github/workflows/test-homebrew.yml b/.github/workflows/test-homebrew.yml index e2d91a71e2..9c0dace057 100644 --- a/.github/workflows/test-homebrew.yml +++ b/.github/workflows/test-homebrew.yml @@ -19,7 +19,7 @@ on: - 'crates/vp_global_cli/src/self_setup*' - 'crates/vp_global_cli/src/self_setup/**' - 'crates/vp_global_cli/src/homebrew.rs' - - 'crates/vp_pm_cli/src/package_manager/bootstrap.rs' + - 'crates/vp_pm_cli/**' concurrency: # A skipped run for an unrelated label must not cancel installation tests. @@ -47,9 +47,9 @@ jobs: with: persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: '24.18.0' + node-version-file: .node-version package-manager-cache: false - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 @@ -67,7 +67,7 @@ jobs: - 'crates/vp_global_cli/src/self_setup*' - 'crates/vp_global_cli/src/self_setup/**' - 'crates/vp_global_cli/src/homebrew.rs' - - 'crates/vp_pm_cli/src/package_manager/bootstrap.rs' + - 'crates/vp_pm_cli/**' - name: Check formula publication rules run: node --test .github/scripts/__tests__/homebrew-formula.mjs @@ -101,15 +101,16 @@ jobs: persist-credentials: false - uses: ./.github/actions/clone - - uses: oxc-project/setup-rust@68c3199c5339f965e6e163924c3c450773eba42b # main (pending v1.0.17) + - uses: ./.github/actions/setup-rust with: save-cache: ${{ github.ref_name == 'main' }} - cache-key: homebrew-${{ matrix.target }} + cache-key: homebrew-${{ matrix.target }}-ci - - uses: oxc-project/setup-node@1f1a5b4450c8905bd1830c3a908d22b960c4559a # main (Node before pnpm; unreleased) + - uses: ./.github/actions/setup-ci-node - uses: ./.github/actions/build-upstream with: + profile: ci target: ${{ matrix.target }} - name: Pack the current checkout @@ -131,8 +132,8 @@ jobs: fs.writeFileSync(file, source.replace(/^version = "[^"]+"/m, `version = "${next}"`)); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${next}\n`); JS - cargo build --release -p vp_global_cli --target "$TARGET" - cp "${CARGO_TARGET_DIR:-target}/$TARGET/release/vp" "$RUNNER_TEMP/vp-replacement" + cargo build --profile ci -p vp_global_cli --target "$TARGET" + cp "${CARGO_TARGET_DIR:-target}/$TARGET/ci/vp" "$RUNNER_TEMP/vp-replacement" git restore -- Cargo.lock crates/vp_global_cli/Cargo.toml - name: Check authentication, retries, and external ownership diff --git a/HomebrewFormula/vp.rb b/HomebrewFormula/vp.rb index 76e95b5f0c..d8b26cc9b1 100644 --- a/HomebrewFormula/vp.rb +++ b/HomebrewFormula/vp.rb @@ -116,7 +116,8 @@ def self.resolve(ref, platform) conflicts_with "vite-plus", because: "both install vp, vpr, and vpx" def install - bin.install "vp" + libexec.install "vp", "vp-install-lock.yaml" + bin.install_symlink libexec/"vp" bin.install_symlink "vp" => "vpr" bin.install_symlink "vp" => "vpx" end @@ -125,7 +126,7 @@ def caveats <<~EOS Run vp to install your dependencies and configure your shell. First use needs access to the Node.js download service and your npm registry. - npm and pnpm read ~/.npmrc or NPM_CONFIG_USERCONFIG, including registry credentials. + vp and pnpm read ~/.npmrc or NPM_CONFIG_USERCONFIG, including registry credentials. EOS end diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/archive.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/archive.mjs new file mode 100644 index 0000000000..32d16431c2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/archive.mjs @@ -0,0 +1,23 @@ +import { gzipSync } from 'node:zlib'; + +/** + * Create a small, valid archive to keep download tests offline. + * @param {Record} files + * @returns {Buffer} + */ +export function createArchive(files) { + const blocks = []; + for (const [name, contents] of Object.entries(files)) { + const body = Buffer.from(contents); + const header = Buffer.alloc(512); + header.write(name); + header.write('0000755\0', 100); + header.write(body.length.toString(8).padStart(11, '0') + '\0', 124); + header.fill(' ', 148, 156); + header.write('0', 156); + const checksum = header.reduce((sum, byte) => sum + byte, 0); + header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148); + blocks.push(header, body, Buffer.alloc((512 - (body.length % 512)) % 512)); + } + return gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs index f6ea580903..6605413c99 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs @@ -1,11 +1,13 @@ import assert from 'node:assert/strict'; -import { spawn, spawnSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { once } from 'node:events'; import fs from 'node:fs'; import { createServer } from 'node:http'; import path from 'node:path'; +import { createArchive } from './archive.mjs'; + const root = path.resolve('homebrew-test'); const source = process.env.TEST_VP_BINARY ?? fs.realpathSync(path.join(process.env.VP_HOME, 'bin/vp')); @@ -20,7 +22,8 @@ const originalEnv = Object.fromEntries( Object.entries(process.env).filter(([key]) => !/^(VP_|XDG_|npm_|NPM_|PNPM_)/.test(key)), ); const token = 'synthetic-homebrew-token'; -const pin = '10.33.0'; +const pin = '10.34.7'; +const lockfile = "lockfileVersion: '9.0'\n"; fs.writeFileSync( path.join(root, 'package.json'), JSON.stringify({ name: 'homebrew-fixture', packageManager: `pnpm@${pin}` }), @@ -43,19 +46,12 @@ async function run(binary, args, env, cwd = root, status = 0) { return { stdout, stderr }; } -const packageRoot = path.join(root, 'fake-pnpm'); -fs.mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); -fs.writeFileSync( - path.join(packageRoot, 'package.json'), - JSON.stringify({ - name: 'pnpm', - version: pin, - bin: { pnpm: 'bin/pnpm.cjs', pnpx: 'bin/pnpx.cjs' }, - }), -); const pnpmCode = ` const fs = require('node:fs'); + const assert = require('node:assert/strict'); if (process.argv.includes('install')) { + assert.ok(process.argv.includes('--frozen-lockfile')); + assert.equal(fs.readFileSync('pnpm-lock.yaml', 'utf8'), ${JSON.stringify(lockfile)}); const version = JSON.parse(fs.readFileSync('package.json')).dependencies['vite-plus']; fetch(process.env.TEST_REGISTRY + '/installed').then(response => { if (!response.ok) { console.error(process.env.NPM_TOKEN); process.exit(17); } @@ -65,23 +61,16 @@ const pnpmCode = ` }); } else { console.log('pinned pnpm tool'); } `; -for (const name of ['pnpm', 'pnpx']) - fs.writeFileSync(path.join(packageRoot, 'bin', `${name}.cjs`), pnpmCode); -const packEnv = { ...originalEnv, HOME: root, npm_config_cache: path.join(root, 'pack-cache') }; -const packed = spawnSync( - node, - [ - path.join(npmRoot, 'bin/npm-cli.js'), - 'pack', - packageRoot, - '--ignore-scripts', - '--json', - '--workspaces=false', - ], - { cwd: root, env: packEnv, encoding: 'utf8' }, -); -assert.equal(packed.status, 0, packed.stderr); -const archive = fs.readFileSync(path.join(root, JSON.parse(packed.stdout)[0].filename)); +const archive = createArchive({ + 'package/package.json': JSON.stringify({ + name: 'pnpm', + version: pin, + engines: { node: '>=18.12' }, + bin: { pnpm: 'bin/pnpm.cjs', pnpx: 'bin/pnpx.cjs' }, + }), + 'package/bin/pnpm.cjs': pnpmCode, + 'package/bin/pnpx.cjs': pnpmCode, +}); const integrity = 'sha512-' + createHash('sha512').update(archive).digest('base64'); let downloads = 0, installs = 0, @@ -97,18 +86,12 @@ const server = createServer((request, response) => { } if (request.headers.authorization !== authorization) return response.writeHead(401).end('Unauthorized'); - if (request.url === '/registry/pnpm') { + if (request.url === `/registry/pnpm/${pin}`) { return response.end( JSON.stringify({ name: 'pnpm', - 'dist-tags': { latest: pin }, - versions: { - [pin]: { - name: 'pnpm', - version: pin, - dist: { tarball: `${url}/registry/custom-pnpm.tgz`, integrity }, - }, - }, + version: pin, + dist: { tarball: `${url}/registry/custom-pnpm.tgz`, integrity }, }), ); } @@ -126,8 +109,11 @@ const publicVp = path.join(root, 'brew/bin/vp'); function keg(version) { const prefix = path.join(root, 'brew/Cellar/vp', version); fs.mkdirSync(path.join(prefix, 'bin'), { recursive: true }); - fs.copyFileSync(source, path.join(prefix, 'bin/vp')); - fs.chmodSync(path.join(prefix, 'bin/vp'), 0o555); + fs.mkdirSync(path.join(prefix, 'libexec')); + fs.copyFileSync(source, path.join(prefix, 'libexec/vp')); + fs.writeFileSync(path.join(prefix, 'libexec/vp-install-lock.yaml'), lockfile); + fs.chmodSync(path.join(prefix, 'libexec/vp'), 0o555); + fs.symlinkSync('../libexec/vp', path.join(prefix, 'bin/vp')); fs.writeFileSync( path.join(prefix, 'INSTALL_RECEIPT.json'), JSON.stringify({ @@ -136,6 +122,7 @@ function keg(version) { }), ); fs.chmodSync(path.join(prefix, 'bin'), 0o555); + fs.chmodSync(path.join(prefix, 'libexec'), 0o555); fs.chmodSync(prefix, 0o555); return prefix; } @@ -185,6 +172,7 @@ function userPackage(env) { function unlock(prefix) { fs.chmodSync(prefix, 0o755); fs.chmodSync(path.join(prefix, 'bin'), 0o755); + fs.chmodSync(path.join(prefix, 'libexec'), 0o755); } try { @@ -207,6 +195,17 @@ try { assert.ok(retry.every((result) => !result.stderr.includes('manage Node'))); assert.equal(fs.readFileSync(settingsFile, 'utf8'), preferences); assert.ok(!fs.existsSync(path.join(old, 'bin/.vp-setup-complete'))); + assert.equal( + fs.readFileSync(path.join(userPackage(env), 'bin/vp-install-lock.yaml'), 'utf8'), + lockfile, + ); + assert.equal( + fs + .readFileSync(path.join(userPackage(env), 'node_modules/.vp-install-complete')) + .toString('hex'), + createHash('sha256').update(lockfile).digest('hex'), + ); + console.log('First use copies the release lockfile and records a successful frozen install.'); assert.ok(!fs.existsSync(path.join(env.VP_HOME, 'current'))); assert.equal(fs.readlinkSync(path.join(env.VP_HOME, 'bin/vp')), publicVp); assert.equal(fs.readlinkSync(path.join(env.VP_HOME, 'fallback-bin/npm')), publicVp); @@ -235,6 +234,16 @@ try { assert.equal(installs, 2); console.log('CLI, Node, npm, pn, and pnx shims survive Cellar replacement and removal.'); + fs.writeFileSync(path.join(userPackage(env), 'pnpm-lock.yaml'), 'interrupted resolution'); + rejectInstall = true; + await run(publicVp, ['--help'], { ...env, VP_SELF_SETUP_REPLACE_EXISTING: '1' }, root, 1); + assert.ok(!fs.existsSync(path.join(userPackage(env), '.vp-deps-complete'))); + rejectInstall = false; + await run(publicVp, ['--help'], env); + assert.equal(installs, 4); + assert.equal(fs.readFileSync(settingsFile, 'utf8'), preferences); + console.log('A failed frozen refresh invalidates completion and retries with saved preferences.'); + const upgrade = await run(publicVp, ['upgrade'], env, root, 1); assert.ok(upgrade.stderr.includes('brew upgrade voidzero-dev/vite-plus/vp')); const outdated = await run(publicVp, ['upgrade', '--check'], env); @@ -264,7 +273,7 @@ try { const globalShim = path.join(secondUser.VP_HOME, 'bin/global-tool'); fs.symlinkSync(previous, globalShim); await run(publicVp, ['--help'], secondUser); - assert.equal(installs, 3); + assert.equal(installs, 5); assert.notEqual(userPackage(env), userPackage(secondUser)); assert.equal(fs.readlinkSync(globalShim), publicVp); assert.ok(fs.existsSync(metadata) && fs.existsSync(previous)); @@ -303,7 +312,7 @@ try { await run(publicVp, ['--help'], basic); authorization = `Bearer ${token}`; console.log( - 'Registry overrides, relative user configuration, and basic authentication work with bundled npm.', + 'Registry overrides, relative user configuration, and basic authentication work with the shared downloader.', ); await run(publicVp, ['--help'], environment('unauthenticated', false), root, 1); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md index 14b370ff7b..3ec2783bdd 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md @@ -3,10 +3,12 @@ ## `node homebrew.mjs` ``` +First use copies the release lockfile and records a successful frozen install. Authenticated first use retries once, preserves preferences, and serializes concurrent setup. CLI, Node, npm, pn, and pnx shims survive Cellar replacement and removal. +A failed frozen refresh invalidates completion and retries with saved preferences. Ownership commands use the tap name; migration preserves global packages and refreshes their shims. User installations and cleanup stay independent. -Registry overrides, relative user configuration, and basic authentication work with bundled npm. +Registry overrides, relative user configuration, and basic authentication work with the shared downloader. Missing authentication and corrupted pnpm tarballs stop setup. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs index bcde7ed9ed..eec1553fc8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { randomBytes } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; import { once } from 'node:events'; import { copyFileSync, @@ -33,22 +33,6 @@ const nodeBin = join(home, 'js_runtime/node/99.0.0/bin'); for (const directory of [nodeBin, user, external]) mkdirSync(directory, { recursive: true }); // Use the runner's real Node for the mock pnpm program, without a network download. symlinkSync(process.execPath, join(nodeBin, 'node')); -// Model Node's bundled npm while retaining deterministic download checkpoints. -const npmBin = join(nodeBin, '../lib/node_modules/npm/bin'); -mkdirSync(npmBin, { recursive: true }); -writeFileSync( - join(npmBin, 'npm-cli.js'), - ` - const fs = require('node:fs'); - fetch(process.env.npm_config_registry + '/pnpm/-/pnpm-10.33.0.tgz') - .then(response => response.arrayBuffer()) - .then(data => { - const filename = 'pnpm-10.33.0.tgz'; - fs.writeFileSync(filename, Buffer.from(data)); - console.log(JSON.stringify([{ name: 'pnpm', version: '10.33.0', filename }])); - }); -`, -); copyFileSync(join(process.env.VP_HOME, 'bin/vp'), join(external, 'vp')); const lockfile = "lockfileVersion: '9.0'\n"; writeFileSync(join(external, 'vp-install-lock.yaml'), lockfile); @@ -95,6 +79,17 @@ async function handleRequest(request, response) { if (request.url === '/index.json') { await checkpoint('prepare'); response.end(JSON.stringify([{ version: 'v99.0.0', lts: 'Fixture' }])); + } else if (request.url === '/pnpm/10.34.7') { + response.end( + JSON.stringify({ + name: 'pnpm', + version: '10.34.7', + dist: { + tarball: `${mirror}/pnpm/-/pnpm-10.34.7.tgz`, + integrity: 'sha512-' + createHash('sha512').update(pnpmArchive).digest('base64'), + }, + }), + ); } else if (/^\/pnpm\/-\/pnpm-[\d.]+\.tgz$/.test(request.url)) { await checkpoint('download'); response.setHeader('Content-Length', pnpmArchive.length); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/peer-config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/peer-config.mjs index 8a412a4504..25a1df62ad 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/peer-config.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/peer-config.mjs @@ -226,7 +226,16 @@ async function testBypass(lockfile) { for (const value of [undefined, '', '0', 'true']) { await assert.rejects( runSetup(installation, { VP_SETUP_SKIP_INSTALL_LOCKFILE: value }), - (error) => error.code === 1 && /ERR_PNPM_BROKEN_LOCKFILE/.test(error.message), + (error) => + error.code === 1 && /Failed to install production dependencies/.test(error.message), + ); + assert.equal( + readFileSync(join(installation.home, version, 'bin/vp-install-lock.yaml'), 'utf8'), + brokenLockfile, + ); + assert.equal( + existsSync(join(installation.home, version, 'node_modules/.vp-install-complete')), + false, ); } console.log('A broken lockfile still fails unless VP_SETUP_SKIP_INSTALL_LOCKFILE is exactly 1.'); @@ -315,7 +324,7 @@ async function testBypass(lockfile) { // A fresh offline store makes resolution fail without any external requests or retries. await assert.rejects( runSetup(failed, { ...override, npm_config_offline: 'true', NPM_CONFIG_OFFLINE: 'true' }), - (error) => error.code === 1 && /ERR_PNPM_NO_OFFLINE_META/.test(error.message), + (error) => error.code === 1 && /Failed to install production dependencies/.test(error.message), ); assert.equal(existsSync(failedReceipt), false); assert.equal(existsSync(join(failedTarget, 'bin/.vp-setup-complete')), false); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md index d80a6d2f1c..8ad305ea43 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md @@ -7,7 +7,7 @@ ``` Before installation: preserve this output. info: installing vite-plus@... -⠿ Preparing Node.js and pnpm... +⠿ Downloading pnpm ... B ( B/s) ``` **← write-key:** `enter` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md index e593b21856..ee36674471 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md @@ -7,7 +7,7 @@ ``` Before installation: preserve this output. info: installing vite-plus@... -⠿ Preparing Node.js and pnpm... +⠿ Downloading pnpm ... B ( B/s) ``` **← write-key:** `enter` diff --git a/crates/vp_global_cli/src/homebrew.rs b/crates/vp_global_cli/src/homebrew.rs index ac797bb0db..05ffee0963 100644 --- a/crates/vp_global_cli/src/homebrew.rs +++ b/crates/vp_global_cli/src/homebrew.rs @@ -41,8 +41,10 @@ pub(crate) fn owns_current_exe() -> bool { fn installation(binary: &Path) -> Option { // Resolve both Homebrew's public entrypoint and Vite+'s generated shims. let binary = std::fs::canonicalize(binary).ok()?; - let bin = binary.parent().filter(|bin| bin.file_name().is_some_and(|name| name == "bin"))?; - let prefix = bin.parent()?; + let executable_dir = binary.parent().filter(|directory| { + directory.file_name().is_some_and(|name| name == "bin" || name == "libexec") + })?; + let prefix = executable_dir.parent()?; let data = std::fs::read(prefix.join("INSTALL_RECEIPT.json")).ok()?; let receipt = serde_json::from_slice::(&data).ok()?; if receipt.get("homebrew_version").and_then(serde_json::Value::as_str).is_none_or(str::is_empty) @@ -163,8 +165,9 @@ mod tests { let root = tempfile::tempdir().unwrap(); for version in ["old", "new"] { let prefix = root.path().join("Cellar/vp").join(version); - std::fs::create_dir_all(prefix.join("bin")).unwrap(); - std::fs::write(prefix.join("bin/vp"), version).unwrap(); + let directory = if version == "old" { "bin" } else { "libexec" }; + std::fs::create_dir_all(prefix.join(directory)).unwrap(); + std::fs::write(prefix.join(directory).join("vp"), version).unwrap(); std::fs::write(prefix.join("INSTALL_RECEIPT.json"), br#"{"homebrew_version":"7.0.2"}"#) .unwrap(); } @@ -174,7 +177,7 @@ mod tests { let old = installation(&public).unwrap(); let stable = old.public_binary().unwrap(); std::fs::remove_file(&public).unwrap(); - std::os::unix::fs::symlink("../Cellar/vp/new/bin/vp", &public).unwrap(); + std::os::unix::fs::symlink("../Cellar/vp/new/libexec/vp", &public).unwrap(); std::fs::remove_dir_all(root.path().join("Cellar/vp/old")).unwrap(); assert_eq!(std::fs::read_to_string(&stable).unwrap(), "new"); assert_eq!(installation(&public).unwrap().public_binary().unwrap(), stable); diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index 0713566161..aa8bcc2713 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -251,7 +251,7 @@ async fn run( let version_dir = if let Some(package) = user_package { // Save choices before network work so a failed install does not ask again. save_management_preferences(node_override, default_mode).await?; - tokio::fs::create_dir_all(package).await?; + tokio::fs::create_dir_all(package.join("bin")).await?; package.to_absolute_path_buf() } else if deploy { let name = @@ -296,6 +296,13 @@ async fn run( let node_version = if !skip_deps && (deploy || needs_deps) { runtime::resolve().await? } else { None }; if needs_deps { + if let Some(package) = user_package { + match tokio::fs::remove_file(package.join(external::PACKAGE_MARKER)).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } output::info(&format!("installing vite-plus@{version}...")); install::generate_wrapper_package_json(&version_dir, version).await?; if !skip_deps { diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index c76297b198..da773ac5e8 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -27,13 +27,13 @@ pub use error::Error; pub use lifecycle_env::LifecycleEnvContext; pub use package_manager::{ EnvironmentPackageManagerResolution, PackageManager, PackageManagerBuilder, - PackageManagerResolution, PackageManagerSource, PackageManagerType, bootstrap_pnpm, configure_npm_command, download_package_manager, - download_package_manager_for_cwd, ensure_package_manager_bin, fetch_package_manager_versions, - fetch_package_manager_versions_for_cwd, get_package_manager_type_and_version, - is_package_manager_dist_tag, package_manager_bin_path, package_manager_install_dir, - resolve_environment_package_manager, resolve_environment_package_manager_spec, - resolve_package_manager_from_package_json, resolve_package_manager_version, - resolve_package_manager_version_for_cwd, + PackageManagerResolution, PackageManagerSource, PackageManagerType, bootstrap_pnpm, + download_package_manager, download_package_manager_for_cwd, ensure_package_manager_bin, + fetch_package_manager_versions, fetch_package_manager_versions_for_cwd, + get_package_manager_type_and_version, is_package_manager_dist_tag, package_manager_bin_path, + package_manager_install_dir, resolve_environment_package_manager, + resolve_environment_package_manager_spec, resolve_package_manager_from_package_json, + resolve_package_manager_version, resolve_package_manager_version_for_cwd, }; pub use request::HttpClient; pub use resolution::{ diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index d49af6bcab..da059b19b3 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -1,8 +1,6 @@ #![allow(clippy::disallowed_macros)] #![cfg_attr(test, allow(clippy::print_stdout))] -mod bootstrap; - use std::{ collections::HashMap, env, fmt, @@ -12,7 +10,6 @@ use std::{ time::Duration, }; -pub use bootstrap::{bootstrap_pnpm, configure_npm_command}; use semver::Version; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -31,6 +28,9 @@ use crate::{ shim, }; +mod bootstrap; +pub use bootstrap::bootstrap_pnpm; + #[derive(Serialize, Deserialize, Clone, Default)] #[serde(rename_all = "camelCase")] struct PackageJson { @@ -1362,6 +1362,7 @@ async fn download_package_manager_with_config( version_or_latest, expected_hash, npm_config, + None, ) .await?; #[cfg(windows)] @@ -1420,6 +1421,7 @@ async fn download_package_manager_inner( version_or_latest: &str, expected_hash: Option<&str>, npm_config: &NpmConfig, + tarball_url: Option<&str>, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let version = resolve_package_manager_version_with_config( cwd, @@ -1468,7 +1470,9 @@ async fn download_package_manager_inner( .await; } - let tgz_url = npm_config.package_tgz_url(&package_name, &version); + let tgz_url = tarball_url + .map(Str::from) + .unwrap_or_else(|| npm_config.package_tgz_url(&package_name, &version)); // /package_manager/pnpm/10.0.0 let target_dir = data_dir.join("package_manager").join(&bin_name).join(&version); let install_dir = target_dir.join(&bin_name); diff --git a/crates/vp_pm_cli/src/package_manager/bootstrap.rs b/crates/vp_pm_cli/src/package_manager/bootstrap.rs index d79d5e5ba6..c35605e9f6 100644 --- a/crates/vp_pm_cli/src/package_manager/bootstrap.rs +++ b/crates/vp_pm_cli/src/package_manager/bootstrap.rs @@ -1,67 +1,31 @@ -//! Fetch the setup pnpm with Node's npm so registry authentication follows npmrc. - -use std::path::Component; +//! Bootstrap setup's pinned pnpm through the shared authenticated downloader. use serde::Deserialize; -use tokio::process::Command; use vp_error::Error; -use vp_shared::{EnvConfig, env_vars}; -use vt_path::{AbsolutePath, AbsolutePathBuf}; -use vt_str::Str; +use vt_path::AbsolutePathBuf; use super::{ - PackageManagerType, create_shim_files, find_extracted_package_dir, - is_package_manager_install_complete, open_lock_file, remove_dir_all_force, + PackageManagerType, download_package_manager_inner, is_package_manager_install_complete, + package_manager_install_dir, }; - -/// Keep npm and pnpm on the same explicit registry and user config when their -/// working directory differs from the caller's. Credentials stay in npm's -/// normal configuration and inherited environment, never in command arguments. -pub fn configure_npm_command(command: &mut Command, registry: Option<&str>) -> Result<(), Error> { - if let Some(registry) = registry { - command.env(env_vars::NPM_CONFIG_REGISTRY, registry); - command.env(env_vars::NPM_CONFIG_REGISTRY_UPPER, registry); - } - if let Some(path) = std::env::var_os("npm_config_userconfig") - .or_else(|| std::env::var_os("NPM_CONFIG_USERCONFIG")) - .filter(|path| !path.is_empty()) - { - let path = vt_path::current_dir()?.join(path); - command.env("npm_config_userconfig", path.as_path()); - command.env("NPM_CONFIG_USERCONFIG", path.as_path()); - } - Ok(()) -} +use crate::{config::NpmConfig, request::HttpClient}; #[derive(Deserialize)] -struct PackedPackage { - name: Str, - version: Str, - filename: Str, +struct Metadata { + name: String, + version: String, + dist: Distribution, } -fn packed_filename(output: &[u8], version: &str) -> Result { - let packages: Vec = serde_json::from_slice(output)?; - let [package] = packages.as_slice() else { - return Err(Error::InvalidArgument("npm pack must return one pnpm archive".into())); - }; - let mut components = std::path::Path::new(package.filename.as_str()).components(); - if package.name != "pnpm" - || package.version != version - || !matches!(components.next(), Some(Component::Normal(_))) - || components.next().is_some() - || !package.filename.ends_with(".tgz") - { - return Err(Error::InvalidArgument("npm pack returned an unexpected pnpm archive".into())); - } - Ok(package.filename.clone()) +#[derive(Deserialize)] +struct Distribution { + tarball: String, + integrity: String, } -/// Bootstrap a pinned JavaScript pnpm package with the npm included in a managed -/// Node distribution. Reuse the normal cache and lock so later vp commands see -/// the same completed package-manager installation. +/// Keep the normal cache, authentication, extraction, and installation lock. +/// Metadata supplies the registry's tarball URL and integrity for the exact pin. pub async fn bootstrap_pnpm( - node: &AbsolutePath, version: &str, registry: Option<&str>, ) -> Result { @@ -72,146 +36,33 @@ pub async fn bootstrap_pnpm( "pnpm bootstrap requires a JavaScript pnpm release".into(), )); } - let target = EnvConfig::get().dirs.data.join("package_manager/pnpm").join(version); - let install = target.join("pnpm"); - let complete = || { - Ok::<_, Error>( - is_package_manager_install_complete(&install, "pnpm")? - && install.join("bin/pnpm.cjs").as_path().is_file(), - ) - }; - if complete()? { - return Ok(install); - } - - let parent = target.parent().expect("pnpm cache has a parent"); - tokio::fs::create_dir_all(parent).await?; - let lock_path = parent.join(vt_str::format!("{version}.lock")); - // File::lock can block; keep it off the async executor during concurrent setup. - let _lock = tokio::task::spawn_blocking(move || { - let file = open_lock_file(lock_path.as_path())?; - file.lock()?; - Ok::<_, std::io::Error>(file) - }) - .await??; - if complete()? { + let install = package_manager_install_dir(PackageManagerType::Pnpm, version) + .expect("pnpm has an install directory"); + if is_package_manager_install_complete(&install, "pnpm")? + && install.join("bin/pnpm.cjs").as_path().is_file() + { return Ok(install); } - let temporary = tempfile::tempdir_in(parent)?; - let work = AbsolutePathBuf::new(temporary.path().to_path_buf()).expect("absolute tempdir"); - let node_bin = - node.parent().ok_or_else(|| Error::CannotFindBinaryPath("Node directory".into()))?; - let npm = node_bin.join(if cfg!(windows) { - "node_modules/npm/bin/npm-cli.js" - } else { - "../lib/node_modules/npm/bin/npm-cli.js" - }); - if !npm.as_path().is_file() { - return Err(Error::CannotFindBinaryPath( - "npm bundled with the managed Node.js runtime".into(), - )); + let mut config = NpmConfig::load(); + if let Some(registry) = registry { + config.values.insert("registry".to_string(), registry.to_string()); } - // Establish a package root even if the user chose a data directory under a workspace. - tokio::fs::write(work.join("package.json"), br#"{"name":"vp-bootstrap","private":true}"#) + let metadata: Metadata = HttpClient::with_npm_config(3, 500, config.clone()) + .get_json(&config.package_version_url("pnpm", version)) .await?; - let mut command = Command::new(node.as_path()); - command - .arg(npm.as_path()) - .args([ - "pack", - &vt_str::format!("pnpm@{version}"), - "--ignore-scripts", - "--json", - "--workspaces=false", - "--logs-max=0", - ]) - .current_dir(&work); - configure_npm_command(&mut command, registry)?; - let output = command.output().await?; - if !output.status.success() { - // Registry error bodies can contain credentials. Do not log subprocess output. - return Err(Error::InvalidArgument(vt_str::format!( - "Failed to download pnpm {version} (exit code {}). Check your npm registry and authentication configuration.", - vp_shared::exit_code_from_status(output.status) - ))); - } - let archive = work.join(packed_filename(&output.stdout, version)?); - let extracted = work.join("extracted"); - let destination = extracted.clone(); - tokio::task::spawn_blocking(move || crate::request::extract_tgz(&archive, &destination)) - .await??; - let package = find_extracted_package_dir(extracted.as_path())?; - let staged = work.join("installation"); - tokio::fs::create_dir(&staged).await?; - tokio::fs::rename(package, staged.join("pnpm")).await?; - let staged_install = staged.join("pnpm"); - create_shim_files(PackageManagerType::Pnpm, staged_install.join("bin")).await?; - if !is_package_manager_install_complete(&staged_install, "pnpm")? - || !staged_install.join("bin/pnpm.cjs").as_path().is_file() + if metadata.name != "pnpm" || metadata.version != version || metadata.dist.integrity.is_empty() { - return Err(Error::CannotFindBinaryPath("pnpm bootstrap archive has no CLI".into())); + return Err(Error::InvalidArgument("Registry returned an unexpected pnpm package".into())); } - remove_dir_all_force(&target).await?; - tokio::fs::rename(staged, &target).await?; + let (install, _, _) = download_package_manager_inner( + None, + PackageManagerType::Pnpm, + version, + Some(&metadata.dist.integrity), + &config, + Some(&metadata.dist.tarball), + ) + .await?; Ok(install) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rejects_wrong_versions_and_archive_paths() { - let output = |name: &str, version: &str, filename: &str| { - serde_json::to_vec( - &serde_json::json!([{ "name": name, "version": version, "filename": filename }]), - ) - .unwrap() - }; - assert_eq!( - packed_filename(&output("pnpm", "10.33.0", "pnpm-10.33.0.tgz"), "10.33.0").unwrap(), - "pnpm-10.33.0.tgz" - ); - for (name, version, filename) in [ - ("npm", "10.33.0", "pnpm.tgz"), - ("pnpm", "10.32.0", "pnpm.tgz"), - ("pnpm", "10.33.0", "../pnpm.tgz"), - ("pnpm", "10.33.0", "/pnpm.tgz"), - ("pnpm", "10.33.0", "dir/pnpm.tgz"), - ("pnpm", "10.33.0", ""), - ] { - assert!(packed_filename(&output(name, version, filename), "10.33.0").is_err()); - } - assert!(packed_filename(b"[]", "10.33.0").is_err()); - assert!(packed_filename(b"invalid JSON", "10.33.0").is_err()); - } - - #[test] - fn keeps_explicit_userconfig_relative_to_the_caller() { - EnvConfig::with_vars( - [ - ("npm_config_userconfig", None), - ("NPM_CONFIG_USERCONFIG", Some("config/enterprise.npmrc")), - ], - |_| { - let mut command = Command::new("node"); - configure_npm_command(&mut command, Some("https://registry.example.test/")) - .unwrap(); - let expected = vt_path::current_dir().unwrap().join("config/enterprise.npmrc"); - for (name, value) in command.as_std().get_envs() { - match name.to_str().unwrap() { - "npm_config_userconfig" | "NPM_CONFIG_USERCONFIG" => { - assert_eq!(value.unwrap(), expected.as_path()) - } - "npm_config_registry" | "NPM_CONFIG_REGISTRY" => { - assert_eq!(value.unwrap(), "https://registry.example.test/") - } - other => panic!("unexpected environment setting {other}"), - } - } - assert_eq!(command.as_std().get_args().len(), 0); - }, - ); - } -} diff --git a/crates/vp_pm_cli/src/request.rs b/crates/vp_pm_cli/src/request.rs index 5501f23b87..0d690fe8e4 100644 --- a/crates/vp_pm_cli/src/request.rs +++ b/crates/vp_pm_cli/src/request.rs @@ -389,10 +389,7 @@ impl HttpClient { } } -pub(crate) fn extract_tgz( - tgz_file: impl AsRef, - target_dir: impl AsRef, -) -> Result<(), Error> { +fn extract_tgz(tgz_file: impl AsRef, target_dir: impl AsRef) -> Result<(), Error> { let tgz_file = tgz_file.as_ref(); let target_dir = target_dir.as_ref(); tracing::debug!("Extract tgz: {:?} to {:?}", tgz_file, target_dir); diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 3e82104808..4f486573e9 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -14,7 +14,7 @@ use std::{ use flate2::read::GzDecoder; use tar::Archive; use vp_js_runtime::{JsRuntimeType, NodeProvider, download_runtime}; -use vp_pm_cli::{PackageManagerType, configure_npm_command, download_package_manager}; +use vp_pm_cli::bootstrap_pnpm; use vp_shared::EnvConfig; use vt_path::{AbsolutePath, AbsolutePathBuf}; @@ -221,7 +221,7 @@ pub async fn install_production_deps( let (node_runtime, pnpm_entry) = vp_shared::progress::with_spinner( show_progress.then_some("Preparing Node.js and pnpm..."), - prepare_install_runtime(preferred_node_version), + prepare_install_runtime(preferred_node_version, registry), ) .await?; let output = vp_shared::progress::with_spinner( @@ -281,15 +281,11 @@ async fn dependency_install_args<'a>( async fn prepare_install_runtime( preferred_node_version: Option<&str>, + registry: Option<&str>, ) -> Result<(vp_js_runtime::JsRuntime, AbsolutePathBuf), Error> { - let (pnpm_dir, _, _) = - download_package_manager(PackageManagerType::Pnpm, PINNED_PNPM_VERSION, None) - .await - .map_err(|error| { - Error::Setup( - format!("Failed to install pnpm {PINNED_PNPM_VERSION}: {error}").into(), - ) - })?; + let pnpm_dir = bootstrap_pnpm(PINNED_PNPM_VERSION, registry).await.map_err(|error| { + Error::Setup(format!("Failed to install pnpm {PINNED_PNPM_VERSION}: {error}").into()) + })?; let pnpm_entry = pnpm_dir.join("bin").join("pnpm.cjs"); if !tokio::fs::try_exists(&pnpm_entry).await.unwrap_or(false) { return Err(Error::Setup( @@ -332,6 +328,27 @@ fn supports_pnpm_node(package: &serde_json::Value, version: &str) -> bool { range.satisfies(&version) } +/// Keep pnpm's registry and user config aligned with the native downloader when +/// its working directory differs from the caller's. +fn configure_pnpm_command( + command: &mut tokio::process::Command, + registry: Option<&str>, +) -> Result<(), Error> { + if let Some(registry) = registry { + command.env(vp_shared::env_vars::NPM_CONFIG_REGISTRY, registry); + command.env(vp_shared::env_vars::NPM_CONFIG_REGISTRY_UPPER, registry); + } + if let Some(path) = std::env::var_os("npm_config_userconfig") + .or_else(|| std::env::var_os("NPM_CONFIG_USERCONFIG")) + .filter(|path| !path.is_empty()) + { + let path = vt_path::current_dir()?.join(path); + command.env("npm_config_userconfig", path.as_path()); + command.env("NPM_CONFIG_USERCONFIG", path.as_path()); + } + Ok(()) +} + async fn run_pnpm_install( version_dir: &AbsolutePath, node_runtime: &vp_js_runtime::JsRuntime, @@ -363,8 +380,7 @@ async fn run_pnpm_install( .env("NPM_CONFIG_PEERS_SUFFIX_MAX_LENGTH", "1000"); } - configure_npm_command(&mut cmd, registry) - .map_err(|error| Error::Setup(error.to_string().into()))?; + configure_pnpm_command(&mut cmd, registry)?; let output = cmd.output().await?; Ok(output) @@ -678,6 +694,34 @@ pub async fn create_env_files(install_dir: &AbsolutePath) -> Result<(), Error> { mod tests { use super::*; + #[test] + fn keeps_explicit_userconfig_relative_to_the_caller() { + EnvConfig::with_vars( + [ + ("npm_config_userconfig", None), + ("NPM_CONFIG_USERCONFIG", Some("config/enterprise.npmrc")), + ], + |_| { + let mut command = tokio::process::Command::new("node"); + configure_pnpm_command(&mut command, Some("https://registry.example.test/")) + .unwrap(); + let expected = vt_path::current_dir().unwrap().join("config/enterprise.npmrc"); + for (name, value) in command.as_std().get_envs() { + match name.to_str().unwrap() { + "npm_config_userconfig" | "NPM_CONFIG_USERCONFIG" => { + assert_eq!(value.unwrap(), expected.as_path()) + } + "npm_config_registry" | "NPM_CONFIG_REGISTRY" => { + assert_eq!(value.unwrap(), "https://registry.example.test/") + } + other => panic!("unexpected environment setting {other}"), + } + } + assert_eq!(command.as_std().get_args().len(), 0); + }, + ); + } + #[tokio::test] async fn bootstrap_reuses_project_node_and_only_resolves_lts_for_incompatible_pnpm() { use vp_js_runtime::{JsRuntimeProvider, Platform}; @@ -721,13 +765,14 @@ mod tests { tokio::fs::write(binary, "").await.unwrap(); } - let (runtime, entry) = prepare_install_runtime(Some("22.23.1")).await.unwrap(); + let (runtime, entry) = + prepare_install_runtime(Some("22.23.1"), None).await.unwrap(); assert_eq!(runtime.version(), "22.23.1"); assert_eq!(entry, pnpm.join("bin/pnpm.cjs")); index.assert_calls(0); tokio::fs::write(manifest, r#"{"engines":{"node":">=24"}}"#).await.unwrap(); - let (runtime, _) = prepare_install_runtime(Some("22.23.1")).await.unwrap(); + let (runtime, _) = prepare_install_runtime(Some("22.23.1"), None).await.unwrap(); assert_eq!(runtime.version(), "24.11.0"); index.assert_calls(1); }, diff --git a/docs/guide/homebrew.md b/docs/guide/homebrew.md index c23fc937c6..d03064496b 100644 --- a/docs/guide/homebrew.md +++ b/docs/guide/homebrew.md @@ -5,7 +5,7 @@ Stable installation stays disabled until the first release with per-user Homebre Use the [script installer](/guide/global-cli) in the meantime. ::: -The official tap installs the native `vp` CLI from GitHub Releases on macOS and glibc Linux, for ARM64 and x64. +The official tap installs the native `vp` CLI and its dependency lockfile from GitHub Releases on macOS and glibc Linux, for ARM64 and x64. Homebrew needs no Node.js, pnpm, or npm registry access to install it. When the tap becomes available, run: @@ -18,7 +18,8 @@ vp The explicit repository URL is required. The formula is part of the Vite+ repository. -On first use, `vp` downloads Node.js LTS, pinned pnpm, and matching CLI dependencies. +On first use, `vp` prepares Node.js and pinned pnpm, then installs matching CLI dependencies from the published lockfile. +It uses the same runtime selection and lockfile recovery options as the [script installer](/guide/global-cli#installation-variables). It stores dependencies in `/cli-packages//` and sets up your preferences and shims. `` comes from the [Vite+ directory configuration](/guide/global-cli#installation-variables). The native executable stays under Homebrew's control. Later invocations reuse the completed setup. @@ -33,12 +34,12 @@ registry=https://registry.example.com/repository/npm/ //registry.example.com/repository/npm/:_authToken=${NPM_TOKEN} ``` -Export `NPM_TOKEN` before running `vp`. Both npm, which downloads pnpm, and pnpm, which installs dependencies, use this configuration. +Export `NPM_TOKEN` before running `vp`. The native pnpm downloader and pnpm dependency installation both use this configuration. You can select another file with `NPM_CONFIG_USERCONFIG` or override the registry with `NPM_CONFIG_REGISTRY`. Relative user-config paths resolve from your current directory. No Homebrew-specific npm variables are required. Authentication failures stop setup. Fix the configuration and run `vp` again; setup preserves your management choices. -Failure logs record the exit code without registry output, which can contain credentials. +If pnpm fails, setup reports its exit code and omits registry output, which can contain credentials. ## Switch from the script installer diff --git a/rfcs/official-homebrew-tap.md b/rfcs/official-homebrew-tap.md index 137ef97ba3..c467720233 100644 --- a/rfcs/official-homebrew-tap.md +++ b/rfcs/official-homebrew-tap.md @@ -33,8 +33,8 @@ The explicit repository URL is required because Homebrew otherwise looks for it from core's `vite-plus` formula and `vp` alias. Homebrew downloads the target's [GitHub release archive](https://github.com/voidzero-dev/vite-plus/releases), -verifies its pinned SHA-256 checksum, and installs `vp` with `vpr` and `vpx` -aliases. The formula needs no npm access, Node.js, or pnpm during installation. +verifies its pinned SHA-256 checksum, and installs `vp` and its published dependency lockfile under `libexec`. +Public `vp`, `vpr`, and `vpx` links point to that binary. The formula needs no npm access, Node.js, or pnpm during installation. On first use, `vp` installs the matching `vite-plus` npm package and its production dependencies into the user's data directory. It then configures the @@ -77,9 +77,12 @@ and links the public commands. It creates no per-user Vite+ installation. / ├── Cellar/vp/A/ │ ├── bin/ -│ │ ├── vp +│ │ ├── vp -> ../libexec/vp │ │ ├── vpr -> vp │ │ └── vpx -> vp +│ ├── libexec/ +│ │ ├── vp +│ │ └── vp-install-lock.yaml │ └── INSTALL_RECEIPT.json └── bin/ ├── vp -> ../Cellar/vp/A/bin/vp @@ -89,9 +92,10 @@ and links the public commands. It creates no per-user Vite+ installation. ### 3. Prepare dependencies on first use -The first `vp` invocation prepares Node.js and uses its bundled npm to download -the pinned pnpm package. Then pnpm installs `vite-plus@A` and production -dependencies in the user's data directory, keyed by CLI version and platform. +The first `vp` invocation uses the shared authenticated downloader for pinned +pnpm and prepares a compatible Node.js runtime. It copies `vp-install-lock.yaml` +into the user directory and installs `vite-plus@A` with `--frozen-lockfile`. +Dependencies are keyed by CLI version and platform. Setup saves management choices before downloads so a failed attempt can retry without asking again. The Homebrew files stay unchanged. @@ -104,6 +108,7 @@ without asking again. The Homebrew files stay unchanged. │ └── lib/node_modules/npm/ # included in the Node.js distribution ├── package_manager/pnpm//pnpm/ └── cli-packages/A// + ├── bin/vp-install-lock.yaml # copy of the published lockfile ├── package.json ├── pnpm-lock.yaml └── node_modules/ @@ -137,7 +142,7 @@ Node.js and system-first npm: └── .json ``` -The executable remains at `/Cellar/vp/A/bin/vp` and loads JavaScript from +The executable remains at `/Cellar/vp/A/libexec/vp` and loads JavaScript from `/cli-packages/A//node_modules/vite-plus`. Later invocations reuse these files. The generated environment follows the existing [directory layout](directory-layout.md): `` comes first on `PATH`, and @@ -151,7 +156,8 @@ continue to point through those links, even after Homebrew removes version `A`. ```text /Cellar/vp/B/ -├── bin/ # new vp, vpr, vpx +├── bin/ # public vp, vpr, vpx links +├── libexec/ # new vp and vp-install-lock.yaml └── INSTALL_RECEIPT.json /bin/ @@ -166,6 +172,7 @@ applicable. ```text /cli-packages/B// +├── bin/vp-install-lock.yaml ├── package.json ├── pnpm-lock.yaml └── node_modules/ @@ -200,7 +207,7 @@ command. Setup must not write into the Cellar. ## Registry configuration -The user runs `vp` outside Homebrew's build sandbox. Both npm and pnpm read +The user runs `vp` outside Homebrew's build sandbox. The native downloader and pnpm read `~/.npmrc`, or the file selected by `NPM_CONFIG_USERCONFIG`, and inherit registry and token environment variables. Use [npm-compatible authentication](https://docs.npmjs.com/cli/v11/configuring-npm/npmrc/) at both stages, including registry-scoped tokens and basic authentication: @@ -214,19 +221,16 @@ Users export `NPM_TOKEN` before running `vp`. They can also use `NPM_CONFIG_REGISTRY` for a registry override. No Homebrew-specific npm variables or Homebrew npm downloader are required. -Change the shared [dependency installer](../crates/vp_setup/src/install.rs) to -fetch pinned pnpm with Node's bundled npm. Invoke Node and `npm-cli.js` by their -absolute paths. Use [`npm pack`](https://docs.npmjs.com/cli/v11/commands/npm-pack/) -with an exact version, `--ignore-scripts`, `--json`, and `--workspaces=false` in -a temporary directory. npm handles registry metadata, tarball URLs, integrity, -and credentials. Reuse the existing pnpm cache layout and installation lock. -Then use that pnpm for dependency installation. +Reuse the shared [authenticated downloader](../crates/vp_pm_cli/src/package_manager.rs) +for pnpm. A small setup adapter reads the exact version's metadata and supplies +its tarball URL and integrity hash to the existing download, cache, and lock code. +No npm subprocess is needed. -Run both stages outside the calling project's workspace. Resolve an explicit -relative `NPM_CONFIG_USERCONFIG` before changing directories, and pass the same -registry override to both stages. Keep credentials out of command arguments, -logs, and receipts. Authentication failures must stop setup without public -registry fallback. pnpm-only credential helpers are outside the initial scope. +Use the shared [dependency installer](../crates/vp_setup/src/install.rs) for +runtime selection, frozen installation, and the explicit lockfile recovery option. +Resolve relative `NPM_CONFIG_USERCONFIG` before pnpm changes directories. Keep +credentials out of command arguments, logs, and receipts. Authentication failures +stop setup without a public-registry fallback. ## Commands and migration @@ -272,7 +276,7 @@ checksums. A failed update leaves the previous formula available. Use a formula `revision` for recipe fixes and a new release for CLI changes. A formula-only merge must not trigger another product release. -The updater checks the release tag for the new bootstrap and formula before +The updater checks the release tag for the tap formula before removing the initial disable gate. Existing releases cannot enable the tap. For early testing, `HOMEBREW_VP_PR_VERSION` selects a published preview by PR From 4f0e16c35e84b883e106272a55b4e2ab3ee50ada Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 21 Oct 2026 11:56:41 +0800 Subject: [PATCH 27/31] fix(ci): scope Homebrew release token permissions --- .github/workflows/release.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ba91706f6..8ca93629c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -356,9 +356,9 @@ jobs: ref: main persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: '24.18.0' + node-version-file: .node-version package-manager-cache: false - name: Check publication and update formula @@ -371,6 +371,8 @@ jobs: with: client-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: From 9d180d2272b8b5c11ba8f3e7518e174614b8503c Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 21 Oct 2026 14:17:44 +0800 Subject: [PATCH 28/31] fix: expand npm user config paths before installer handoff --- .../command_self_setup_external/homebrew.mjs | 29 +++++-- ...d_self_setup_homebrew_user_dependencies.md | 3 +- crates/vp_pm_cli/src/config.rs | 13 +++- crates/vp_pm_cli/src/lib.rs | 4 +- crates/vp_setup/src/install.rs | 78 ++++++++++++------- 5 files changed, 85 insertions(+), 42 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs index 6605413c99..9c22ecadd7 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs @@ -50,6 +50,11 @@ const pnpmCode = ` const fs = require('node:fs'); const assert = require('node:assert/strict'); if (process.argv.includes('install')) { + if (process.env.TEST_USERCONFIG) { + assert.equal(process.env.npm_config_userconfig, process.env.TEST_USERCONFIG); + assert.equal(process.env.NPM_CONFIG_USERCONFIG, process.env.TEST_USERCONFIG); + assert.ok(fs.readFileSync(process.env.npm_config_userconfig, 'utf8').includes('registry=' + process.env.TEST_REGISTRY + '/registry/')); + } assert.ok(process.argv.includes('--frozen-lockfile')); assert.equal(fs.readFileSync('pnpm-lock.yaml', 'utf8'), ${JSON.stringify(lockfile)}); const version = JSON.parse(fs.readFileSync('package.json')).dependencies['vite-plus']; @@ -296,11 +301,21 @@ try { override.NPM_CONFIG_REGISTRY = `${url}/registry/`; await run(publicVp, ['--help'], override); - const userconfig = environment('relative-userconfig'); - const userconfigFile = path.join(userconfig.HOME, 'enterprise.npmrc'); - fs.renameSync(path.join(userconfig.HOME, '.npmrc'), userconfigFile); - userconfig.NPM_CONFIG_USERCONFIG = path.relative(root, userconfigFile); - await run(publicVp, ['--help'], userconfig); + for (const [name, selector] of [ + ['relative', null], + ['tilde', '~/enterprise.npmrc'], + ['environment', '${HOME}/enterprise.npmrc'], + ]) { + const userconfig = environment(`${name}-userconfig`); + const userconfigFile = path.join(userconfig.HOME, 'enterprise.npmrc'); + fs.renameSync(path.join(userconfig.HOME, '.npmrc'), userconfigFile); + userconfig.NPM_CONFIG_USERCONFIG = selector ?? path.relative(root, userconfigFile); + userconfig.TEST_USERCONFIG = userconfigFile; + await run(publicVp, ['--help'], userconfig); + } + console.log( + 'Relative, tilde, and environment-variable selectors reach pnpm as the same absolute user config.', + ); const basic = environment('basic-auth'); const encoded = Buffer.from('fixture:synthetic-password').toString('base64'); @@ -311,9 +326,7 @@ try { authorization = `Basic ${encoded}`; await run(publicVp, ['--help'], basic); authorization = `Bearer ${token}`; - console.log( - 'Registry overrides, relative user configuration, and basic authentication work with the shared downloader.', - ); + console.log('Registry overrides and basic authentication work with the shared downloader.'); await run(publicVp, ['--help'], environment('unauthenticated', false), root, 1); corrupt = true; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md index 3ec2783bdd..bb54bc86df 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md @@ -9,6 +9,7 @@ CLI, Node, npm, pn, and pnx shims survive Cellar replacement and removal. A failed frozen refresh invalidates completion and retries with saved preferences. Ownership commands use the tap name; migration preserves global packages and refreshes their shims. User installations and cleanup stay independent. -Registry overrides, relative user configuration, and basic authentication work with the shared downloader. +Relative, tilde, and environment-variable selectors reach pnpm as the same absolute user config. +Registry overrides and basic authentication work with the shared downloader. Missing authentication and corrupted pnpm tarballs stop setup. ``` diff --git a/crates/vp_pm_cli/src/config.rs b/crates/vp_pm_cli/src/config.rs index 12365402c7..5168e82c66 100644 --- a/crates/vp_pm_cli/src/config.rs +++ b/crates/vp_pm_cli/src/config.rs @@ -49,10 +49,8 @@ impl NpmConfig { if let Some(root) = project_root { load_npmrc(root.join(".npmrc"), &mut project_values); } - let user_config = env_value("userconfig") - .map(|path| expand_value(&path)) - .or_else(|| project_values.get("userconfig").cloned()) - .map(|path| config_path(&path, cwd)) + let user_config = npm_user_config_path(cwd) + .or_else(|| project_values.get("userconfig").map(|path| config_path(path, cwd))) .unwrap_or_else(|| EnvConfig::get().user_home.join(".npmrc").into_path_buf()); let mut user_values = HashMap::new(); load_npmrc(user_config, &mut user_values); @@ -374,6 +372,13 @@ pub fn npm_registry_for_scope(cwd: &AbsolutePath, scope: Option<&str>) -> String NpmConfig::load_for_cwd(cwd).registry_for_scope(scope) } +/// Resolve an explicit `NPM_CONFIG_USERCONFIG` selector with npm variable and home expansion. +/// When supplied, `cwd` anchors relative paths; otherwise they remain relative. +#[must_use] +pub fn npm_user_config_path(cwd: Option<&AbsolutePath>) -> Option { + env_value("userconfig").map(|path| config_path(&expand_value(&path), cwd)) +} + /// Resolve the `Authorization` header value npm would send to `url` from the npm config at `cwd`. #[must_use] pub fn npm_authorization_for_url(cwd: &AbsolutePath, url: &str) -> Option { diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index da773ac5e8..7e891cfeff 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -18,7 +18,9 @@ pub(crate) mod resolution; mod shim; pub use cli::{ManagedGlobalCommand, PackageManagerCommand, PmCommand}; -pub use config::{npm_authorization_for_url, npm_registry, npm_registry_for_scope}; +pub use config::{ + npm_authorization_for_url, npm_registry, npm_registry_for_scope, npm_user_config_path, +}; pub use dispatch::{ DispatchResult, dispatch, dispatch_with_metadata, dispatch_with_package_manager, dispatch_with_resolved_package_manager, diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 4f486573e9..2b322d91bb 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -14,7 +14,7 @@ use std::{ use flate2::read::GzDecoder; use tar::Archive; use vp_js_runtime::{JsRuntimeType, NodeProvider, download_runtime}; -use vp_pm_cli::bootstrap_pnpm; +use vp_pm_cli::{bootstrap_pnpm, npm_user_config_path}; use vp_shared::EnvConfig; use vt_path::{AbsolutePath, AbsolutePathBuf}; @@ -338,10 +338,7 @@ fn configure_pnpm_command( command.env(vp_shared::env_vars::NPM_CONFIG_REGISTRY, registry); command.env(vp_shared::env_vars::NPM_CONFIG_REGISTRY_UPPER, registry); } - if let Some(path) = std::env::var_os("npm_config_userconfig") - .or_else(|| std::env::var_os("NPM_CONFIG_USERCONFIG")) - .filter(|path| !path.is_empty()) - { + if let Some(path) = npm_user_config_path(None) { let path = vt_path::current_dir()?.join(path); command.env("npm_config_userconfig", path.as_path()); command.env("NPM_CONFIG_USERCONFIG", path.as_path()); @@ -695,31 +692,56 @@ mod tests { use super::*; #[test] - fn keeps_explicit_userconfig_relative_to_the_caller() { - EnvConfig::with_vars( - [ - ("npm_config_userconfig", None), - ("NPM_CONFIG_USERCONFIG", Some("config/enterprise.npmrc")), - ], - |_| { - let mut command = tokio::process::Command::new("node"); - configure_pnpm_command(&mut command, Some("https://registry.example.test/")) - .unwrap(); - let expected = vt_path::current_dir().unwrap().join("config/enterprise.npmrc"); - for (name, value) in command.as_std().get_envs() { - match name.to_str().unwrap() { - "npm_config_userconfig" | "NPM_CONFIG_USERCONFIG" => { - assert_eq!(value.unwrap(), expected.as_path()) + fn expands_userconfig_before_resolving_against_the_caller() { + let home = tempfile::tempdir().unwrap(); + let relative = vt_path::current_dir().unwrap().join("config/enterprise.npmrc"); + let cases = [ + ("config/enterprise.npmrc", relative.as_path().to_path_buf()), + ("~/enterprise.npmrc", home.path().join("enterprise.npmrc")), + ("${HOME}/enterprise.npmrc", home.path().join("enterprise.npmrc")), + ]; + for (selector, expected) in cases { + for variable in ["npm_config_userconfig", "NPM_CONFIG_USERCONFIG"] { + EnvConfig::with_vars( + [ + ("HOME", home.path().to_str()), + ("USERPROFILE", home.path().to_str()), + ( + "npm_config_userconfig", + (variable == "npm_config_userconfig").then_some(selector), + ), + ( + "NPM_CONFIG_USERCONFIG", + (variable == "NPM_CONFIG_USERCONFIG").then_some(selector), + ), + ], + |_| { + let mut command = tokio::process::Command::new("node"); + configure_pnpm_command( + &mut command, + Some("https://registry.example.test/"), + ) + .unwrap(); + let values: std::collections::BTreeMap<_, _> = + command.as_std().get_envs().collect(); + for name in ["npm_config_userconfig", "NPM_CONFIG_USERCONFIG"] { + assert_eq!( + values[std::ffi::OsStr::new(name)].unwrap(), + expected.as_os_str(), + "{variable}={selector}" + ); } - "npm_config_registry" | "NPM_CONFIG_REGISTRY" => { - assert_eq!(value.unwrap(), "https://registry.example.test/") + for name in ["npm_config_registry", "NPM_CONFIG_REGISTRY"] { + assert_eq!( + values[std::ffi::OsStr::new(name)].unwrap(), + "https://registry.example.test/" + ); } - other => panic!("unexpected environment setting {other}"), - } - } - assert_eq!(command.as_std().get_args().len(), 0); - }, - ); + assert_eq!(command.as_std().get_args().len(), 0); + }, + ); + } + } } #[tokio::test] From d8aa6489c7b2f1b6a379379548fe22bdf288deae Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 23 Oct 2026 21:14:41 +0900 Subject: [PATCH 29/31] fix: expand registry overrides during pnpm bootstrap --- .../command_self_setup_external/homebrew.mjs | 23 ++++++++++++------- crates/vp_pm_cli/src/config.rs | 4 ++++ .../src/package_manager/bootstrap.rs | 2 +- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs index 9c22ecadd7..7c1bd33063 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs @@ -292,14 +292,21 @@ try { ); console.log('User installations and cleanup stay independent.'); - const override = environment('registry-override'); - const config = path.join(override.HOME, '.npmrc'); - fs.writeFileSync( - config, - fs.readFileSync(config, 'utf8').replace(`${url}/registry/`, 'http://127.0.0.1:1/unavailable/'), - ); - override.NPM_CONFIG_REGISTRY = `${url}/registry/`; - await run(publicVp, ['--help'], override); + for (const [name, registry] of [ + ['direct', `${url}/registry/`], + ['interpolated', '${TEST_REGISTRY}/registry/'], + ]) { + const override = environment(`${name}-registry-override`); + const config = path.join(override.HOME, '.npmrc'); + fs.writeFileSync( + config, + fs + .readFileSync(config, 'utf8') + .replace(`${url}/registry/`, 'http://127.0.0.1:1/unavailable/'), + ); + override.NPM_CONFIG_REGISTRY = registry; + await run(publicVp, ['--help'], override); + } for (const [name, selector] of [ ['relative', null], diff --git a/crates/vp_pm_cli/src/config.rs b/crates/vp_pm_cli/src/config.rs index 5168e82c66..5a22578ffc 100644 --- a/crates/vp_pm_cli/src/config.rs +++ b/crates/vp_pm_cli/src/config.rs @@ -91,6 +91,10 @@ impl NpmConfig { Self { values } } + pub(crate) fn set_registry(&mut self, registry: &str) { + self.values.insert("registry".to_string(), expand_value(registry)); + } + pub(crate) fn registry_for_package(&self, package: &str) -> String { let scope = package.strip_prefix('@').and_then(|rest| rest.split_once('/')).map(|(scope, _)| scope); diff --git a/crates/vp_pm_cli/src/package_manager/bootstrap.rs b/crates/vp_pm_cli/src/package_manager/bootstrap.rs index c35605e9f6..4ee9e6fc1b 100644 --- a/crates/vp_pm_cli/src/package_manager/bootstrap.rs +++ b/crates/vp_pm_cli/src/package_manager/bootstrap.rs @@ -46,7 +46,7 @@ pub async fn bootstrap_pnpm( let mut config = NpmConfig::load(); if let Some(registry) = registry { - config.values.insert("registry".to_string(), registry.to_string()); + config.set_registry(registry); } let metadata: Metadata = HttpClient::with_npm_config(3, 500, config.clone()) .get_json(&config.package_version_url("pnpm", version)) From 28a3ca75fabd8bc37944dac3291c448b425f28d5 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 23 Oct 2026 23:04:42 +0900 Subject: [PATCH 30/31] test: handle Windows environment aliases in installer coverage --- crates/vp_setup/src/install.rs | 68 +++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 2b322d91bb..8be6deb4f0 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -706,38 +706,46 @@ mod tests { [ ("HOME", home.path().to_str()), ("USERPROFILE", home.path().to_str()), - ( - "npm_config_userconfig", - (variable == "npm_config_userconfig").then_some(selector), - ), - ( - "NPM_CONFIG_USERCONFIG", - (variable == "NPM_CONFIG_USERCONFIG").then_some(selector), - ), + ("npm_config_userconfig", None), + ("NPM_CONFIG_USERCONFIG", None), ], |_| { - let mut command = tokio::process::Command::new("node"); - configure_pnpm_command( - &mut command, - Some("https://registry.example.test/"), - ) - .unwrap(); - let values: std::collections::BTreeMap<_, _> = - command.as_std().get_envs().collect(); - for name in ["npm_config_userconfig", "NPM_CONFIG_USERCONFIG"] { - assert_eq!( - values[std::ffi::OsStr::new(name)].unwrap(), - expected.as_os_str(), - "{variable}={selector}" - ); - } - for name in ["npm_config_registry", "NPM_CONFIG_REGISTRY"] { - assert_eq!( - values[std::ffi::OsStr::new(name)].unwrap(), - "https://registry.example.test/" - ); - } - assert_eq!(command.as_std().get_args().len(), 0); + // Clear both spellings before setting either: they alias on Windows. + EnvConfig::with_vars([(variable, selector)], |_| { + let mut command = tokio::process::Command::new("node"); + configure_pnpm_command( + &mut command, + Some("https://registry.example.test/"), + ) + .unwrap(); + let command_env = |name: &str| { + command + .as_std() + .get_envs() + .find(|(key, _)| { + if cfg!(windows) { + key.eq_ignore_ascii_case(name) + } else { + *key == name + } + }) + .and_then(|(_, value)| value) + }; + for name in ["npm_config_userconfig", "NPM_CONFIG_USERCONFIG"] { + assert_eq!( + command_env(name), + Some(expected.as_os_str()), + "{variable}={selector}" + ); + } + for name in ["npm_config_registry", "NPM_CONFIG_REGISTRY"] { + assert_eq!( + command_env(name), + Some(std::ffi::OsStr::new("https://registry.example.test/")) + ); + } + assert_eq!(command.as_std().get_args().len(), 0); + }); }, ); } From 2082b0d7f40de985a823bb68403dba06206a69d6 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 23 Oct 2026 23:12:09 +0900 Subject: [PATCH 31/31] test: compare installer config paths using platform semantics --- crates/vp_setup/src/install.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 8be6deb4f0..38855430fa 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -733,8 +733,8 @@ mod tests { }; for name in ["npm_config_userconfig", "NPM_CONFIG_USERCONFIG"] { assert_eq!( - command_env(name), - Some(expected.as_os_str()), + command_env(name).map(std::path::Path::new), + Some(expected.as_path()), "{variable}={selector}" ); }