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/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/__tests__/homebrew-formula.mjs b/.github/scripts/__tests__/homebrew-formula.mjs new file mode 100644 index 0000000000..1e15851136 --- /dev/null +++ b/.github/scripts/__tests__/homebrew-formula.mjs @@ -0,0 +1,95 @@ +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 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'); +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)); +} + +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( + verifyRelease(version, { ...release, assets: release.assets.slice(1) }, download), + /Missing release asset/, + ); + await assert.rejects( + verifyRelease(version, release, async (url) => + url.endsWith('HomebrewFormula/vp.rb') ? 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/, + ); +}); + +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/, + ); + 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/); +}); + +await test('updates preserve recipe changes and reset only a previous version revision', () => { + 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')); + assert.ok(result.includes('conflicts_with "vite-plus"')); + 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'); + assert.equal(updateFormula(sameVersion, version, assets), sameVersion); + assert.throws(() => updateFormula(result, currentVersion, 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/__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 new file mode 100644 index 0000000000..35e74e3591 --- /dev/null +++ b/.github/scripts/test-homebrew.mjs @@ -0,0 +1,490 @@ +// 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 { homedir } from 'node:os'; +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({ + 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; +// 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'; +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 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); + const upstream = 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); + } + }); + }); + return (await mirrorRegistry(upstream)).url; +} + +// 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 (authenticated && request.headers.authorization !== `Bearer ${token}`) { + response.writeHead(401).end('Unauthorized'); + return; + } + requests.add(request.url); + try { + const result = await fetch(upstream + request.url, { + 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) => { + 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())); + } + } 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 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', INSTALL_LOCKFILE]); + const sha256 = createHash('sha256') + .update(await fs.readFile(archive)) + .digest('hex'); + const assets = targets.map(([os, arch]) => ({ + os, + arch, + url: pathToFileURL(archive).href, + sha256, + })); + // 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) { + 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 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'); + 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), + }; + 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( + 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 fs.chmod(path.join(oldKeg, 'libexec'), 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 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']) { + 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 }); + 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); + 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'), + 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..572f37947d --- /dev/null +++ b/.github/scripts/update-homebrew-formula.mjs @@ -0,0 +1,152 @@ +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; + }; + // 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()); + 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/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/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/release.yml b/.github/workflows/release.yml index 0014131339..8ca93629c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -336,6 +336,60 @@ 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@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .node-version + 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 }} + permission-contents: write + permission-pull-requests: write + + - 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/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/.github/workflows/test-homebrew.yml b/.github/workflows/test-homebrew.yml new file mode 100644 index 0000000000..9c0dace057 --- /dev/null +++ b/.github/workflows/test-homebrew.yml @@ -0,0 +1,155 @@ +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/scripts/__tests__/homebrew-preview.rb' + - '.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/**' + +concurrency: + # 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: + 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@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .node-version + 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/scripts/__tests__/homebrew-preview.rb' + - '.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/**' + + - 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: ./.github/actions/setup-rust + with: + save-cache: ${{ github.ref_name == 'main' }} + cache-key: homebrew-${{ matrix.target }}-ci + + - uses: ./.github/actions/setup-ci-node + + - uses: ./.github/actions/build-upstream + with: + profile: ci + 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(/[-+]/, 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`); + JS + 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 + 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/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] diff --git a/HomebrewFormula/vp.rb b/HomebrewFormula/vp.rb new file mode 100644 index 0000000000..d8b26cc9b1 --- /dev/null +++ b/HomebrewFormula/vp.rb @@ -0,0 +1,145 @@ +# 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" unless preview_ref + + # 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 + + 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 + libexec.install "vp", "vp-install-lock.yaml" + bin.install_symlink libexec/"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. + vp 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/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 new file mode 100644 index 0000000000..7c1bd33063 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs @@ -0,0 +1,346 @@ +import assert from 'node:assert/strict'; +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')); +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.34.7'; +const lockfile = "lockfileVersion: '9.0'\n"; +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 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']; + 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', "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'); } +`; +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, + 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/${pin}`) { + return response.end( + JSON.stringify({ + 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.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({ + 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(path.join(prefix, 'libexec'), 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, + TEST_CLI_BINARY: path.join(home, 'vp-path'), + 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); + fs.chmodSync(path.join(prefix, 'libexec'), 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.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); + 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 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']) { + 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.'); + + 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); + 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, 5); + 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.'); + + 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], + ['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'); + 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 and basic authentication work with the shared downloader.'); + + 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..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,27 @@ +[[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" +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_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_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/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/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..bb54bc86df --- /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,15 @@ +# command_self_setup_homebrew_user_dependencies + +## `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. +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_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..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 @@ -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; } @@ -64,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'); @@ -189,6 +249,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 +380,19 @@ 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-sources') { + verifyHomebrewSources(source); + return; + } + 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_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..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, @@ -79,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); @@ -191,9 +202,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/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 24eb896c32..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 @@ -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_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index e19d10f40a..98833639c4 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -95,10 +95,12 @@ 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() + { + 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(); let current_vp = crate::commands::global::install::package_shim_target(); if let Some(binary) = ¤t_exe_canon @@ -639,6 +656,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); + // 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?; + } + } + 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/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 { diff --git a/crates/vp_global_cli/src/commands/implode.rs b/crates/vp_global_cli/src/commands/implode.rs index 71796fa508..814388ee34 100644 --- a/crates/vp_global_cli/src/commands/implode.rs +++ b/crates/vp_global_cli/src/commands/implode.rs @@ -149,9 +149,12 @@ 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 Some(homebrew) = crate::homebrew::current() else { return }; + output::note(&format!( + "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.", ); @@ -329,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 f2b2540090..2819a4ac59 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -41,18 +41,35 @@ pub async fn execute(options: UpgradeOptions) -> Result { return Ok(ExitStatus::default()); } - if crate::homebrew::owns_current_exe() { - if options.check && !options.rollback { + if let Some(homebrew) = crate::homebrew::current() { + 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( - "Homebrew manages this installation. Run `brew outdated vite-plus` to check for updates.", - ); + output::info(&message); } return Ok(ExitStatus::default()); } - return Err(Error::Upgrade( - "Homebrew manages this installation. Run `brew upgrade vite-plus` to update it.".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 efbac33138..05ffee0963 100644 --- a/crates/vp_global_cli/src/homebrew.rs +++ b/crates/vp_global_cli/src/homebrew.rs @@ -2,31 +2,187 @@ use std::{path::Path, sync::OnceLock}; +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> { + 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 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) + { + return None; + } + let source = receipt.get("source"); + let name = source + .and_then(|source| source.get("path")) .and_then(serde_json::Value::as_str) - .is_some_and(|version| !version.is_empty()) + .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) + .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}"), + }; + 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 { + value.as_bytes().first().is_some_and(u8::is_ascii_alphanumeric) + && 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 { + 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, 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"), + 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.source, source); + 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); + 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(); + } + 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/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); + } + #[test] fn detects_receipt_under_custom_prefix() { let temp = tempfile::tempdir().unwrap(); @@ -45,6 +201,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/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index eaf698667b..275818c412 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 { @@ -94,7 +95,11 @@ impl JsExecutor { return AbsolutePathBuf::new(dir.into()).ok_or(Error::JsScriptsDirNotFound); } - // 3. Auto-detect from binary location + if let Some(package) = crate::homebrew::user_package_dir()? { + return Ok(package.join("node_modules/vite-plus/dist")); + } + + // 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)?; @@ -114,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) } diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index b220b5d88b..aa8bcc2713 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,29 +42,49 @@ 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) = state.completed_binary(bundled).await? + && 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) = + completed_external(state, retains_binary, user_package.as_deref()).await? + { + drop(lock); + if installed_binary.as_path() == binary { + return Ok(None); + } + return execute_installed(&installed_binary, false); + } // 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.join("bin")).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,11 +288,21 @@ 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 }; 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 { @@ -264,6 +315,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 +365,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 +409,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/config.rs b/crates/vp_pm_cli/src/config.rs index 12365402c7..5a22578ffc 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); @@ -93,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); @@ -374,6 +376,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 12ad5fa286..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, @@ -27,13 +29,13 @@ pub use error::Error; pub use lifecycle_env::LifecycleEnvContext; pub use package_manager::{ EnvironmentPackageManagerResolution, PackageManager, PackageManagerBuilder, - PackageManagerResolution, PackageManagerSource, PackageManagerType, 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 4880a9c83d..da059b19b3 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -28,6 +28,9 @@ use crate::{ shim, }; +mod bootstrap; +pub use bootstrap::bootstrap_pnpm; + #[derive(Serialize, Deserialize, Clone, Default)] #[serde(rename_all = "camelCase")] struct PackageJson { @@ -1359,6 +1362,7 @@ async fn download_package_manager_with_config( version_or_latest, expected_hash, npm_config, + None, ) .await?; #[cfg(windows)] @@ -1417,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, @@ -1465,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 new file mode 100644 index 0000000000..4ee9e6fc1b --- /dev/null +++ b/crates/vp_pm_cli/src/package_manager/bootstrap.rs @@ -0,0 +1,68 @@ +//! Bootstrap setup's pinned pnpm through the shared authenticated downloader. + +use serde::Deserialize; +use vp_error::Error; +use vt_path::AbsolutePathBuf; + +use super::{ + PackageManagerType, download_package_manager_inner, is_package_manager_install_complete, + package_manager_install_dir, +}; +use crate::{config::NpmConfig, request::HttpClient}; + +#[derive(Deserialize)] +struct Metadata { + name: String, + version: String, + dist: Distribution, +} + +#[derive(Deserialize)] +struct Distribution { + tarball: String, + integrity: String, +} + +/// 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( + 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 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 mut config = NpmConfig::load(); + if let Some(registry) = registry { + config.set_registry(registry); + } + let metadata: Metadata = HttpClient::with_npm_config(3, 500, config.clone()) + .get_json(&config.package_version_url("pnpm", version)) + .await?; + if metadata.name != "pnpm" || metadata.version != version || metadata.dist.integrity.is_empty() + { + return Err(Error::InvalidArgument("Registry returned an unexpected pnpm package".into())); + } + let (install, _, _) = download_package_manager_inner( + None, + PackageManagerType::Pnpm, + version, + Some(&metadata.dist.integrity), + &config, + Some(&metadata.dist.tarball), + ) + .await?; + Ok(install) +} diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 65626b0eea..38855430fa 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::{bootstrap_pnpm, npm_user_config_path}; 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>, @@ -222,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( @@ -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), @@ -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,24 @@ 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) = 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()); + } + Ok(()) +} + async fn run_pnpm_install( version_dir: &AbsolutePath, node_runtime: &vp_js_runtime::JsRuntime, @@ -363,9 +377,7 @@ 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_pnpm_command(&mut cmd, registry)?; let output = cmd.output().await?; Ok(output) @@ -679,6 +691,67 @@ pub async fn create_env_files(install_dir: &AbsolutePath) -> Result<(), Error> { mod tests { use super::*; + #[test] + 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", None), + ("NPM_CONFIG_USERCONFIG", None), + ], + |_| { + // 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).map(std::path::Path::new), + Some(expected.as_path()), + "{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); + }); + }, + ); + } + } + } + #[tokio::test] async fn bootstrap_reuses_project_node_and_only_resolves_lts_for_incompatible_pnpm() { use vp_js_runtime::{JsRuntimeProvider, Platform}; @@ -722,13 +795,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); }, @@ -997,10 +1071,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 +1084,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..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 bundled JavaScript. 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,9 @@ 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. ## Use It Without a Local Package diff --git a/docs/guide/homebrew.md b/docs/guide/homebrew.md new file mode 100644 index 0000000000..d03064496b --- /dev/null +++ b/docs/guide/homebrew.md @@ -0,0 +1,92 @@ +# Homebrew tap + +::: 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) in the meantime. +::: + +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: + +```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` 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. +`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`. 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. +If pnpm fails, setup reports its exit code and omits registry output, which can contain credentials. + +## 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: + +```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` 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: + +```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. + +## 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/docs/guide/implode.md b/docs/guide/implode.md index da0c51feda..4971f4dc85 100644 --- a/docs/guide/implode.md +++ b/docs/guide/implode.md @@ -26,13 +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 ``` -The confirmation prompt explains that the Homebrew package will remain installed. After cleanup, `vp implode` directs you to `brew uninstall vite-plus`. +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..3d1d9224f7 100644 --- a/docs/guide/upgrade.md +++ b/docs/guide/upgrade.md @@ -87,15 +87,17 @@ Older versions are pruned automatically after each upgrade. The active version a ### Homebrew -Homebrew owns its installed binary and JavaScript package. Update them 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. -`vp upgrade --check` directs you to `brew outdated vite-plus`. Automatic npm update checks and notices are disabled for Homebrew installations. +First use installs the new version's per-user dependencies and preserves your management preferences. + +`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 new file mode 100644 index 0000000000..c467720233 --- /dev/null +++ b/rfcs/official-homebrew-tap.md @@ -0,0 +1,320 @@ +# RFC: Vite+-managed Homebrew tap + +Status: Implementation proposed. Stable installation stays disabled until a +compatible release is published and its formula update is approved. + +## Motivation + +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). + +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 + +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 +brew install voidzero-dev/vite-plus/vp +vp +``` + +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. + +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` 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 +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. 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. + +## Installation flow + +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. Register the tap + +`brew tap` clones the repository. At this point, Homebrew has the formula but +has not installed the CLI. + +```text +/ +└── HomebrewFormula/vp.rb +``` + +### 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 -> ../libexec/vp +│ │ ├── vpr -> vp +│ │ └── vpx -> vp +│ ├── libexec/ +│ │ ├── vp +│ │ └── vp-install-lock.yaml +│ └── 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 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. + +```text +/config.json # saved before downloads + +/ +├── js_runtime/node// +│ ├── bin/node +│ └── 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/ + ├── vite-plus/ + └── ... # production dependencies +``` + +### 4. Complete user setup + +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: + +```text +/ +├── config.json +└── env # Bash/Zsh; other shell files omitted + +/ +├── vp -> /bin/vp +├── node -> /bin/vp +└── ... # vpr, vpx, and other managed shims + +/fallback-bin/ +├── npm -> /bin/vp +├── npx -> /bin/vp +└── ... # other system-first shims + +/self-setup/ +└── .json +``` + +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 +`/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 + +`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/ # public vp, vpr, vpx links +├── libexec/ # new vp and vp-install-lock.yaml +└── 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// +├── bin/vp-install-lock.yaml +├── 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. + +## CLI behavior + +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 +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. + +## Registry configuration + +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: + +```ini +registry=https://registry.example.com/repository/npm/ +//registry.example.com/repository/npm/:_authToken=${NPM_TOKEN} +``` + +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. + +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. + +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 + +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. + +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. + +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 and releases + +```text +vite-plus/ +├── HomebrewFormula/vp.rb +└── .github/ + ├── scripts/update-homebrew-formula.mjs + └── workflows/ # release integration and installation tests +``` + +Homebrew supports the [`HomebrewFormula/` layout](https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap#creating-a-tap), +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. + +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. + +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 +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. + +## Launch checks + +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. 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 + 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 +migration guides when the tap is ready. + +## Tradeoffs + +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.