diff --git a/.github/scripts/test-install-bootstrap.ps1 b/.github/scripts/test-install-bootstrap.ps1 index fac546d5e0..ee078fc5cf 100644 --- a/.github/scripts/test-install-bootstrap.ps1 +++ b/.github/scripts/test-install-bootstrap.ps1 @@ -2,16 +2,42 @@ $ErrorActionPreference = 'Stop' $source = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../../packages/cli/install.ps1') -Raw . ([scriptblock]::Create(($source -replace '(?m)^ Main\r?$', ''))) -function Exit-Installer { param([int]$Code = 1); $script:ExitCode = $Code; throw $script:InstallStopSignal } function Assert($Condition, [string]$Message) { if (-not $Condition) { throw $Message } } +# Exercise the piped entry point without acquiring a payload. +function Test-InstallerEntryPoint { + Assert ($ErrorActionPreference -eq 'Stop') 'Installer did not enable terminating errors' + if ($entryPointFails) { throw 'Expected installer failure' } +} +$entryPointSource = $source -replace '(?m)^ Main\r?$', ' Test-InstallerEntryPoint' +try { + foreach ($preference in @('Continue', 'Stop')) { + foreach ($entryPointFails in @($false, $true)) { + $ErrorActionPreference = $preference + $caught = $false + try { + $entryPointSource | Invoke-Expression + } catch { + Assert ($_.Exception.Message -eq 'Expected installer failure') "Unexpected error: $_" + $caught = $true + } + Assert ($caught -eq $entryPointFails) 'Installer failure was lost' + Assert ($ErrorActionPreference -eq $preference) 'Installer changed the caller error preference' + } + } +} finally { + $ErrorActionPreference = 'Stop' +} +function Exit-Installer { param([int]$Code = 1); $script:ExitCode = $Code; throw $script:InstallStopSignal } + $testRoot = Join-Path $env:TEMP "vite-bootstrap-test-$(Get-Random)" $originalTemp = $env:TEMP $originalCheck = $env:VP_SELF_SETUP_SUPPORT_CHECK $originalPath = $env:Path $originalRegistry = $env:NPM_CONFIG_REGISTRY +$originalVpShell = $env:VP_SHELL $fixtureSha = '0123456789012345678901234567890123456789' New-Item -ItemType Directory -Path "$testRoot/package", "$testRoot/tmp", "$testRoot/scripts" | Out-Null Set-Content -LiteralPath "$testRoot/package/vp.exe" -Value 'Payload fixture' @@ -21,6 +47,8 @@ if ($args.Count -eq 0) { New-Item -ItemType File -Path "$testRoot/binary-invoked" | Out-Null if ($scenario -eq 'failure') { exit 42 } if ($env:VP_SELF_SETUP_SHELL -ne 'powershell') { exit 98 } + $expectedVpShell = if ($scenario -eq 'supported-pr') { 'fish' } else { 'powershell' } + if ($env:VP_SHELL -ne $expectedVpShell) { exit 96 } if ($scenario -eq 'supported-pr' -and $env:NPM_CONFIG_REGISTRY -ne 'https://registry-bridge.viteplus.dev/') { exit 97 } Write-Output ("`$script:InstallDir = '{0}'" -f "$testRoot/data") Write-Output ("`$script:ShimDir = '{0}'" -f "$testRoot/installed bin") @@ -98,6 +126,8 @@ try { foreach ($scenario in @('supported', 'legacy', 'legacy-remote', 'legacy-failure', 'failure', 'pr', 'supported-pr')) { $env:Path = $originalPath $env:NPM_CONFIG_REGISTRY = 'https://custom.example' + $initialVpShell = if ($scenario -eq 'supported-pr') { 'fish' } else { $null } + $env:VP_SHELL = $initialVpShell $script:Requests = New-Object 'System.Collections.Generic.List[string]' $script:ExitCode = 0 $script:PackageMetadata = $null @@ -123,6 +153,7 @@ try { } $expectedExit = if ($scenario -in @('failure', 'legacy-failure')) { 42 } else { 0 } Assert ($script:ExitCode -eq $expectedExit) 'Binary exit code was lost' + Assert ($env:VP_SHELL -eq $initialVpShell) 'Setup changed the caller shell' if ($scenario -eq 'supported') { Assert (($env:Path -split ';')[0] -eq "$testRoot/installed bin") 'Installed bin directory was not added to the current PATH' } elseif ($scenario -eq 'failure') { @@ -144,6 +175,7 @@ try { $env:VP_SELF_SETUP_SUPPORT_CHECK = $originalCheck $env:Path = $originalPath $env:NPM_CONFIG_REGISTRY = $originalRegistry + $env:VP_SHELL = $originalVpShell $env:TEMP = $originalTemp Remove-Item -LiteralPath $testRoot -Recurse -Force $global:LASTEXITCODE = 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ed06c97b3..9a335bbc3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1243,6 +1243,11 @@ jobs: SystemTemp='${{ steps.snapshot-temp.outputs.directory }}' \ cargo-nextest nextest run --archive-file windows-snapshot-tests.tar.zst --workspace-remap . --no-fail-fast \ -E 'test(windows_case_sensitive_shims)' || test_exit=$? + + # Exercise the same environment wrapper under Windows PowerShell 5.1. + VP_SNAP_PWSH_BIN="$(cygpath -w "$(command -v powershell.exe)")" \ + cargo-nextest nextest run --archive-file windows-snapshot-tests.tar.zst --workspace-remap . --no-fail-fast \ + -E 'test(command_env_powershell)' || test_exit=$? fi exit "$test_exit" env: diff --git a/Cargo.lock b/Cargo.lock index 8606770648..c18da5b97c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8814,7 +8814,6 @@ dependencies = [ "futures-util", "hex", "httpmock", - "indicatif", "node-semver", "pgp", "reqwest", @@ -8937,6 +8936,7 @@ dependencies = [ "temp-env", "tempfile", "thiserror 2.0.20", + "tokio", "tracing", "tracing-subscriber", "vp_shared", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs new file mode 100644 index 0000000000..e7d985133d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const mode = process.argv[2]; +const home = path.resolve('profiles/user'); +const binary = path.join( + process.env.VP_HOME, + 'current/bin', + process.platform === 'win32' ? 'vp.exe' : 'vp', +); +const env = { + ...process.env, + HOME: home, + USERPROFILE: home, + ZDOTDIR: path.join(home, 'zsh'), + XDG_CONFIG_HOME: path.join(home, '.config'), + XDG_DATA_HOME: path.join(home, '.local/share'), + VP_SELF_SETUP_NO_MODIFY_PATH: '1', +}; +// Emulate a fresh terminal, without tool delegation state from this Node process. +delete env.VP_PATH_INJECTED_TOOLS; +delete env.VP_BYPASS; +delete env.VP_SHELL; +fs.mkdirSync(home, { recursive: true }); +fs.writeFileSync('.node-version', '22.18.0\n'); + +/** @returns {string} */ +function captureVp(args, extra = {}) { + const result = spawnSync(binary, args, { + env: { ...env, ...extra }, + encoding: 'utf8', + timeout: 30000, + }); + assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr); + return result.stdout.replace(/\u001b\[[0-9;]*m/g, '').replaceAll('\r\n', '\n'); +} +const dirs = Object.fromEntries( + captureVp([], { VP_DUMP_DIRS: '1' }) + .trim() + .split('\n') + .map((line) => line.split('\t')), +); + +/** @returns {string} */ +function setup(shell) { + if (shell === undefined) delete env.VP_SHELL; + else env.VP_SHELL = shell; + console.log(`VP_SHELL=${shell ?? ''}:`); + const output = captureVp(['env', 'setup']); + const heading = output.indexOf('Next Steps:\n'); + assert.ok(heading >= 0, output); + return output.slice(heading).trimEnd(); +} + +/** @returns {{ system: string, source: string }} */ +function prepareSystemNode() { + captureVp(['env', 'on', 'node']); + const system = path.resolve('profiles/system'); + fs.mkdirSync(system, { recursive: true }); + fs.writeFileSync(path.join(system, 'node'), '#!/bin/sh\necho system-node\n', { mode: 0o755 }); + // Keep system startup helpers available, with the fake Node first on PATH. + env.PATH = [system, '/usr/bin', '/bin'].join(path.delimiter); + const envPath = path.join(dirs.config, 'env').replace(/[\\$`"]/g, '\\$&'); + return { system, source: `. "${envPath}"\n` }; +} + +if (mode === 'powershell') { + // Control executable discovery independently of the shells installed on the runner. + const shellBin = path.resolve('profiles/shell-bin'); + fs.mkdirSync(shellBin, { recursive: true }); + fs.writeFileSync( + path.join(shellBin, process.platform === 'win32' ? 'powershell.exe' : 'pwsh'), + '', + { + mode: 0o755, + }, + ); + env.PATH = shellBin; + delete env.SHELL; + for (const shell of ['pwsh', undefined]) { + const output = setup(shell); + assert.match(output, /\. '[^\n]*env\.ps1'/); + assert.match(output, /\$PROFILE if it is not already there/); + assert.doesNotMatch(output, /Or open a new terminal/); + assert.doesNotMatch(output, /Fish:|Nushell:/); + console.log(output.split('\n').find((line) => line.includes('$PROFILE'))); + } +} else if (mode.startsWith('cmd')) { + if (mode === 'cmd') delete env.VP_SELF_SETUP_NO_MODIFY_PATH; + console.log(`VP_SELF_SETUP_NO_MODIFY_PATH=${env.VP_SELF_SETUP_NO_MODIFY_PATH ?? ''}:`); + const output = setup('cmd'); + const activation = output + .split('\n') + .find((line) => line.trimStart().startsWith('set "PATH=')) + ?.trim(); + const fallbackBin = path.join(dirs.data, 'fallback-bin'); + assert.equal(activation, `set "PATH=${dirs.bin};%PATH%;${fallbackBin}"`); + assert.match(output, /user PATH if missing/); + assert.ok(output.includes(`At the start: ${dirs.bin}`)); + assert.ok(output.includes(`At the end: ${fallbackBin}`)); + assert.match(output, /System Properties -> Environment Variables -> User variables -> Path/); + assert.doesNotMatch(output, /open a new terminal to load/i); + console.log(output); + + if (process.platform === 'win32') { + const system = path.resolve('profiles/system'); + fs.mkdirSync(system, { recursive: true }); + fs.copyFileSync(process.execPath, path.join(system, 'node.exe')); + const staleEnv = { + ...env, + PATH: [system, path.join(process.env.SystemRoot, 'System32')].join(';'), + }; + for (const [command, expected] of [ + ['where.exe node', path.join(system, 'node.exe')], + [`${activation} & where.exe node`, path.join(dirs.bin, 'node.exe')], + ]) { + const result = spawnSync(process.env.ComSpec, ['/d', '/v:off', '/s', '/c', `"${command}"`], { + env: staleEnv, + windowsVerbatimArguments: true, + encoding: 'utf8', + timeout: 30000, + }); + assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr); + assert.equal(result.stdout.trim().split(/\r?\n/)[0].toLowerCase(), expected.toLowerCase()); + } + } +} else if (mode.startsWith('zsh-')) { + const { system, source } = prepareSystemNode(); + fs.mkdirSync(env.ZDOTDIR, { recursive: true }); + // Ubuntu's global compinit can prompt about the runner's completion directories. + // Keep normal profile loading, but skip completion setup in this PATH test. + fs.writeFileSync(path.join(env.ZDOTDIR, '.zshenv'), `skip_global_compinit=1\n${source}`); + // Model PATH changes made by login startup after .zshenv, such as macOS path_helper. + const systemPath = system.replace(/[\\$`"]/g, '\\$&'); + fs.writeFileSync(path.join(env.ZDOTDIR, '.zprofile'), `export PATH="${systemPath}:$PATH"\n`); + const configured = mode === 'zsh-interactive'; + if (configured) fs.writeFileSync(path.join(env.ZDOTDIR, '.zshrc'), source); + console.log(configured ? 'Zsh .zshrc is configured:' : 'Only Zsh .zshenv is configured:'); + const output = setup('zsh'); + if (configured) { + assert.doesNotMatch(output, /Add the command/); + assert.match(output, /Or open a new terminal/); + } else { + assert.match(output, /Add the command to your \.zshrc/); + assert.doesNotMatch(output, /Or open a new terminal/); + } + console.log(output); +} else { + // Suppress Ubuntu's sudo hint while still loading the normal Bash startup files. + fs.writeFileSync(path.join(home, '.hushlogin'), ''); + const { system, source } = prepareSystemNode(); + const bash = process.env.PATH.split(path.delimiter) + .map((dir) => path.join(dir, 'bash')) + .find((file) => fs.existsSync(file)); + assert.ok(bash); + env.SHELL = '/bin/bash'; + const fish = path.join(env.XDG_CONFIG_HOME, 'fish/config.fish'); + fs.mkdirSync(path.dirname(fish), { recursive: true }); + fs.writeFileSync(fish, `source "${path.join(dirs.config, 'env.fish')}"\n`); + + /** @returns {void} */ + function freshBash(expectedNode) { + console.log("$ bash --noprofile -ic 'command -v node; node --version'"); + const result = spawnSync( + bash, + [ + '--noprofile', + '-ic', + 'command -v node; test "$(command -v node)" = "$EXPECTED_NODE" || exit 1; node --version', + ], + { env: { ...env, EXPECTED_NODE: expectedNode }, stdio: 'inherit', timeout: 30000 }, + ); + assert.equal(result.status, 0, result.error?.message); + } + + if (mode === 'unset' || mode === 'unrecognized') { + console.log('Only Fish is configured:'); + const output = setup(mode === 'unset' ? undefined : mode); + assert.match(output, /For Bash, run:/); + assert.match(output, /If your ~\/\.bashrc does not already load Vite\+/); + assert.doesNotMatch(output, /Or open a new terminal/); + console.log(output); + freshBash(path.join(system, 'node')); + } else if (mode === 'bash-login') { + console.log('Only the Bash login profile is configured:'); + fs.writeFileSync(path.join(home, '.bash_profile'), source); + const output = setup('bash'); + assert.match(output, /Add the command to ~\/\.bashrc/); + assert.doesNotMatch(output, /Or open a new terminal/); + console.log(output); + freshBash(path.join(system, 'node')); + } else { + assert.equal(mode, 'bash-interactive'); + console.log('Bash .bashrc is configured:'); + fs.writeFileSync(path.join(home, '.bashrc'), source); + const output = setup('bash'); + assert.doesNotMatch(output, /Add the command/); + assert.match(output, /Or start an interactive non-login Bash shell/); + console.log(output); + freshBash(path.join(dirs.bin, 'node')); + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.fish b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.fish new file mode 100644 index 0000000000..76bd3ac022 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.fish @@ -0,0 +1,13 @@ +printf '$ command -v node\n' +command -v node +printf '$ node --version\n' +node --version +printf '$ %s\n' "$ACTIVATION_COMMAND" +eval $ACTIVATION_COMMAND; or exit 1 +printf '$ command -v node\n' +command -v node +test (command -v node) = "$ACTIVATION_BIN/node"; or exit 1 +printf '$ node --version\n' +node --version; or exit 1 +printf '$ vp env list node\n' +vp env list node; or exit 1 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.nu b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.nu new file mode 100644 index 0000000000..9055732e30 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.nu @@ -0,0 +1,15 @@ +print '$ which node' +which node | first | get path | print +print '$ node --version' +^node --version +print ('$ ' + $env.ACTIVATION_COMMAND) +__ACTIVATION_COMMAND__ +print '$ which node' +which node | first | get path | print +if (which node | first | get path) != ($env.ACTIVATION_BIN | path join node) { + error make {msg: 'node did not resolve through the shim'} +} +print '$ node --version' +^node --version +print '$ vp env list node' +vp env list node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.sh b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.sh new file mode 100644 index 0000000000..17dd8f6075 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.sh @@ -0,0 +1,14 @@ +set -eu +printf '$ command -v node\n' +command -v node +printf '$ node --version\n' +node --version +printf '$ %s\n' "$ACTIVATION_COMMAND" +eval "$ACTIVATION_COMMAND" +printf '$ command -v node\n' +command -v node +test "$(command -v node)" = "$ACTIVATION_BIN/node" +printf '$ node --version\n' +node --version +printf '$ vp env list node\n' +vp env list node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/shell-guidance.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/shell-guidance.mjs new file mode 100644 index 0000000000..53ea0d997a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/shell-guidance.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const mode = process.argv[2]; +const home = path.resolve('shell-guidance/user'); +const shellBin = path.resolve('shell-guidance/bin'); +const binary = path.join(process.env.VP_HOME, 'current/bin/vp'); +const env = { ...process.env, HOME: home, ZDOTDIR: home, PATH: shellBin }; +delete env.VP_SHELL; +delete env.SHELL; +fs.mkdirSync(home, { recursive: true }); +fs.mkdirSync(shellBin, { recursive: true }); + +/** @returns {string} */ +function capture(args, extra = {}) { + const result = spawnSync(binary, args, { + env: { ...env, ...extra }, + encoding: 'utf8', + timeout: 30000, + }); + assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr); + return result.stdout.replace(/\u001b\[[0-9;]*m/g, '').replaceAll('\r\n', '\n'); +} + +const dirs = Object.fromEntries( + capture([], { VP_DUMP_DIRS: '1' }) + .trim() + .split('\n') + .map((line) => line.split('\t')), +); + +/** @returns {string} */ +function setup() { + const output = capture(['env', 'setup']); + const heading = output.indexOf('Next Steps:\n'); + assert.ok(heading >= 0, output); + const instructions = output.slice(heading).trimEnd(); + assert.doesNotMatch(instructions, /Or open a new terminal/); + console.log(instructions); + return instructions; +} + +if (mode === 'hints') { + // Even matching profiles must not turn a login-shell hint into a current-shell guarantee. + for (const profile of ['.zshrc', '.bashrc']) { + fs.writeFileSync(path.join(home, profile), `. "${dirs.config}/env"\n`); + } + for (const [shell, label, file] of [ + ['zsh', 'Zsh', 'env'], + ['bash', 'Bash', 'env'], + ['sh', 'sh', 'env'], + ['fish', 'Fish', 'env.fish'], + ['nu', 'Nushell', 'env.nu'], + ['pwsh', 'PowerShell', 'env.ps1'], + ]) { + env.SHELL = `/usr/bin/${shell}`; + console.log(`SHELL=${env.SHELL}, VP_SHELL=:`); + const output = setup(); + assert.ok(output.includes(`For ${label}, run:`)); + assert.ok(output.includes(path.join(dirs.config, file))); + assert.equal(output.split('\n').filter((line) => line.includes(dirs.config)).length, 1); + assert.doesNotMatch(output, /Activate Vite\+ in this terminal/); + } + env.SHELL = '/bin/zsh'; + env.VP_SHELL = 'unrecognized'; + console.log('SHELL=/bin/zsh, VP_SHELL=unrecognized:'); + assert.match(setup(), /For Zsh, run:/); + env.VP_SHELL = 'fish'; + console.log('SHELL=/bin/zsh, VP_SHELL=fish (Fish is not on PATH):'); + const output = setup(); + assert.match(output, /Activate Vite\+ in this terminal:/); + assert.match(output, /env\.fish/); + assert.doesNotMatch(output, /For Zsh|env\.ps1|env\.nu/); +} else { + assert.equal(mode, 'available'); + env.SHELL = '/bin/unrecognized'; + for (const shell of ['fish', 'nu', 'pwsh']) { + fs.writeFileSync(path.join(shellBin, shell), '', { mode: 0o644 }); + } + console.log('Unknown shell; optional shell files are not executable:'); + assert.doesNotMatch(setup(), /Fish:|Nushell:|PowerShell:|\$PROFILE/); + for (const [shell, label] of [ + ['fish', 'Fish'], + ['nu', 'Nushell'], + ['pwsh', 'PowerShell'], + ]) { + fs.chmodSync(path.join(shellBin, shell), 0o755); + console.log(`Unknown shell; only ${label} is available:`); + const output = setup(); + assert.ok(output.includes(`${label}:`)); + for (const other of ['Fish', 'Nushell', 'PowerShell'].filter((name) => name !== label)) { + assert.ok(!output.includes(`${other}:`)); + } + assert.equal(output.includes('$PROFILE'), shell === 'pwsh'); + fs.chmodSync(path.join(shellBin, shell), 0o644); + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml new file mode 100644 index 0000000000..6b8e4699fc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml @@ -0,0 +1,101 @@ +[[case]] +name = "activation_managed_bash" +vp = "global" +skip-platforms = ["windows"] +requires = ["bash"] +steps = [["node", "verify.mjs", "managed", "bash"]] + +[[case]] +name = "activation_external_zsh" +vp = "global" +skip-platforms = ["windows"] +requires = ["zsh"] +steps = [["node", "verify.mjs", "external", "zsh"]] + +[[case]] +name = "activation_xdg_bash" +vp = "global" +skip-platforms = ["windows"] +requires = ["bash"] +steps = [["node", "verify.mjs", "xdg", "bash"]] + +[[case]] +name = "activation_legacy_bash" +vp = "global" +skip-platforms = ["windows"] +requires = ["bash"] +steps = [["node", "verify.mjs", "legacy", "bash"]] + +[[case]] +name = "activation_fish" +vp = "global" +skip-platforms = ["windows"] +requires = ["fish"] +steps = [["node", "verify.mjs", "managed", "fish"]] + +[[case]] +name = "activation_nushell" +vp = "global" +skip-platforms = ["windows"] +requires = ["nu"] +steps = [["node", "verify.mjs", "managed", "nu"]] + +[[case]] +name = "activation_profile_guidance" +vp = "global" +skip-platforms = ["windows"] +requires = ["bash"] +# Give each interactive Bash its own PTY so job control cannot affect the next shell. +steps = [ + ["node", "profile-guidance.mjs", "unset"], + ["node", "profile-guidance.mjs", "unrecognized"], + ["node", "profile-guidance.mjs", "bash-login"], + ["node", "profile-guidance.mjs", "bash-interactive"], +] + +[[case]] +name = "activation_login_shell_hint" +vp = "global" +skip-platforms = ["windows"] +steps = [["node", "shell-guidance.mjs", "hints"]] + +[[case]] +name = "activation_available_shells" +vp = "global" +skip-platforms = ["windows"] +steps = [["node", "shell-guidance.mjs", "available"]] + +[[case]] +name = "activation_powershell_profile_guidance" +vp = "global" +steps = [["node", "profile-guidance.mjs", "powershell"]] + +[[case]] +name = "activation_zsh_profile_guidance" +vp = "global" +comment = "Login startup reorders PATH after .zshenv; .zshrc must activate the shims afterward." +skip-platforms = ["windows"] +requires = ["zsh"] +# Launch each login shell directly as the PTY owner, without Node as its parent. +steps = [ + ["node", "profile-guidance.mjs", "zsh-env"], + { argv = ["zsh", "-lic", 'command -v node; test "$(command -v node)" = "$EXPECTED_NODE" || exit 1; node --version'], envs = [ + ["HOME", "${workspace}/profiles/user"], + ["ZDOTDIR", "${workspace}/profiles/user/zsh"], + ["EXPECTED_NODE", "${workspace}/profiles/system/node"], + ] }, + ["node", "profile-guidance.mjs", "zsh-interactive"], + { argv = ["zsh", "-lic", 'command -v node; test "$(command -v node)" = "$EXPECTED_NODE" || exit 1; node --version'], envs = [ + ["HOME", "${workspace}/profiles/user"], + ["ZDOTDIR", "${workspace}/profiles/user/zsh"], + ["EXPECTED_NODE", "${VP_HOME}/bin/node"], + ] }, +] + +[[case]] +name = "activation_cmd_path_guidance" +vp = "global" +steps = [ + ["node", "profile-guidance.mjs", "cmd"], + ["node", "profile-guidance.mjs", "cmd-no-modify-path"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_available_shells.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_available_shells.md new file mode 100644 index 0000000000..94e4428fb0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_available_shells.md @@ -0,0 +1,42 @@ +# activation_available_shells + +## `node shell-guidance.mjs available` + +``` +Unknown shell; optional shell files are not executable: +Next Steps: + Activate Vite+ in this terminal: + Bash/Zsh: . "/.vite-plus/env" + + If your shell profile does not already load Vite+, add the command for your shell. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +Unknown shell; only Fish is available: +Next Steps: + Activate Vite+ in this terminal: + Bash/Zsh: . "/.vite-plus/env" + Fish: source "/.vite-plus/env.fish" + + If your shell profile does not already load Vite+, add the command for your shell. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +Unknown shell; only Nushell is available: +Next Steps: + Activate Vite+ in this terminal: + Bash/Zsh: . "/.vite-plus/env" + Nushell: source "/.vite-plus/env.nu" + + If your shell profile does not already load Vite+, add the command for your shell. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +Unknown shell; only PowerShell is available: +Next Steps: + Activate Vite+ in this terminal: + Bash/Zsh: . "/.vite-plus/env" + PowerShell: . '/.vite-plus/env.ps1' + + If your shell profile does not already load Vite+, add the command for your shell. + For PowerShell, add its command to $PROFILE if it is not already there. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_cmd_path_guidance.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_cmd_path_guidance.md new file mode 100644 index 0000000000..41b72de002 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_cmd_path_guidance.md @@ -0,0 +1,37 @@ +# activation_cmd_path_guidance + +## `node profile-guidance.mjs cmd` + +``` +VP_SELF_SETUP_NO_MODIFY_PATH=: +VP_SHELL=cmd: +Next Steps: + Activate Vite+ in this terminal: + set "PATH=/.vite-plus/bin;%PATH%;/.vite-plus/fallback-bin" + + For future cmd.exe sessions, add these directories to your user PATH if missing: + At the start: /.vite-plus/bin + At the end: /.vite-plus/fallback-bin + System Properties -> Environment Variables -> User variables -> Path + Open a new terminal after updating PATH. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +``` + +## `node profile-guidance.mjs cmd-no-modify-path` + +``` +VP_SELF_SETUP_NO_MODIFY_PATH=1: +VP_SHELL=cmd: +Next Steps: + Activate Vite+ in this terminal: + set "PATH=/.vite-plus/bin;%PATH%;/.vite-plus/fallback-bin" + + For future cmd.exe sessions, add these directories to your user PATH if missing: + At the start: /.vite-plus/bin + At the end: /.vite-plus/fallback-bin + System Properties -> Environment Variables -> User variables -> Path + Open a new terminal after updating PATH. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md new file mode 100644 index 0000000000..6a9999cd6d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md @@ -0,0 +1,84 @@ +# activation_external_zsh + +## `node verify.mjs external zsh` + +``` +First setup: +$ vp env list node +Setup: + Preparing vite-plus environment. + +Created Shims: + /activation/home/bin/node + /activation/home/fallback-bin/npm + /activation/home/fallback-bin/npx + /activation/home/fallback-bin/pnpm + /activation/home/fallback-bin/pnpx + /activation/home/fallback-bin/yarn + /activation/home/fallback-bin/yarnpkg + /activation/home/fallback-bin/bun + /activation/home/fallback-bin/bunx + /activation/home/bin/vpx + /activation/home/bin/vpr + +Next Steps: + Activate Vite+ in this terminal: + . "/activation/home/env" + + Add the command to your .zshrc file to activate future Zsh terminals. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +✓ Vite+ setup complete. +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Setup with an existing profile entry: +$ vp env setup +VITE+ - The Unified Toolchain for the Web + +Setup: + Preparing vite-plus environment. + +Skipped Shims: + /activation/home/bin/node + /activation/home/fallback-bin/npm + /activation/home/fallback-bin/npx + /activation/home/fallback-bin/pnpm + /activation/home/fallback-bin/pnpx + /activation/home/fallback-bin/yarn + /activation/home/fallback-bin/yarnpkg + /activation/home/fallback-bin/bun + /activation/home/fallback-bin/bunx + /activation/home/bin/vpx + /activation/home/bin/vpr + + Use --refresh to update existing shims. + +Next Steps: + Activate Vite+ in this terminal: + . "/activation/home/env" + + Or open a new terminal to load your configured shell profile. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +Same terminal: +$ command -v node +/activation/system/node +$ node --version +system-node +$ . "/activation/home/env" +$ command -v node +/activation/home/bin/node +$ node --version + +$ vp env list node +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_fish.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_fish.md new file mode 100644 index 0000000000..6881f210b1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_fish.md @@ -0,0 +1,29 @@ +# activation_fish + +## `node verify.mjs managed fish` + +``` +First setup: + Activate Vite+ in this terminal: + source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" + Add the command for your shell to its profile to activate future terminals. +Setup with an existing profile entry: + Or open a new terminal to load your configured shell profile. +Same terminal: +$ command -v node +/activation/system/node +$ node --version +system-node +$ source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" +$ command -v node +/activation/home space 'quote' $cash `tick` "double" \slash/bin/node +$ node --version + +$ vp env list node +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md new file mode 100644 index 0000000000..bd12cd8d4e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md @@ -0,0 +1,30 @@ +# activation_legacy_bash + +## `node verify.mjs legacy bash` + +``` +First setup: + Activate Vite+ in this terminal: + . "/activation/user/.vite-plus/env" + Add the command to ~/.bashrc for interactive non-login Bash sessions. + Login Bash shells must also load the command through their login profile. +Setup with an existing profile entry: + Or start an interactive non-login Bash shell to load your configured ~/.bashrc. +Same terminal: +$ command -v node +/activation/system/node +$ node --version +system-node +$ . "/activation/user/.vite-plus/env" +$ command -v node +/activation/user/.vite-plus/bin/node +$ node --version + +$ vp env list node +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_login_shell_hint.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_login_shell_hint.md new file mode 100644 index 0000000000..77dd1444e1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_login_shell_hint.md @@ -0,0 +1,71 @@ +# activation_login_shell_hint + +## `node shell-guidance.mjs hints` + +``` +SHELL=/usr/bin/zsh, VP_SHELL=: +Next Steps: + For Zsh, run: + . "/.vite-plus/env" + + If your .zshrc does not already load Vite+, add this command. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +SHELL=/usr/bin/bash, VP_SHELL=: +Next Steps: + For Bash, run: + . "/.vite-plus/env" + + If your ~/.bashrc does not already load Vite+, add this command for interactive non-login Bash sessions. + Login Bash shells must also load the command through their login profile. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +SHELL=/usr/bin/sh, VP_SHELL=: +Next Steps: + For sh, run: + . "/.vite-plus/env" + + If your shell profile does not already load Vite+, add the command for your shell. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +SHELL=/usr/bin/fish, VP_SHELL=: +Next Steps: + For Fish, run: + source "/.vite-plus/env.fish" + + If your shell profile does not already load Vite+, add the command for your shell. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +SHELL=/usr/bin/nu, VP_SHELL=: +Next Steps: + For Nushell, run: + source "/.vite-plus/env.nu" + + If your shell profile does not already load Vite+, add the command for your shell. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +SHELL=/usr/bin/pwsh, VP_SHELL=: +Next Steps: + For PowerShell, run: + . '/.vite-plus/env.ps1' + + Add this command to $PROFILE if it is not already there, for future PowerShell sessions. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +SHELL=/bin/zsh, VP_SHELL=unrecognized: +Next Steps: + For Zsh, run: + . "/.vite-plus/env" + + If your .zshrc does not already load Vite+, add this command. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +SHELL=/bin/zsh, VP_SHELL=fish (Fish is not on PATH): +Next Steps: + Activate Vite+ in this terminal: + source "/.vite-plus/env.fish" + + Add the command for your shell to its profile to activate future terminals. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md new file mode 100644 index 0000000000..933dff10d9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md @@ -0,0 +1,30 @@ +# activation_managed_bash + +## `node verify.mjs managed bash` + +``` +First setup: + Activate Vite+ in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" + Or start an interactive non-login Bash shell to load your configured ~/.bashrc. + Login Bash shells must also load the command through their login profile. +Setup with an existing profile entry: + Or start an interactive non-login Bash shell to load your configured ~/.bashrc. +Same terminal: +$ command -v node +/activation/system/node +$ node --version +system-node +$ . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +$ command -v node +/activation/home space 'quote' $cash `tick` "double" \slash/bin/node +$ node --version + +$ vp env list node +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_nushell.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_nushell.md new file mode 100644 index 0000000000..4b14b15b49 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_nushell.md @@ -0,0 +1,29 @@ +# activation_nushell + +## `node verify.mjs managed nu` + +``` +First setup: + Activate Vite+ in this terminal: + source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" + Add the command for your shell to its profile to activate future terminals. +Setup with an existing profile entry: + Or open a new terminal to load your configured shell profile. +Same terminal: +$ which node +/activation/system/node +$ node --version +system-node +$ source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" +$ which node +/activation/home space 'quote' $cash `tick` "double" \slash/bin/node +$ node --version + +$ vp env list node +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_powershell_profile_guidance.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_powershell_profile_guidance.md new file mode 100644 index 0000000000..200bc76714 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_powershell_profile_guidance.md @@ -0,0 +1,10 @@ +# activation_powershell_profile_guidance + +## `node profile-guidance.mjs powershell` + +``` +VP_SHELL=pwsh: + Add this command to $PROFILE if it is not already there, for future PowerShell sessions. +VP_SHELL=: + For PowerShell, add its command to $PROFILE if it is not already there. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_profile_guidance.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_profile_guidance.md new file mode 100644 index 0000000000..862b5d5b6f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_profile_guidance.md @@ -0,0 +1,73 @@ +# activation_profile_guidance + +## `node profile-guidance.mjs unset` + +``` +Only Fish is configured: +VP_SHELL=: +Next Steps: + For Bash, run: + . "/.vite-plus/env" + + If your ~/.bashrc does not already load Vite+, add this command for interactive non-login Bash sessions. + Login Bash shells must also load the command through their login profile. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +$ bash --noprofile -ic 'command -v node; node --version' +/profiles/system/node +system-node +``` + +## `node profile-guidance.mjs unrecognized` + +``` +Only Fish is configured: +VP_SHELL=unrecognized: +Next Steps: + For Bash, run: + . "/.vite-plus/env" + + If your ~/.bashrc does not already load Vite+, add this command for interactive non-login Bash sessions. + Login Bash shells must also load the command through their login profile. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +$ bash --noprofile -ic 'command -v node; node --version' +/profiles/system/node +system-node +``` + +## `node profile-guidance.mjs bash-login` + +``` +Only the Bash login profile is configured: +VP_SHELL=bash: +Next Steps: + Activate Vite+ in this terminal: + . "/.vite-plus/env" + + Add the command to ~/.bashrc for interactive non-login Bash sessions. + Login Bash shells must also load the command through their login profile. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +$ bash --noprofile -ic 'command -v node; node --version' +/profiles/system/node +system-node +``` + +## `node profile-guidance.mjs bash-interactive` + +``` +Bash .bashrc is configured: +VP_SHELL=bash: +Next Steps: + Activate Vite+ in this terminal: + . "/.vite-plus/env" + + Or start an interactive non-login Bash shell to load your configured ~/.bashrc. + Login Bash shells must also load the command through their login profile. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +$ bash --noprofile -ic 'command -v node; node --version' +/.vite-plus/bin/node + +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md new file mode 100644 index 0000000000..df5c2303f4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md @@ -0,0 +1,30 @@ +# activation_xdg_bash + +## `node verify.mjs xdg bash` + +``` +First setup: + Activate Vite+ in this terminal: + . "/activation/config space 'quote' \$cash \`tick\` \"double\" \\slash/vite-plus/env" + Add the command to ~/.bashrc for interactive non-login Bash sessions. + Login Bash shells must also load the command through their login profile. +Setup with an existing profile entry: + Or start an interactive non-login Bash shell to load your configured ~/.bashrc. +Same terminal: +$ command -v node +/activation/system/node +$ node --version +system-node +$ . "/activation/config space 'quote' \$cash \`tick\` \"double\" \\slash/vite-plus/env" +$ command -v node +/activation/bin space 'quote' $cash `tick` "double" \slash/node +$ node --version + +$ vp env list node +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_zsh_profile_guidance.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_zsh_profile_guidance.md new file mode 100644 index 0000000000..c15197745f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_zsh_profile_guidance.md @@ -0,0 +1,45 @@ +# activation_zsh_profile_guidance + +Login startup reorders PATH after .zshenv; .zshrc must activate the shims afterward. + +## `node profile-guidance.mjs zsh-env` + +``` +Only Zsh .zshenv is configured: +VP_SHELL=zsh: +Next Steps: + Activate Vite+ in this terminal: + . "/.vite-plus/env" + + Add the command to your .zshrc file to activate future Zsh terminals. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +``` + +## `HOME=${workspace}/profiles/user ZDOTDIR=${workspace}/profiles/user/zsh EXPECTED_NODE=${workspace}/profiles/system/node zsh -lic 'command -v node; test "$(command -v node)" = "$EXPECTED_NODE" || exit 1; node --version'` + +``` +/profiles/system/node +system-node +``` + +## `node profile-guidance.mjs zsh-interactive` + +``` +Zsh .zshrc is configured: +VP_SHELL=zsh: +Next Steps: + Activate Vite+ in this terminal: + . "/.vite-plus/env" + + Or open a new terminal to load your configured shell profile. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +``` + +## `HOME=${workspace}/profiles/user ZDOTDIR=${workspace}/profiles/user/zsh EXPECTED_NODE=${VP_HOME}/bin/node zsh -lic 'command -v node; test "$(command -v node)" = "$EXPECTED_NODE" || exit 1; node --version'` + +``` +/.vite-plus/bin/node + +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs new file mode 100644 index 0000000000..19d2e21203 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const [kind, shell] = process.argv.slice(2); +const root = path.resolve('activation'); +const binary = path.join(root, 'prefix/bin/vp'); +const source = path.join(process.env.VP_HOME, 'current/bin/vp'); +const version = '22.18.0'; +const seedNode = path.join(process.env.VP_HOME, 'js_runtime/node', version, 'bin/node'); +const special = 'space \'quote\' $cash `tick` "double" \\slash'; +const env = { ...process.env }; +for (const key of Object.keys(env)) { + if (key.startsWith('VP_') || key.startsWith('XDG_') || key === 'CI') delete env[key]; +} +Object.assign(env, { + HOME: path.join(root, 'user'), + ZDOTDIR: path.join(root, 'user/zsh'), + XDG_CONFIG_HOME: path.join(root, 'user/.config'), + XDG_DATA_HOME: path.join(root, 'user/.local/share'), + VP_CLI_TEST: '1', + VP_NO_UPDATE_CHECK: '1', + VP_SELF_SETUP_NO_MODIFY_PATH: '1', + VP_NODE_MANAGER: 'yes', + VP_PM_MANAGER: 'no', + VP_SHELL: shell, + SHELL: '/login-shell-is-not-the-current-shell/fish', + NPM_CONFIG_REGISTRY: 'http://127.0.0.1:9', +}); +fs.mkdirSync(path.dirname(binary), { recursive: true }); +fs.mkdirSync(env.HOME, { recursive: true }); +fs.copyFileSync(source, binary); +fs.chmodSync(binary, 0o555); +if (kind === 'external' || kind === 'xdg') { + const pkg = path.join(root, 'prefix/node_modules/vite-plus'); + fs.mkdirSync(path.join(pkg, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(pkg, 'package.json'), '{"name":"vite-plus"}'); + fs.writeFileSync(path.join(pkg, 'dist/bin.js'), '// External bundle'); + fs.writeFileSync(path.join(root, 'prefix/INSTALL_RECEIPT.json'), '{"homebrew_version":"test"}'); +} else { + env.VP_SKIP_DEPS_INSTALL = '1'; + env.VP_VERSION = 'activation-test'; +} +if (kind === 'xdg') { + env.XDG_CONFIG_HOME = path.join(root, 'config ' + special); + env.XDG_STATE_HOME = path.join(root, 'state'); + env.VP_BIN_DIR = path.join(root, 'bin ' + special); + env.VP_DATA_DIR = path.join(root, 'data'); + env.VP_CACHE_DIR = path.join(root, 'cache'); +} else if (kind === 'legacy') { + const legacy = path.join(env.HOME, '.vite-plus'); + fs.mkdirSync(legacy, { recursive: true }); + fs.symlinkSync('activation-test', path.join(legacy, 'current')); +} else { + env.VP_HOME = path.join(root, kind === 'external' ? 'home' : 'home ' + special); +} +/** @returns {import('node:child_process').SpawnSyncReturns} */ +function captureVp(args, extra = {}) { + const result = spawnSync(binary, args, { + env: { ...env, ...extra }, + encoding: 'utf8', + timeout: 30000, + }); + assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr); + return result; +} +/** @returns {void} */ +function runInTerminal(executable, args) { + const result = spawnSync(executable, args, { env, stdio: 'inherit', timeout: 30000 }); + assert.equal(result.status, 0, result.error?.message); +} +const dirs = Object.fromEntries( + captureVp([], { VP_DUMP_DIRS: '1' }) + .stdout.trim() + .split('\n') + .map((line) => line.split('\t')), +); +// Query the authoritative resolver instead of assuming a platform's directory layout. +const { bin, config, data } = dirs; +assert.ok(bin && config && data, JSON.stringify(dirs)); +const runtime = path.join(data, 'js_runtime/node', version, 'bin'); +fs.mkdirSync(runtime, { recursive: true }); +fs.symlinkSync(seedNode, path.join(runtime, 'node')); +fs.writeFileSync('.node-version', version); +const system = path.join(root, 'system'); +fs.mkdirSync(system); +fs.writeFileSync(path.join(system, 'node'), '#!/bin/sh\necho system-node\n', { mode: 0o755 }); +// Resolve the shell using the runner PATH before switching to the stale PATH. +const shellBin = process.env.PATH.split(path.delimiter) + .map((dir) => path.join(dir, shell)) + .find((file) => fs.existsSync(file)); +assert.ok(shellBin, shell); +const automaticProfile = kind === 'managed' && shell === 'bash'; +if (automaticProfile) { + delete env.VP_SELF_SETUP_NO_MODIFY_PATH; + fs.writeFileSync(path.join(env.HOME, '.bashrc'), '# Existing shell configuration\n'); + fs.symlinkSync(shellBin, path.join(system, 'bash')); +} +env.PATH = system; +console.log('First setup:'); +let activation; +if (kind === 'external') { + console.log('$ vp env list node'); + runInTerminal(binary, ['env', 'list', 'node']); + // The full PTY snapshot records the printed command. Other cases also parse + // the guidance and execute it, so escaping remains covered independently. + const escaped = path.join(config, 'env').replace(/[\\$`"]/g, '\\$&'); + activation = `. "${escaped}"`; +} else { + const first = captureVp(['env', 'list', 'node']); + const setup = first.stderr.replace(/\u001b\[[0-9;]*m/g, ''); + assert.match(setup, /Vite\+ setup complete/); + if (automaticProfile) { + assert.doesNotMatch(setup, /Add the command/); + } else { + assert.match(setup, /Add the command/); + } + const sourcePrefix = shell === 'fish' || shell === 'nu' ? 'source "' : '. "'; + activation = setup + .split('\n') + .find((line) => line.trim().startsWith(sourcePrefix)) + ?.trim(); + assert.ok(activation, setup); + console.log( + setup + .split('\n') + .filter((line) => + /Activate Vite\+|^ \. |^ source |new terminal|Add the command|Bash shell/.test(line), + ) + .join('\n'), + ); +} +let profile; +switch (shell) { + case 'zsh': + profile = path.join(env.ZDOTDIR, '.zshrc'); + break; + case 'fish': + profile = path.join(env.XDG_CONFIG_HOME, 'fish/config.fish'); + break; + case 'nu': + profile = path.join(env.XDG_CONFIG_HOME, 'nushell/config.nu'); + break; + default: + profile = path.join(env.HOME, '.bashrc'); +} +if (automaticProfile) { + assert.ok(fs.readFileSync(profile, 'utf8').includes(activation)); +} else { + fs.mkdirSync(path.dirname(profile), { recursive: true }); + fs.writeFileSync(profile, activation + '\n'); +} +console.log('Setup with an existing profile entry:'); +if (kind === 'external') { + console.log('$ vp env setup'); + runInTerminal(binary, ['env', 'setup']); +} else { + const repeated = captureVp(['env', 'setup']).stdout.replace(/\u001b\[[0-9;]*m/g, ''); + assert.doesNotMatch(repeated, /Add the command/); + const configuredAdvice = + shell === 'bash' ? 'interactive non-login Bash shell' : 'open a new terminal'; + assert.ok(repeated.includes(configuredAdvice), repeated); + console.log(repeated.split('\n').find((line) => line.includes(configuredAdvice))); +} +Object.assign(env, { + ACTIVATION_BIN: bin, + ACTIVATION_COMMAND: activation, +}); +console.log('Same terminal:'); +if (shell === 'fish') { + runInTerminal(shellBin, ['--no-config', 'session.fish']); +} else if (shell === 'nu') { + fs.writeFileSync( + 'activate.nu', + fs.readFileSync('session.nu', 'utf8').replaceAll('__ACTIVATION_COMMAND__', activation), + ); + runInTerminal(shellBin, ['--no-config-file', 'activate.nu']); +} else if (shell === 'bash') { + runInTerminal(shellBin, ['--noprofile', '--norc', 'session.sh']); +} else { + runInTerminal(shellBin, ['-f', 'session.sh']); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 index 60b039fa4b..ebfd4f71c8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 @@ -1,16 +1,39 @@ $ErrorActionPreference = "Stop" +$expectedBin = Join-Path $env:EXPECTED_VP_HOME "bin" +$expectedFallback = Join-Path $env:EXPECTED_VP_HOME "fallback-bin" +$externalBin = Join-Path $PWD "external-node" +New-Item -ItemType Directory -Path $externalBin | Out-Null +$node = Get-Command node -CommandType Application | Select-Object -First 1 +Copy-Item -LiteralPath $node.Source -Destination $externalBin +$externalNode = Join-Path $externalBin (Split-Path $node.Source -Leaf) +$env:PATH = (@($expectedFallback.ToUpperInvariant(), $externalBin, $expectedBin.ToUpperInvariant(), $env:PATH, $expectedBin, $expectedFallback)) -join [IO.Path]::PathSeparator +if ((Get-Command node -CommandType Application | Select-Object -First 1).Source -ne $externalNode) { + throw "Fixture did not create a Node PATH priority conflict" +} + +# Repeated activation must put one shim entry first, ahead of the external Node. +. (Join-Path $env:EXPECTED_VP_HOME "env.ps1") . (Join-Path $env:EXPECTED_VP_HOME "env.ps1") if ($env:VP_HOME -ne $env:EXPECTED_VP_HOME) { throw "VP_HOME mismatch: expected $env:EXPECTED_VP_HOME, got $env:VP_HOME" } -$expectedBin = Join-Path $env:EXPECTED_VP_HOME "bin" -$binCount = @($env:Path -split [IO.Path]::PathSeparator | Where-Object { $_ -ieq $expectedBin }).Count +$binCount = @($env:PATH -split [IO.Path]::PathSeparator | Where-Object { $_ -ieq $expectedBin }).Count if ($binCount -ne 1) { throw "PATH contains the Vite+ bin directory $binCount times" } +if (($env:PATH -split [IO.Path]::PathSeparator)[0] -ne $expectedBin) { + throw "Activation did not put the shim directory first on PATH" +} +$fallbackCount = @($env:PATH -split [IO.Path]::PathSeparator | Where-Object { $_ -ieq $expectedFallback }).Count +if ($fallbackCount -ne 1 -or ($env:PATH -split [IO.Path]::PathSeparator)[-1] -ne $expectedFallback) { + throw "Activation did not put exactly one fallback directory last on PATH" +} +if ((Get-Command node -CommandType Application | Select-Object -First 1).Source -ne (Join-Path $expectedBin (Split-Path $node.Source -Leaf))) { + throw "Activation did not give the Node shim priority" +} if (-not (Get-Command vp -CommandType Function -ErrorAction SilentlyContinue)) { throw "env.ps1 did not define the vp wrapper" @@ -40,7 +63,38 @@ if ($LASTEXITCODE -ne 0) { if ($env:VP_NODE_VERSION -ne "20.18.0") { throw "VP_NODE_VERSION mismatch: expected 20.18.0, got $env:VP_NODE_VERSION" } +if ($ErrorActionPreference -ne 'Stop' -or (Test-Path Env:VP_ENV_USE_EVAL_ENABLE) -or (Test-Path Env:VP_SHELL)) { + throw "vp env use did not restore the caller's preferences and environment" +} + +$env:VP_ENV_USE_EVAL_ENABLE = 'original' +$env:VP_SHELL = 'powershell' +vp env use invalid-version --no-install 6>$null +if ($LASTEXITCODE -eq 0 -or $env:VP_NODE_VERSION -ne '20.18.0') { + throw "Failed vp env use changed the selected Node version or lost its exit code" +} +if ($ErrorActionPreference -ne 'Stop' -or $env:VP_ENV_USE_EVAL_ENABLE -ne 'original' -or $env:VP_SHELL -ne 'powershell') { + throw "Failed vp env use did not restore the caller's preferences and environment" +} +# A terminating error while displaying native stderr must also restore the environment. +& { + function Write-Host { throw 'Simulated host output failure' } + $caught = $false + try { + vp env use 20.18.0 --no-install + } catch { + if ($_.Exception.Message -notlike '*Simulated host output failure*') { throw } + $caught = $true + } + if (-not $caught) { throw 'The wrapper did not propagate the host output failure' } +} +if ($ErrorActionPreference -ne 'Stop' -or $env:VP_ENV_USE_EVAL_ENABLE -ne 'original' -or $env:VP_SHELL -ne 'powershell') { + throw "Interrupted vp env use did not restore the caller's preferences and environment" +} +Remove-Item Env:VP_ENV_USE_EVAL_ENABLE, Env:VP_SHELL + +$ErrorActionPreference = 'Continue' vp env use --unset if ($LASTEXITCODE -ne 0) { throw "vp env use --unset failed through the PowerShell wrapper" @@ -48,6 +102,10 @@ if ($LASTEXITCODE -ne 0) { if (Test-Path Env:VP_NODE_VERSION) { throw "vp env use --unset did not remove VP_NODE_VERSION" } +if ($ErrorActionPreference -ne 'Continue' -or (Test-Path Env:VP_ENV_USE_EVAL_ENABLE) -or (Test-Path Env:VP_SHELL)) { + throw "vp env use --unset did not restore the caller's preferences and environment" +} +$ErrorActionPreference = 'Stop' vp env use --no-install if ($LASTEXITCODE -ne 0) { diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots.toml index d04fa3e3a4..a2f8117f07 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots.toml @@ -1,7 +1,6 @@ [[case]] name = "command_env_powershell" vp = "global" -skip-platforms = ["linux", "macos"] requires = ["pwsh"] steps = [ { argv = ["vp", "env", "setup", "--refresh"], snapshot = false }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/archive.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/archive.mjs new file mode 100644 index 0000000000..32d16431c2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/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/download_progress/download.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/download.mjs index 976b1a30a8..0ff7e851b6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/download.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/download.mjs @@ -6,29 +6,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { gzipSync } from 'node:zlib'; - -/** - * Small, valid archives keep this test offline. The installed files are never run. - * @param {Record} files - * @returns {Buffer} - */ -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)])); -} +import { createArchive } from './archive.mjs'; const version = '99.0.0'; const musl = process.platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime; @@ -91,7 +69,12 @@ try { console.log(`Before ${tool} download: preserve this output.`); const child = spawn('vp', ['env', 'install', `${tool}@${version}`], { stdio: 'inherit', - env: { ...process.env, VP_HOME: home, VP_NODE_DIST_MIRROR: mirror, npm_config_registry: mirror }, + env: { + ...process.env, + VP_HOME: home, + VP_NODE_DIST_MIRROR: mirror, + npm_config_registry: mirror, + }, }); const [code, signal] = await once(child, 'exit'); assert.equal(signal, null); 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 new file mode 100644 index 0000000000..187639828f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { once } from 'node:events'; +import { copyFileSync, mkdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { join, resolve } from 'node:path'; +import { createArchive } from './archive.mjs'; + +const interactive = process.argv.includes('interactive'); +const failure = process.argv.includes('failure'); +if (interactive) process.stdin.setRawMode(true); +const home = resolve('home'); +const user = resolve('user'); +const external = resolve('external'); +const nodeBin = join(home, 'js_runtime/node/99.0.0/bin'); +for (const directory of [nodeBin, user, external]) mkdirSync(directory, { recursive: true }); +// Use the runner's real Node for the mock pnpm program, without a network download. +symlinkSync(process.execPath, join(nodeBin, 'node')); +copyFileSync(join(process.env.VP_HOME, 'bin/vp'), join(external, 'vp')); + +const pnpmArchive = createArchive({ + 'package/package.json': JSON.stringify({ + name: 'pnpm', + version: '10.33.0', + bin: { pnpm: 'bin/pnpm.cjs' }, + }), + 'package/bin/pnpm.cjs': ` + const fs = require('node:fs'); + console.log('pnpm captured stdout'); + console.error('pnpm captured stderr'); + fetch(process.env.npm_config_registry + '/install').then(response => { + if (!response.ok) process.exit(17); + fs.mkdirSync('node_modules/vite-plus', { recursive: true }); + fs.writeFileSync('node_modules/vite-plus/package.json', '{"name":"vite-plus"}'); + }); + `, +}); + +// The request is made after the CLI draws each phase. Hold its response until +// the runner captures the milestone, without sleeps or polling terminal text. +const phases = []; +/** @returns {Promise} */ +async function checkpoint(phase) { + phases.push(phase); + if (!interactive) return; + const nextInput = once(process.stdin, 'data'); + process.stdin.resume(); + const id = randomBytes(16).toString('hex'); + const name = Buffer.from(`install:${phase}:ready`).toString('base64url'); + process.stderr.write(`\x1b]2;pty-terminal-test:${id}:${name}\x1b\\`); + await nextInput; + process.stdin.pause(); +} + +/** @returns {Promise} */ +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 (/^\/pnpm\/-\/pnpm-[\d.]+\.tgz$/.test(request.url)) { + await checkpoint('download'); + response.setHeader('Content-Length', pnpmArchive.length); + response.end(pnpmArchive); + } else if (request.url === '/install') { + await checkpoint('dependencies'); + response.writeHead(failure ? 500 : 200).end(); + } else { + response.writeHead(404).end(); + } +} + +const server = createServer(handleRequest); +server.listen(0, '127.0.0.1'); +await once(server, 'listening'); +const mirror = `http://127.0.0.1:${server.address().port}`; + +try { + console.log('Before installation: preserve this output.'); + const child = spawn(join(external, 'vp'), [], { + stdio: ['ignore', 'pipe', 'inherit'], + env: { + ...process.env, + HOME: user, + PATH: nodeBin, + VP_HOME: home, + VP_NODE_DIST_MIRROR: mirror, + npm_config_registry: mirror, + NPM_CONFIG_REGISTRY: mirror, + VP_SELF_SETUP_SHELL: 'sh', + VP_SELF_SETUP_NO_MODIFY_PATH: '1', + VP_NODE_MANAGER: 'no', + VP_SHELL: 'zsh', + VP_SKIP_DEPS_INSTALL: '', + }, + }); + let stdout = ''; + child.stdout.setEncoding('utf8').on('data', (chunk) => { + stdout += chunk; + }); + const [code, signal] = await once(child, 'exit'); + assert.equal(signal, null); + assert.equal(code, failure ? 1 : 0); + assert.deepEqual(phases, ['prepare', 'download', 'dependencies']); + 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.'); + } else { + assert.deepEqual( + stdout + .trim() + .split('\n') + .map((line) => line.split('=')[0]), + ['INSTALL_DIR', 'SHIM_DIR', 'CACHE_DIR', 'CONFIG_DIR', 'STATE_DIR'], + ); + console.log('Bootstrap stdout contains only shell assignments.'); + } + console.log('After installation.'); +} finally { + if (interactive) process.stdin.setRawMode(false); + server.close(); + server.closeAllConnections(); + for (const directory of [home, user, external]) + rmSync(directory, { recursive: true, force: true }); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml index f9204f7e82..aea82015da 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml @@ -22,3 +22,41 @@ name = "download_progress_ci" vp = "global" skip-platforms = ["windows"] steps = [{ argv = ["node", "download.mjs", "known"], envs = [["CI", "1"]] }] + +[[case]] +name = "installation_progress_interactive" +vp = "global" +skip-platforms = ["windows"] +steps = [{ argv = ["node", "install.mjs", "interactive"], interactions = [ + { expect-milestone = "install:prepare:ready" }, + { write-key = "enter" }, + { expect-milestone = "install:download:ready" }, + { write-key = "enter" }, + { expect-milestone = "install:dependencies:ready" }, + { write-key = "enter" }, +] }] + +[[case]] +name = "installation_progress_failure" +vp = "global" +skip-platforms = ["windows"] +steps = [{ argv = ["node", "install.mjs", "interactive", "failure"], interactions = [ + { expect-milestone = "install:prepare:ready" }, + { write-key = "enter" }, + { expect-milestone = "install:download:ready" }, + { write-key = "enter" }, + { expect-milestone = "install:dependencies:ready" }, + { write-key = "enter" }, +] }] + +[[case]] +name = "installation_progress_non_tty" +vp = "global" +skip-platforms = ["windows"] +steps = [{ argv = ["node", "install.mjs"], tty = false }] + +[[case]] +name = "installation_progress_ci" +vp = "global" +skip-platforms = ["windows"] +steps = [{ argv = ["node", "install.mjs"], envs = [["CI", "1"]] }] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_ci.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_ci.md new file mode 100644 index 0000000000..db09614c2e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_ci.md @@ -0,0 +1,37 @@ +# installation_progress_ci + +## `CI=1 node install.mjs` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +info: Preparing Node.js and pnpm... +info: Installing dependencies... +✓ Dependencies installed. +Setup: + Preparing vite-plus environment. + +Created Shims: + /home/fallback-bin/node + /home/bin/npm + /home/bin/npx + /home/bin/pnpm + /home/bin/pnpx + /home/bin/yarn + /home/bin/yarnpkg + /home/bin/bun + /home/bin/bunx + /home/bin/vpx + /home/bin/vpr + +Next Steps: + Activate Vite+ in this terminal: + . "/home/env" + + Add the command to your .zshrc file to activate future Zsh terminals. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +✓ Vite+ setup complete. +Bootstrap stdout contains only shell assignments. +After installation. +``` 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 new file mode 100644 index 0000000000..9feb25033d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md @@ -0,0 +1,41 @@ +# installation_progress_failure + +## `node install.mjs interactive failure` + +**→ expect-milestone:** `install:prepare:ready` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +⠿ Preparing Node.js and pnpm... +``` + +**← write-key:** `enter` + +**→ expect-milestone:** `install:download:ready` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +⠿ Downloading pnpm ... B ( B/s) +``` + +**← write-key:** `enter` + +**→ expect-milestone:** `install:dependencies:ready` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +⠿ Installing dependencies... +``` + +**← write-key:** `enter` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +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. +After installation. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md new file mode 100644 index 0000000000..d842a66302 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md @@ -0,0 +1,65 @@ +# installation_progress_interactive + +## `node install.mjs interactive` + +**→ expect-milestone:** `install:prepare:ready` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +⠿ Preparing Node.js and pnpm... +``` + +**← write-key:** `enter` + +**→ expect-milestone:** `install:download:ready` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +⠿ Downloading pnpm ... B ( B/s) +``` + +**← write-key:** `enter` + +**→ expect-milestone:** `install:dependencies:ready` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +⠿ Installing dependencies... +``` + +**← write-key:** `enter` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +✓ Dependencies installed. +Setup: + Preparing vite-plus environment. + +Created Shims: + /home/fallback-bin/node + /home/bin/npm + /home/bin/npx + /home/bin/pnpm + /home/bin/pnpx + /home/bin/yarn + /home/bin/yarnpkg + /home/bin/bun + /home/bin/bunx + /home/bin/vpx + /home/bin/vpr + +Next Steps: + Activate Vite+ in this terminal: + . "/home/env" + + Add the command to your .zshrc file to activate future Zsh terminals. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +✓ Vite+ setup complete. +Bootstrap stdout contains only shell assignments. +After installation. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_non_tty.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_non_tty.md new file mode 100644 index 0000000000..097b4133ed --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_non_tty.md @@ -0,0 +1,37 @@ +# installation_progress_non_tty + +## `node install.mjs` + +``` +Before installation: preserve this output. +Bootstrap stdout contains only shell assignments. +After installation. +info: installing vite-plus@0.3.3... +info: Preparing Node.js and pnpm... +info: Installing dependencies... +✓ Dependencies installed. +Setup: + Preparing vite-plus environment. + +Created Shims: + /home/fallback-bin/node + /home/bin/npm + /home/bin/npx + /home/bin/pnpm + /home/bin/pnpx + /home/bin/yarn + /home/bin/yarnpkg + /home/bin/bun + /home/bin/bunx + /home/bin/vpx + /home/bin/vpr + +Next Steps: + Activate Vite+ in this terminal: + . "/home/env" + + Add the command to your .zshrc file to activate future Zsh terminals. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +✓ Vite+ setup complete. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md index cd19e5f537..3cc19cfa52 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md @@ -256,8 +256,11 @@ PowerShell wrapper and vpr completion keep global -C before env use/run $env:VP_HOME = '/home' $__vp_bin = '/home/bin' $__vp_fallback = '/home/fallback-bin' -$__vp_paths = @($env:Path -split ';' | Where-Object { $_ -and $_ -ne $__vp_bin -and $_ -ne $__vp_fallback }) -$env:Path = (@($__vp_bin) + $__vp_paths + @($__vp_fallback)) -join ';' +$env:PATH = @( + $__vp_bin + $env:PATH -split [IO.Path]::PathSeparator | Where-Object { $_ -and $_ -ne $__vp_bin -and $_ -ne $__vp_fallback } + $__vp_fallback +) -join [IO.Path]::PathSeparator # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. @@ -274,17 +277,26 @@ function vp { if ($args -contains "-h" -or $args -contains "--help") { & (Join-Path $__vp_bin "vp") @args; return } - $env:VP_ENV_USE_EVAL_ENABLE = "1" - $env:VP_SHELL = "pwsh" - $output = & (Join-Path $__vp_bin "vp") @args 2>&1 | ForEach-Object { - if ($_ -is [System.Management.Automation.ErrorRecord]) { - Write-Host $_.Exception.Message - } else { - $_ + $previousEvalEnable = $env:VP_ENV_USE_EVAL_ENABLE + $previousShell = $env:VP_SHELL + $previousErrorActionPreference = $ErrorActionPreference + try { + $env:VP_ENV_USE_EVAL_ENABLE = "1" + $env:VP_SHELL = "pwsh" + # Windows PowerShell 5.1 treats native stderr as an error when redirected. + $ErrorActionPreference = "Continue" + $output = & (Join-Path $__vp_bin "vp") @args 2>&1 | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + Write-Host $_.Exception.Message + } else { + $_ + } } + } finally { + $ErrorActionPreference = $previousErrorActionPreference + $env:VP_ENV_USE_EVAL_ENABLE = $previousEvalEnable + $env:VP_SHELL = $previousShell } - Remove-Item Env:VP_ENV_USE_EVAL_ENABLE -ErrorAction SilentlyContinue - Remove-Item Env:VP_SHELL -ErrorAction SilentlyContinue if ($LASTEXITCODE -eq 0 -and $output) { Invoke-Expression ($output -join "`n") } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/flavor.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/flavor.rs index d786f786c3..bca3f08eaa 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/flavor.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/flavor.rs @@ -252,7 +252,10 @@ pub fn nushell_path() -> Result, String> { /// Resolves an optional PowerShell binary for fixtures that exercise generated /// `env.ps1` files. pub fn powershell_path() -> Result, String> { - optional_tool_path("VP_SNAP_PWSH_BIN", "pwsh") + // Windows PowerShell 5.1 uses .NET Framework, which cannot load its + // configuration from the verbatim paths returned by std::fs::canonicalize. + Ok(optional_tool_path("VP_SNAP_PWSH_BIN", "pwsh")? + .map(|path| dunce::simplified(&path).to_path_buf())) } /// Resolves an optional cmd.exe for fixtures that exercise generated batch diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index afc83b1a71..af5ef526ad 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -25,6 +25,7 @@ use crate::{ commands::{ env::{bin_config::BinConfig, package_metadata::PackageMetadata}, global::{LEGACY_PACKAGE_MANAGER_PACKAGES, install::uninstall}, + shell::{ALL_SHELL_PROFILES, Shell, ShellProfileRoot, resolve_profile_path}, }, error::Error, help, @@ -54,6 +55,16 @@ impl EnvShell { EnvShell::Powershell => "env.ps1", } } + + fn source_command(self, env_dir: &vt_path::AbsolutePath) -> String { + let path = env_dir.join(self.env_file_name()).to_string(); + match self { + Self::Posix => format!(". \"{}\"", escape_posix_double_quoted_string(&path)), + Self::Fish => format!("source \"{}\"", escape_fish_double_quoted_string(&path)), + Self::Nu => format!("source \"{}\"", escape_nu_double_quoted_string(&path)), + Self::Powershell => format!(". '{}'", escape_powershell_single_quoted_string(&path)), + } + } } /// Execute the setup command. @@ -890,8 +901,11 @@ export extern "vpr" [...args: string@"nu-complete vpr"] const ENV_TEMPLATE_PS1: &str = r#"# Vite+ environment setup (https://viteplus.dev) __ENV_EXPORTS__$__vp_bin = '__VP_BIN_WIN__' $__vp_fallback = '__VP_FALLBACK_BIN_WIN__' -$__vp_paths = @($env:Path -split ';' | Where-Object { $_ -and $_ -ne $__vp_bin -and $_ -ne $__vp_fallback }) -$env:Path = (@($__vp_bin) + $__vp_paths + @($__vp_fallback)) -join ';' +$env:PATH = @( + $__vp_bin + $env:PATH -split [IO.Path]::PathSeparator | Where-Object { $_ -and $_ -ne $__vp_bin -and $_ -ne $__vp_fallback } + $__vp_fallback +) -join [IO.Path]::PathSeparator # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. @@ -908,17 +922,26 @@ function vp { if ($args -contains "-h" -or $args -contains "--help") { & (Join-Path $__vp_bin "vp") @args; return } - $env:VP_ENV_USE_EVAL_ENABLE = "1" - $env:VP_SHELL = "pwsh" - $output = & (Join-Path $__vp_bin "vp") @args 2>&1 | ForEach-Object { - if ($_ -is [System.Management.Automation.ErrorRecord]) { - Write-Host $_.Exception.Message - } else { - $_ + $previousEvalEnable = $env:VP_ENV_USE_EVAL_ENABLE + $previousShell = $env:VP_SHELL + $previousErrorActionPreference = $ErrorActionPreference + try { + $env:VP_ENV_USE_EVAL_ENABLE = "1" + $env:VP_SHELL = "pwsh" + # Windows PowerShell 5.1 treats native stderr as an error when redirected. + $ErrorActionPreference = "Continue" + $output = & (Join-Path $__vp_bin "vp") @args 2>&1 | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + Write-Host $_.Exception.Message + } else { + $_ + } } + } finally { + $ErrorActionPreference = $previousErrorActionPreference + $env:VP_ENV_USE_EVAL_ENABLE = $previousEvalEnable + $env:VP_SHELL = $previousShell } - Remove-Item Env:VP_ENV_USE_EVAL_ENABLE -ErrorAction SilentlyContinue - Remove-Item Env:VP_SHELL -ErrorAction SilentlyContinue if ($LASTEXITCODE -eq 0 -and $output) { Invoke-Expression ($output -join "`n") } @@ -1191,55 +1214,189 @@ async fn create_env_files() -> Result<(), Error> { Ok(()) } +/// Inspect the explicitly selected shell without changing its profiles. +/// Bash only checks non-login startup; the printed advice must keep that qualification. +/// Zsh login startup can reorder PATH after `.zshenv`, so require `.zshrc`. +fn has_configured_profile(config: &vp_shared::EnvConfig) -> bool { + let shell = config.vp_shell.as_deref().map(str::to_ascii_lowercase); + for profile in ALL_SHELL_PROFILES { + let relevant = match shell.as_deref() { + Some("zsh") => profile.path == ".zshrc", + Some("bash") => profile.path == ".bashrc", + Some("sh") => profile.path == ".profile", + Some("fish") => matches!(profile.root, ShellProfileRoot::Fish), + Some("nu" | "nushell") => profile.env_file == "env.nu", + _ => false, + }; + if !relevant { + continue; + } + let path = resolve_profile_path(profile, &config.user_home); + let Ok(content) = std::fs::read_to_string(path) else { continue }; + let env_file = config.dirs.config.join(profile.env_file); + let absolute = env_file.to_string(); + let home_relative = + render_home_relative_path(env_file.as_path(), config.user_home.as_path()); + let escape = match profile.env_file { + "env.fish" => escape_fish_double_quoted_string, + "env.nu" => escape_nu_double_quoted_string, + _ => escape_posix_double_quoted_string, + }; + let escaped_relative = if profile.env_file == "env.nu" { + escape(&render_nu_path_ref(&home_relative)) + } else { + escape_home_relative_double_quoted_path(&home_relative, escape) + }; + let mut arguments = vec![ + format!("\"{}\"", escape(&absolute)), + format!("'{absolute}'"), + absolute, + format!("\"{escaped_relative}\""), + format!("\"{}\"", escaped_relative.replacen("$HOME", "${HOME}", 1)), + render_nu_path_ref(&escaped_relative), + ]; + if profile.env_file == "env.nu" { + arguments.push(format!("'{}'", render_nu_path_ref(&home_relative))); + } + for line in content.lines() { + let line = line.trim_start(); + let Some(argument) = line.strip_prefix(". ").or_else(|| line.strip_prefix("source ")) + else { + continue; + }; + let argument = argument.trim_start(); + if arguments.iter().any(|expected| { + argument.strip_prefix(expected).is_some_and(|rest| { + rest.is_empty() + || rest.starts_with(char::is_whitespace) + || rest.starts_with(';') + }) + }) { + return true; + } + } + } + false +} + /// Print instructions for sourcing the environment files and adding bin to `PATH`. fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { - // Use paths relative to $HOME. POSIX and Fish use $HOME. Nushell cannot - // expand $HOME in the parse-time `source` keyword, so use ~. - let env_path = env_dir.as_path().display().to_string(); - let home = vp_shared::EnvConfig::get().user_home.as_path().display().to_string(); - let (env_path, nu_env_path) = if let Some(suffix) = env_path.strip_prefix(&home) { - (format!("$HOME{suffix}"), format!("~{suffix}")) - } else { - (env_path.clone(), env_path) - }; - output::raw(&help::render_heading("Next Steps")); - output::raw(" Add to your shell profile (~/.zshrc, ~/.bashrc, etc.):"); - output::raw(""); - output::raw(&format!(" . \"{env_path}/env\"")); - output::raw(""); - output::raw(" For fish shell, add to ~/.config/fish/config.fish:"); - output::raw(""); - output::raw(&format!(" source \"{env_path}/env.fish\"")); - output::raw(""); - output::raw(" For Nushell, add to ~/.config/nushell/config.nu:"); - output::raw(""); - output::raw(&format!(" source '{nu_env_path}/env.nu'")); - output::raw(""); - output::raw(" For PowerShell, add to your $PROFILE:"); - output::raw(""); - output::raw(&format!(" . \"{env_path}/env.ps1\"")); + let env = vp_shared::EnvConfig::get(); + let explicit_shell = env.vp_shell.as_deref().and_then(|s| s.parse::().ok()); + // SHELL is a login-shell hint, not proof of the current shell or its startup mode. + let login_shell = if cfg!(unix) { std::env::var("SHELL").ok() } else { None }; + let shell_name = env + .vp_shell + .as_deref() + .filter(|_| explicit_shell.is_some()) + .or_else(|| login_shell.as_deref().and_then(|path| path.rsplit('/').next())); + let shell = explicit_shell.or_else(|| shell_name.and_then(|s| s.parse::().ok())); + let shell_name = shell_name.map(str::to_ascii_lowercase); + let is_hint = explicit_shell.is_none() && shell.is_some(); + let commands: &[(Shell, &str, Option, &[&str])] = &[ + (Shell::Posix, "Bash/Zsh", Some(EnvShell::Posix), &["sh", "bash", "zsh"]), + (Shell::Fish, "Fish", Some(EnvShell::Fish), &["fish"]), + (Shell::NuShell, "Nushell", Some(EnvShell::Nu), &["nu"]), + (Shell::PowerShell, "PowerShell", Some(EnvShell::Powershell), &["pwsh", "powershell"]), + (Shell::Cmd, "cmd.exe", None, &["cmd"]), + ]; + let cwd = vt_path::current_dir().ok(); + let default_shell = if cfg!(windows) { Shell::Cmd } else { Shell::Posix }; + let mut shown = Vec::new(); + if !is_hint { + output::raw(" Activate Vite+ in this terminal:"); + } + for &(kind, label, env_shell, binaries) in commands { + let show = match shell { + Some(selected) => selected == kind, + None => { + kind == default_shell + || binaries.iter().any(|bin| { + cwd.as_ref() + .is_some_and(|cwd| vp_command::resolve_bin(bin, None, cwd).is_ok()) + }) + } + }; + if !show { + continue; + } + shown.push(kind); + let command = match env_shell { + Some(env_shell) => env_shell.source_command(env_dir), + None => format!( + "set \"PATH={};%PATH%;{}\"", + env.dirs.bin.to_string().replace('%', "%%"), + env.dirs.fallback_bin().to_string().replace('%', "%%") + ), + }; + if is_hint { + let label = match shell_name.as_deref() { + Some("zsh") => "Zsh", + Some("bash") => "Bash", + Some("sh") => "sh", + _ => label, + }; + output::raw(&format!(" For {label}, run:")); + } + if shell.is_some() { + output::raw(&format!(" {command}")); + } else { + output::raw(&format!(" {label}: {command}")); + } + } output::raw(""); - output::raw(" For IDE support (VS Code, Cursor), ensure bin directory is in system PATH:"); - - #[cfg(target_os = "macos")] - { - output::raw(" - macOS: Add to ~/.profile or use launchd"); + if shown.iter().any(|s| matches!(s, Shell::Posix | Shell::Fish | Shell::NuShell)) { + let conditional = is_hint || shell.is_none(); + let configured = !conditional && has_configured_profile(&env); + if shell_name.as_deref() == Some("bash") { + output::raw(if conditional { + " If your ~/.bashrc does not already load Vite+, add this command for interactive non-login Bash sessions." + } else if configured { + " Or start an interactive non-login Bash shell to load your configured ~/.bashrc." + } else { + " Add the command to ~/.bashrc for interactive non-login Bash sessions." + }); + output::raw( + " Login Bash shells must also load the command through their login profile.", + ); + } else { + output::raw(match (shell_name.as_deref(), conditional, configured) { + (Some("zsh"), true, _) => { + " If your .zshrc does not already load Vite+, add this command." + } + (_, true, _) => { + " If your shell profile does not already load Vite+, add the command for your shell." + } + (_, _, true) => " Or open a new terminal to load your configured shell profile.", + (Some("zsh"), _, _) => { + " Add the command to your .zshrc file to activate future Zsh terminals." + } + _ => { + " Add the command for your shell to its profile to activate future terminals." + } + }); + } } - - #[cfg(target_os = "linux")] - { - output::raw(" - Linux: Add to ~/.profile for display manager integration"); + if shown.contains(&Shell::PowerShell) { + output::raw(if shell == Some(Shell::PowerShell) { + " Add this command to $PROFILE if it is not already there, for future PowerShell sessions." + } else { + " For PowerShell, add its command to $PROFILE if it is not already there." + }); } - - #[cfg(target_os = "windows")] - { - output::raw(" - Windows: System Properties -> Environment Variables -> Path"); + if shown.contains(&Shell::Cmd) { + output::raw( + " For future cmd.exe sessions, add these directories to your user PATH if missing:", + ); + output::raw(&format!(" At the start: {}", env.dirs.bin.as_path().display())); + output::raw(&format!(" At the end: {}", env.dirs.fallback_bin().as_path().display())); + output::raw(" System Properties -> Environment Variables -> User variables -> Path"); + output::raw(" Open a new terminal after updating PATH."); } - output::raw(""); output::raw(&format!( - " Restart your terminal and IDE, then run {} to verify.", + " Restart an already-running IDE to load its environment. Run {} to verify.", help::accent_command("vp env doctor") )); } @@ -1272,6 +1429,80 @@ mod tests { trampoline } + #[test] + #[cfg(not(windows))] + fn test_configured_profile_checks_the_selected_shell_and_current_install() { + let temp = TempDir::new().unwrap(); + let home = temp.path().join("user"); + let vp_home = home.join(".vite-plus"); + let zsh = temp.path().join("zsh"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&zsh).unwrap(); + vp_shared::EnvConfig::with_vars( + [ + ("HOME", home.as_os_str()), + ("USERPROFILE", home.as_os_str()), + ("VP_HOME", vp_home.as_os_str()), + ("VP_SHELL", std::ffi::OsStr::new("zsh")), + ("ZDOTDIR", zsh.as_os_str()), + ], + |config| { + assert!(!has_configured_profile(&config)); + std::fs::write(home.join(".bashrc"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); + assert!(!has_configured_profile(&config), "Bash does not configure Zsh"); + std::fs::write(zsh.join(".zshenv"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); + assert!(!has_configured_profile(&config), ".zshenv runs before login PATH setup"); + let profile = zsh.join(".zshrc"); + for line in [ + "# . \"$HOME/.vite-plus/env\"", + ". \"$HOME/other-install/env\"", + ". \"$HOME/.vite-plus/env.fish\"", + ] { + std::fs::write(&profile, line).unwrap(); + assert!(!has_configured_profile(&config), "{line}"); + } + for line in [ + ". \"$HOME/.vite-plus/env\"", + "source \"${HOME}/.vite-plus/env\" # existing setup", + ". ~/.vite-plus/env", + ] { + std::fs::write(&profile, line).unwrap(); + assert!(has_configured_profile(&config), "{line}"); + } + std::fs::remove_file(&profile).unwrap(); + assert!(!has_configured_profile(&config), "Do not cache profile state"); + }, + ); + } + + #[test] + #[cfg(not(windows))] + fn test_profile_check_does_not_infer_unknown_shell_or_bash_startup_mode() { + let temp = TempDir::new().unwrap(); + let home = temp.path().join("user"); + let vp_home = home.join(".vite-plus"); + let xdg = home.join(".config"); + std::fs::create_dir_all(xdg.join("fish")).unwrap(); + std::fs::write(xdg.join("fish/config.fish"), "source \"$HOME/.vite-plus/env.fish\"\n") + .unwrap(); + std::fs::write(home.join(".bash_profile"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); + for shell in [None, Some("unknown"), Some("bash")] { + vp_shared::EnvConfig::with_vars( + [ + ("HOME", Some(home.as_os_str())), + ("USERPROFILE", Some(home.as_os_str())), + ("VP_HOME", Some(vp_home.as_os_str())), + ("XDG_CONFIG_HOME", Some(xdg.as_os_str())), + ("VP_SHELL", shell.map(std::ffi::OsStr::new)), + ("SHELL", Some(std::ffi::OsStr::new("/bin/bash"))), + ], + |config| { + assert!(!has_configured_profile(&config), "{shell:?}"); + }, + ); + } + } + #[test] fn test_render_env_content_does_not_export_split_dir_group() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index 1fa8741fb0..94ea88a930 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -213,7 +213,7 @@ async fn install_platform_and_main( install::generate_wrapper_package_json(version_dir, new_version).await?; // Install production dependencies (pnpm installs vite-plus + all transitive deps) - install::install_production_deps(version_dir, registry).await?; + install::install_production_deps(version_dir, registry, !silent).await?; // Save previous version for rollback let previous_version = install::save_previous_version(install_dir).await?; diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index fe65130603..6cca5a3c0b 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -238,7 +238,7 @@ async fn run(source: &Path, bundled: bool) -> Result { output::info(&format!("installing vite-plus@{version}...")); install::generate_wrapper_package_json(&version_dir, version).await?; if !skip_deps { - install::install_production_deps(&version_dir, registry).await?; + install::install_production_deps(&version_dir, registry, true).await?; } } #[cfg(windows)] diff --git a/crates/vp_installer/src/legacy.rs b/crates/vp_installer/src/legacy.rs index fbcdd95155..42bf7bf33e 100644 --- a/crates/vp_installer/src/legacy.rs +++ b/crates/vp_installer/src/legacy.rs @@ -137,7 +137,7 @@ async fn install_new_version( if !opts.quiet { print_info("installing dependencies (this may take a moment)..."); } - install::install_production_deps(version_dir, opts.registry.as_deref()).await?; + install::install_production_deps(version_dir, opts.registry.as_deref(), !opts.quiet).await?; let previous_version = if has_previous { install::save_previous_version(install_dir).await? } else { None }; diff --git a/crates/vp_js_runtime/Cargo.toml b/crates/vp_js_runtime/Cargo.toml index 722873ecd4..fa9f381a52 100644 --- a/crates/vp_js_runtime/Cargo.toml +++ b/crates/vp_js_runtime/Cargo.toml @@ -11,7 +11,6 @@ rust-version.workspace = true backon = { workspace = true } flate2 = { workspace = true } futures-util = { workspace = true } -indicatif = { workspace = true } hex = { workspace = true } node-semver = { workspace = true } pgp = { workspace = true } diff --git a/crates/vp_js_runtime/src/download.rs b/crates/vp_js_runtime/src/download.rs index 9b4d84a5b5..d29bdba1c4 100644 --- a/crates/vp_js_runtime/src/download.rs +++ b/crates/vp_js_runtime/src/download.rs @@ -7,13 +7,13 @@ use std::{fs::File, time::Duration}; use backon::{ExponentialBuilder, Retryable}; use futures_util::StreamExt; -use indicatif::ProgressBar; use serde::de::DeserializeOwned; use sha2::{Digest, Sha256}; use tokio::{ fs, io::{AsyncSeekExt, AsyncWriteExt}, }; +use vp_shared::progress::Progress; use vt_path::{AbsolutePath, AbsolutePathBuf}; use vt_str::Str; @@ -47,15 +47,7 @@ pub async fn download_file( // Create progress bar (only in TTY and not in CI). Built once and reused // across retry attempts; its position is reset at the start of every // attempt so a retried download doesn't double-count bytes. - let is_ci = vp_shared::EnvConfig::get().is_ci; - let progress = if vp_shared::is_stderr_terminal() && !is_ci { - let pb = ProgressBar::new_spinner(); - pb.set_style(vp_shared::download_progress::download_style(message)); - pb.enable_steady_tick(Duration::from_millis(100)); - Some(pb) - } else { - None - }; + let progress = Progress::download(message); // Make the request *and* the body stream a single retried unit, so a // truncated download (bytes written != advertised Content-Length) triggers @@ -122,6 +114,7 @@ pub async fn download_file( }; if let Some(ref pb) = progress { + let pb = pb.bar(); pb.set_position(if is_resumed { resume_from } else { 0 }); if let Some(size) = total_size { pb.set_length(size); @@ -149,7 +142,7 @@ pub async fn download_file( let chunk = chunk_result?; bytes_written += chunk.len() as u64; if let Some(ref pb) = progress { - pb.inc(chunk.len() as u64); + pb.bar().inc(chunk.len() as u64); } file.write_all(&chunk).await?; } @@ -183,9 +176,7 @@ pub async fn download_file( reason: vp_shared::format_error_chain(&e).into(), }); - if let Some(pb) = progress { - pb.finish_and_clear(); - } + drop(progress); result?; diff --git a/crates/vp_pm_cli/src/request.rs b/crates/vp_pm_cli/src/request.rs index a7865586a8..382a573c88 100644 --- a/crates/vp_pm_cli/src/request.rs +++ b/crates/vp_pm_cli/src/request.rs @@ -14,6 +14,7 @@ use sha2::{Digest, Sha224, Sha256, Sha512}; use tar::Archive; use tokio::{fs, io::AsyncWriteExt}; use vp_error::Error; +use vp_shared::progress::Progress; /// HTTP client with built-in retry support #[derive(Clone)] @@ -169,18 +170,7 @@ impl HttpClient { // Progress bar (only in TTY and not in CI). Built once and reused across // retry attempts; its position is reset at the start of every attempt so // a retried download doesn't double-count bytes. - let is_ci = vp_shared::EnvConfig::get().is_ci; - let progress = if let Some(message) = message - && vp_shared::is_stderr_terminal() - && !is_ci - { - let pb = ProgressBar::new_spinner(); - pb.set_style(vp_shared::download_progress::download_style(message)); - pb.enable_steady_tick(Duration::from_millis(100)); - Some(pb) - } else { - None - }; + let progress = message.and_then(Progress::download); // Make the request *and* the body stream a single retried unit. Doing // the request inline (instead of calling `self.get`) avoids a double @@ -194,12 +184,18 @@ impl HttpClient { let result = (|| async { let response = client.get(url).timeout(timeout).send().await?.error_for_status()?; if let Some(ref pb) = progress { + let pb = pb.bar(); pb.set_position(0); if let Some(size) = response.content_length() { pb.set_length(size); } } - Self::write_response_to_file(response, target_path, progress.as_ref()).await + Self::write_response_to_file( + response, + target_path, + progress.as_ref().map(Progress::bar), + ) + .await }) .retry( ExponentialBuilder::default() @@ -209,9 +205,7 @@ impl HttpClient { ) .await; - if let Some(pb) = progress { - pb.finish_and_clear(); - } + drop(progress); result?; tracing::debug!("Download completed: {:?}", target_path); diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index e4970941db..64a0198b35 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -176,6 +176,7 @@ pub async fn write_upgrade_log( pub async fn install_production_deps( version_dir: &AbsolutePath, registry: Option<&str>, + show_progress: bool, ) -> Result<(), Error> { tracing::debug!("Running pnpm install in {}", version_dir.as_path().display()); @@ -189,6 +190,35 @@ pub async fn install_production_deps( args.push(registry_url); } + let (node_runtime, pnpm_entry) = vp_shared::progress::with_spinner( + show_progress.then_some("Preparing Node.js and pnpm..."), + prepare_install_runtime(), + ) + .await?; + let output = vp_shared::progress::with_spinner( + show_progress.then_some("Installing dependencies..."), + run_pnpm_install(version_dir, &node_runtime, &pnpm_entry, &args, registry), + ) + .await?; + + if !output.status.success() { + let log_path = write_upgrade_log(version_dir, &output.stdout, &output.stderr).await; + return Err(Error::Setup( + format_install_failure_message( + vp_shared::exit_code_from_status(output.status), + log_path.as_ref(), + ) + .into(), + )); + } + + if show_progress { + vp_shared::output::success("Dependencies installed."); + } + Ok(()) +} + +async fn prepare_install_runtime() -> Result<(vp_js_runtime::JsRuntime, AbsolutePathBuf), Error> { let node_version = NodeProvider::new().resolve_latest_version().await.map_err(|error| { Error::Setup(format!("Failed to resolve the latest Node.js LTS version: {error}").into()) })?; @@ -210,20 +240,7 @@ pub async fn install_production_deps( format!("pnpm entry not found at {}", pnpm_entry.as_path().display()).into(), )); } - let output = run_pnpm_install(version_dir, &node_runtime, &pnpm_entry, &args, registry).await?; - - if !output.status.success() { - let log_path = write_upgrade_log(version_dir, &output.stdout, &output.stderr).await; - return Err(Error::Setup( - format_install_failure_message( - vp_shared::exit_code_from_status(output.status), - log_path.as_ref(), - ) - .into(), - )); - } - - Ok(()) + Ok((node_runtime, pnpm_entry)) } async fn run_pnpm_install( diff --git a/crates/vp_shared/Cargo.toml b/crates/vp_shared/Cargo.toml index b1f179684b..bfaef1910f 100644 --- a/crates/vp_shared/Cargo.toml +++ b/crates/vp_shared/Cargo.toml @@ -27,6 +27,7 @@ supports-color = "3" temp-env = { workspace = true, optional = true, features = ["async_closure"] } tempfile = { workspace = true, optional = true } thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } vt_path = { workspace = true } @@ -45,6 +46,7 @@ webpki-root-certs = { workspace = true } indicatif = { workspace = true, features = ["in_memory"] } serial_test = { workspace = true } tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } # Enables the `test-utils` feature for this crate's own tests and doctests # (a package's dev-dependencies are active when it is the tested target). vp_shared = { path = ".", features = ["test-utils"] } diff --git a/crates/vp_shared/src/download_progress.rs b/crates/vp_shared/src/download_progress.rs deleted file mode 100644 index 24a4ac312b..0000000000 --- a/crates/vp_shared/src/download_progress.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! Single-row download progress shared by runtime and package-manager downloads. - -use std::fmt; - -use console::{Term, measure_text_width, style, truncate_str}; -use indicatif::{HumanBytes, HumanDuration, ProgressState, ProgressStyle}; -use vt_str::{Str, format}; - -/// Fit the download message and statistics to stderr on every redraw. -/// The same style handles responses with and without a content length. -pub fn download_style(message: &str) -> ProgressStyle { - let term = Term::stderr(); - style_with_width(message, move || term.size().1) -} - -fn style_with_width( - message: &str, - width: impl Fn() -> u16 + Clone + Send + Sync + 'static, -) -> ProgressStyle { - let message = Str::from(message); - ProgressStyle::with_template("{spinner:.green}{download}") - .expect("valid download progress template") - .with_key("download", move |state: &ProgressState, output: &mut dyn fmt::Write| { - // Reserve one column for the spinner. Read the width on each draw - // so a terminal resize also changes the message and statistics. - let available = usize::from(width()).saturating_sub(1); - let row = download_row(state, &message, available); - write!(output, "{}", truncate_str(&row, available, "")).unwrap(); - }) -} - -fn download_row(state: &ProgressState, message: &str, available: usize) -> Str { - let has_total = state.len().is_some(); - let message_width = measure_text_width(message); - let bytes = HumanBytes(state.pos()); - let mut compact_stats = match state.len() { - Some(total) => format!("{bytes}/{}", HumanBytes(total)), - None => format!("{bytes}"), - }; - let speed = HumanBytes(state.per_sec() as u64); - let detailed_stats = if has_total { - format!("{compact_stats} ({speed}/s, {:#})", HumanDuration(state.eta())) - } else { - format!("{compact_stats} ({speed}/s)") - }; - let text_width = message_width + measure_text_width(&detailed_stats); - if has_total { - // Reserve three spaces and two brackets, plus at least four bar columns. - let bar_width = available.saturating_sub(text_width + 5); - if bar_width >= 4 { - let filled = (state.fraction() * bar_width as f32) as usize; - let remaining = bar_width - filled; - let bar = format!("{}{}", "#".repeat(filled), if remaining > 0 { ">" } else { "" }); - return format!( - " {message} [{}{}] {detailed_stats}", - style(bar).blue(), - style("-".repeat(remaining.saturating_sub(1))).white(), - ); - } - } else if text_width + 2 <= available { - return format!(" {message} {detailed_stats}"); - } - - // On narrow terminals, omit the bar, speed, and ETA before shortening the - // message. For very small panes, prefer a percentage to two byte counts. - let compact_width = measure_text_width(&compact_stats) + 2 + message_width.min(8); - if has_total && compact_width > available { - compact_stats = format!("{:.0}%", state.fraction() * 100.0); - } - let available_message_width = available.saturating_sub(measure_text_width(&compact_stats) + 2); - let message = truncate_str(message, available_message_width, ""); - if message.is_empty() { - format!(" {compact_stats}") - } else { - format!(" {message} {compact_stats}") - } -} - -#[cfg(test)] -mod tests { - use std::{ - sync::{ - Arc, - atomic::{AtomicU16, Ordering}, - }, - time::Duration, - }; - - use indicatif::{InMemoryTerm, ProgressBar, ProgressDrawTarget, TermLike}; - - use super::*; - - #[test] - fn redraws_stay_on_one_row_and_preserve_earlier_output() { - for width in [1, 2, 3, 10, 20, 40, 60, 80, 138] { - for message in [ - "Downloading node v26.8.0...", - "Downloading pnpm v11.24.0...", - "Downloading a package with a very long name and wide characters 界界界界界界界界界界界界...", - ] { - for total in [55 * 1024 * 1024, u64::MAX] { - let earlier = if width >= 20 { "Earlier shell output" } else { "A" }; - let term = InMemoryTerm::new(10, width); - term.write_line(earlier).unwrap(); - let progress = ProgressBar::with_draw_target( - None, - ProgressDrawTarget::term_like(Box::new(term.clone())), - ); - let draw_term = term.clone(); - progress.set_style(style_with_width(message, move || draw_term.width())); - - for known_length in [false, true] { - if known_length { - progress.set_length(total); - } - for position in [0, 5 * 1024 * 1024, 25 * 1024 * 1024, total] { - progress.set_elapsed(Duration::from_secs(2)); - progress.set_position(position); - assert_progress_row(&progress, &term, earlier); - progress.reset_eta(); - assert_progress_row(&progress, &term, earlier); - } - } - - progress.finish_and_clear(); - assert_eq!(term.contents(), earlier); - assert!(!term.moves_since_last_check().contains("Up(")); - term.write_line("B").unwrap(); - assert_eq!(term.contents(), vt_str::format!("{earlier}\nB").as_str()); - } - } - } - } - - fn assert_progress_row(progress: &ProgressBar, term: &InMemoryTerm, earlier: &str) { - progress.force_draw(); - let width = term.width(); - let screen = term.contents(); - let lines: Vec<_> = screen.lines().collect(); - assert_eq!(lines.len(), 2, "width {width}: {screen}"); - assert_eq!(lines[0], earlier); - assert!(measure_text_width(lines[1]) <= usize::from(width)); - let moves = term.moves_since_last_check(); - assert!(!moves.contains("Up("), "width {width}: {moves}"); - } - - #[test] - fn compact_layout_preserves_download_counts() { - for (width, has_bar) in [(40, false), (60, false), (80, true), (138, true)] { - let term = InMemoryTerm::new(5, width); - let progress = ProgressBar::with_draw_target( - None, - ProgressDrawTarget::term_like(Box::new(term.clone())), - ); - let draw_term = term.clone(); - progress.set_style(style_with_width("Downloading pnpm v11.24.0...", move || { - draw_term.width() - })); - progress.set_position(25 * 1024 * 1024); - progress.reset_eta(); - progress.force_draw(); - assert!(term.contents().contains("25.00 MiB")); - - progress.set_length(55 * 1024 * 1024); - progress.force_draw(); - let screen = term.contents(); - assert!(screen.contains("25.00 MiB/55.00 MiB"), "{screen}"); - assert_eq!(screen.contains('['), has_bar, "{screen}"); - assert_eq!(screen.contains("0 B/s"), has_bar, "{screen}"); - if width >= 60 { - assert!(screen.contains("Downloading pnpm v11.24.0..."), "{screen}"); - } - } - } - - #[test] - fn layout_reads_the_current_width_on_each_draw() { - let width = Arc::new(AtomicU16::new(138)); - let term = InMemoryTerm::new(5, 138); - let progress = ProgressBar::with_draw_target( - None, - ProgressDrawTarget::term_like(Box::new(term.clone())), - ); - let draw_width = Arc::clone(&width); - progress.set_style(style_with_width("Downloading pnpm v11.24.0...", move || { - draw_width.load(Ordering::Relaxed) - })); - progress.set_position(25 * 1024 * 1024); - progress.reset_eta(); - - for known_length in [false, true] { - if known_length { - progress.set_length(55 * 1024 * 1024); - } - for columns in [138, 60, 40, 1, 80, 138] { - width.store(columns, Ordering::Relaxed); - progress.force_draw(); - let screen = term.contents(); - assert_eq!(screen.lines().count(), 1, "{screen}"); - assert!(measure_text_width(&screen) <= usize::from(columns), "{screen}"); - assert!(!term.moves_since_last_check().contains("Up(")); - } - } - } -} diff --git a/crates/vp_shared/src/lib.rs b/crates/vp_shared/src/lib.rs index 539deca36f..049d9e72c5 100644 --- a/crates/vp_shared/src/lib.rs +++ b/crates/vp_shared/src/lib.rs @@ -8,7 +8,6 @@ )] mod dirs; -pub mod download_progress; mod env_config; pub mod env_vars; mod error; @@ -20,6 +19,7 @@ pub mod output; mod package_json; mod path_env; mod process; +pub mod progress; mod stdio; pub mod string_similarity; mod tls; diff --git a/crates/vp_shared/src/progress.rs b/crates/vp_shared/src/progress.rs new file mode 100644 index 0000000000..df18741c64 --- /dev/null +++ b/crates/vp_shared/src/progress.rs @@ -0,0 +1,189 @@ +//! A single progress row for an operation and its sequential downloads. + +mod style; + +use std::{future::Future, time::Duration}; + +use indicatif::{ProgressBar, ProgressStyle}; + +use self::style::Style; + +tokio::task_local! { + static ACTIVE_PROGRESS: ProgressBar; +} + +fn enabled() -> bool { + crate::is_stderr_terminal() && !crate::EnvConfig::get().is_ci +} + +/// Animate a status with elapsed time while awaiting work. Downloads within the +/// future temporarily use the same row, then restore this status. A missing +/// message suppresses the status. +pub async fn with_spinner(message: Option<&str>, future: F) -> F::Output { + let Some(message) = message else { + return future.await; + }; + if !enabled() { + crate::output::info(message); + return future.await; + } + + let progress = + Progress::new(ProgressBar::new_spinner(), Style::Spinner.for_stderr(message), None); + progress.run(future).await +} + +/// Clears a download on completion or cancellation, or restores its enclosing +/// operation's spinner. Keep this guard alive until the download finishes. +pub struct Progress { + bar: ProgressBar, + restore: Option<(ProgressStyle, Duration)>, +} + +impl Progress { + pub fn download(message: &str) -> Option { + let (bar, restore) = match ACTIVE_PROGRESS.try_with(Clone::clone) { + Ok(bar) => { + let restore = Some((bar.style(), bar.elapsed())); + (bar, restore) + } + Err(_) if enabled() => (ProgressBar::new_spinner(), None), + Err(_) => return None, + }; + Some(Self::new(bar, Style::Download.for_stderr(message), restore)) + } + + fn new( + bar: ProgressBar, + style: ProgressStyle, + restore: Option<(ProgressStyle, Duration)>, + ) -> Self { + bar.set_style(style.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ")); + bar.reset(); + bar.unset_length(); + bar.enable_steady_tick(Duration::from_millis(100)); + bar.force_draw(); + Self { bar, restore } + } + + pub fn bar(&self) -> &ProgressBar { + &self.bar + } + + async fn run(self, future: F) -> F::Output { + ACTIVE_PROGRESS.scope(self.bar.clone(), future).await + } +} + +impl Drop for Progress { + fn drop(&mut self) { + if let Some((style, elapsed)) = self.restore.take() { + let elapsed = elapsed + self.bar.elapsed(); + self.bar.set_style(style); + self.bar.unset_length(); + self.bar.set_position(0); + self.bar.set_elapsed(elapsed); + self.bar.force_draw(); + } else { + self.bar.finish_and_clear(); + } + } +} + +#[cfg(test)] +mod tests { + use std::task::{Context, Poll, Waker}; + + use console::{Term, measure_text_width}; + use indicatif::{InMemoryTerm, ProgressDrawTarget, TermLike}; + + use super::*; + + fn spinner(term: &InMemoryTerm) -> Progress { + let draw_term = term.clone(); + Progress::new( + ProgressBar::with_draw_target( + None, + ProgressDrawTarget::term_like(Box::new(term.clone())), + ), + Style::Spinner.with_width("Preparing Node.js and pnpm...", move || draw_term.width()), + None, + ) + } + + fn assert_single_row(term: &InMemoryTerm) { + let screen = term.contents(); + let lines: Vec<_> = screen.lines().collect(); + assert_eq!(lines.len(), 2, "{screen}"); + assert_eq!(lines[0], "A"); + assert!(measure_text_width(lines[1]) <= usize::from(term.width())); + } + + #[tokio::test] + async fn downloads_share_one_row_and_restore_the_elapsed_spinner() { + // Download styles read stderr's width, so the test surface must fit it. + let width = Term::stderr().size().1.max(80); + for width in [width, width.saturating_add(40)] { + let term = InMemoryTerm::new(10, width); + term.write_line("A").unwrap(); + let progress = spinner(&term); + progress.bar.set_elapsed(Duration::from_secs(8)); + progress.bar.force_draw(); + assert_single_row(&term); + + progress + .run(async { + for message in ["Downloading Node.js...", "Downloading pnpm..."] { + let download = Progress::download(message).unwrap(); + download.bar.set_length(100); + download.bar.set_position(70); + download.bar.force_draw(); + assert_single_row(&term); + assert!(term.contents().contains(message)); + assert!(!term.contents().contains("Preparing")); + drop(download); + assert_single_row(&term); + assert!(term.contents().contains("Preparing Node.js and pnpm...")); + ACTIVE_PROGRESS.with(|bar| { + assert!(bar.elapsed() >= Duration::from_secs(8)); + }); + } + }) + .await; + + assert_eq!(term.contents(), "A"); + term.write_line("Next step").unwrap(); + assert_eq!(term.contents(), "A\nNext step"); + } + } + + #[tokio::test] + async fn errors_clear_progress_and_preserve_the_result() { + let term = InMemoryTerm::new(10, 80); + term.write_line("Earlier output").unwrap(); + let result = spinner(&term) + .run(async { + let _download = Progress::download("Downloading Node.js...").unwrap(); + Err::<(), _>("download failed") + }) + .await; + assert_eq!(result, Err("download failed")); + assert_eq!(term.contents(), "Earlier output"); + assert!(ACTIVE_PROGRESS.try_with(|_| ()).is_err()); + } + + #[test] + fn cancellation_clears_progress() { + let term = InMemoryTerm::new(10, 80); + term.write_line("Earlier output").unwrap(); + let mut future = Box::pin(spinner(&term).run(async { + let _download = Progress::download("Downloading Node.js...").unwrap(); + std::future::pending::<()>().await; + })); + assert_eq!(future.as_mut().poll(&mut Context::from_waker(Waker::noop())), Poll::Pending); + assert!(term.contents().contains("Downloading Node.js...")); + drop(future); + assert_eq!(term.contents(), "Earlier output"); + assert!(ACTIVE_PROGRESS.try_with(|_| ()).is_err()); + } +} diff --git a/crates/vp_shared/src/progress/style.rs b/crates/vp_shared/src/progress/style.rs new file mode 100644 index 0000000000..79aaedc23d --- /dev/null +++ b/crates/vp_shared/src/progress/style.rs @@ -0,0 +1,227 @@ +//! Width-aware styles for a single progress row. + +use std::fmt; + +use console::{Term, measure_text_width, style, truncate_str}; +use indicatif::{HumanBytes, HumanDuration, ProgressState, ProgressStyle}; +use vt_str::{Str, format}; + +#[derive(Clone, Copy)] +pub(super) enum Style { + Spinner, + Download, +} + +impl Style { + pub(super) fn for_stderr(self, message: &str) -> ProgressStyle { + let term = Term::stderr(); + self.with_width(message, move || term.size().1) + } + + pub(super) fn with_width( + self, + message: &str, + width: impl Fn() -> u16 + Clone + Send + Sync + 'static, + ) -> ProgressStyle { + let message = Str::from(message); + ProgressStyle::with_template("{spinner:.green}{status}") + .expect("valid progress template") + .with_key("status", move |state: &ProgressState, output: &mut dyn fmt::Write| { + // Reserve one column for the spinner. Read the width on each draw + // so a terminal resize also changes the message and statistics. + let available = usize::from(width()).saturating_sub(1); + let row = match self { + Self::Spinner => spinner_row(state, &message, available), + Self::Download => download_row(state, &message, available), + }; + write!(output, "{}", truncate_str(&row, available, "")).unwrap(); + }) + } +} + +fn spinner_row(state: &ProgressState, message: &str, available: usize) -> Str { + let elapsed = format!("{}s", state.elapsed().as_secs()); + let message_width = available.saturating_sub(measure_text_width(&elapsed) + 2); + let message = truncate_str(message, message_width, ""); + format!(" {message} {elapsed}") +} + +fn download_row(state: &ProgressState, message: &str, available: usize) -> Str { + let has_total = state.len().is_some(); + let message_width = measure_text_width(message); + let bytes = HumanBytes(state.pos()); + let mut compact_stats = match state.len() { + Some(total) => format!("{bytes}/{}", HumanBytes(total)), + None => format!("{bytes}"), + }; + let speed = HumanBytes(state.per_sec() as u64); + let detailed_stats = if has_total { + format!("{compact_stats} ({speed}/s, {:#})", HumanDuration(state.eta())) + } else { + format!("{compact_stats} ({speed}/s)") + }; + let text_width = message_width + measure_text_width(&detailed_stats); + if has_total { + // Reserve three spaces and two brackets, plus at least four bar columns. + let bar_width = available.saturating_sub(text_width + 5); + if bar_width >= 4 { + let filled = (state.fraction() * bar_width as f32) as usize; + let remaining = bar_width - filled; + let bar = format!("{}{}", "#".repeat(filled), if remaining > 0 { ">" } else { "" }); + return format!( + " {message} [{}{}] {detailed_stats}", + style(bar).blue(), + style("-".repeat(remaining.saturating_sub(1))).white(), + ); + } + } else if text_width + 2 <= available { + return format!(" {message} {detailed_stats}"); + } + + // On narrow terminals, omit the bar, speed, and ETA before shortening the + // message. For very small panes, prefer a percentage to two byte counts. + let compact_width = measure_text_width(&compact_stats) + 2 + message_width.min(8); + if has_total && compact_width > available { + compact_stats = format!("{:.0}%", state.fraction() * 100.0); + } + let available_message_width = available.saturating_sub(measure_text_width(&compact_stats) + 2); + let message = truncate_str(message, available_message_width, ""); + if message.is_empty() { + format!(" {compact_stats}") + } else { + format!(" {message} {compact_stats}") + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, + atomic::{AtomicU16, Ordering}, + }, + time::Duration, + }; + + use indicatif::{InMemoryTerm, ProgressBar, ProgressDrawTarget, TermLike}; + + use super::*; + + #[test] + fn redraws_stay_on_one_row_and_preserve_earlier_output() { + for style in [Style::Download, Style::Spinner] { + for width in [1, 2, 3, 10, 20, 40, 60, 80, 138] { + for message in [ + "Downloading node v26.8.0...", + "Downloading pnpm v11.24.0...", + "Downloading a package with a very long name and wide characters 界界界界界界界界界界界界...", + ] { + for total in [55 * 1024 * 1024, u64::MAX] { + let earlier = if width >= 20 { "Earlier shell output" } else { "A" }; + let term = InMemoryTerm::new(10, width); + term.write_line(earlier).unwrap(); + let progress = ProgressBar::with_draw_target( + None, + ProgressDrawTarget::term_like(Box::new(term.clone())), + ); + let draw_term = term.clone(); + progress.set_style(style.with_width(message, move || draw_term.width())); + + for known_length in [false, true] { + if known_length { + progress.set_length(total); + } + for position in [0, 5 * 1024 * 1024, 25 * 1024 * 1024, total] { + progress.set_elapsed(Duration::from_secs(2)); + progress.set_position(position); + assert_progress_row(&progress, &term, earlier); + progress.reset_eta(); + assert_progress_row(&progress, &term, earlier); + } + } + + progress.finish_and_clear(); + assert_eq!(term.contents(), earlier); + assert!(!term.moves_since_last_check().contains("Up(")); + term.write_line("B").unwrap(); + assert_eq!(term.contents(), vt_str::format!("{earlier}\nB").as_str()); + } + } + } + } + } + + fn assert_progress_row(progress: &ProgressBar, term: &InMemoryTerm, earlier: &str) { + progress.force_draw(); + let width = term.width(); + let screen = term.contents(); + let lines: Vec<_> = screen.lines().collect(); + assert_eq!(lines.len(), 2, "width {width}: {screen}"); + assert_eq!(lines[0], earlier); + assert!(measure_text_width(lines[1]) <= usize::from(width)); + let moves = term.moves_since_last_check(); + assert!(!moves.contains("Up("), "width {width}: {moves}"); + } + + #[test] + fn compact_layout_preserves_download_counts() { + for (width, has_bar) in [(40, false), (60, false), (80, true), (138, true)] { + let term = InMemoryTerm::new(5, width); + let progress = ProgressBar::with_draw_target( + None, + ProgressDrawTarget::term_like(Box::new(term.clone())), + ); + let draw_term = term.clone(); + progress.set_style( + Style::Download + .with_width("Downloading pnpm v11.24.0...", move || draw_term.width()), + ); + progress.set_position(25 * 1024 * 1024); + progress.reset_eta(); + progress.force_draw(); + assert!(term.contents().contains("25.00 MiB")); + + progress.set_length(55 * 1024 * 1024); + progress.force_draw(); + let screen = term.contents(); + assert!(screen.contains("25.00 MiB/55.00 MiB"), "{screen}"); + assert_eq!(screen.contains('['), has_bar, "{screen}"); + assert_eq!(screen.contains("0 B/s"), has_bar, "{screen}"); + if width >= 60 { + assert!(screen.contains("Downloading pnpm v11.24.0..."), "{screen}"); + } + } + } + + #[test] + fn layout_reads_the_current_width_on_each_draw() { + for style in [Style::Download, Style::Spinner] { + let width = Arc::new(AtomicU16::new(138)); + let term = InMemoryTerm::new(5, 138); + let progress = ProgressBar::with_draw_target( + None, + ProgressDrawTarget::term_like(Box::new(term.clone())), + ); + let draw_width = Arc::clone(&width); + progress.set_style(style.with_width("Downloading pnpm v11.24.0...", move || { + draw_width.load(Ordering::Relaxed) + })); + progress.set_position(25 * 1024 * 1024); + progress.reset_eta(); + + for known_length in [false, true] { + if known_length { + progress.set_length(55 * 1024 * 1024); + } + for columns in [138, 60, 40, 1, 80, 138] { + width.store(columns, Ordering::Relaxed); + progress.force_draw(); + let screen = term.contents(); + assert_eq!(screen.lines().count(), 1, "{screen}"); + assert!(measure_text_width(&screen) <= usize::from(columns), "{screen}"); + assert!(!term.moves_since_last_check().contains("Up(")); + } + } + } + } +} diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 7fb7ee5475..78386d4e10 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -20,8 +20,6 @@ # When dot-sourced, returns script-scoped InstallDir, ShimDir, CacheDir, ConfigDir, and StateDir. # These are resolved paths, not VP_* overrides for subsequent commands. -$ErrorActionPreference = "Stop" - $ViteVersion = if ($env:VP_VERSION) { $env:VP_VERSION } else { "latest" } # npm registry URL (strip trailing slash if present) $NpmRegistry = if ($env:NPM_CONFIG_REGISTRY) { $env:NPM_CONFIG_REGISTRY.TrimEnd('/') } else { "https://registry.npmjs.org" } @@ -422,10 +420,12 @@ function Invoke-InstallHandoff { param([string]$BinarySource) $previous = $env:VP_SELF_SETUP_SUPPORT_CHECK $previousShell = $env:VP_SELF_SETUP_SHELL + $previousVpShell = $env:VP_SHELL $previousRegistry = $env:NPM_CONFIG_REGISTRY try { Remove-Item Env:VP_SELF_SETUP_SUPPORT_CHECK -ErrorAction SilentlyContinue $env:VP_SELF_SETUP_SHELL = 'powershell' + if (-not $env:VP_SHELL) { $env:VP_SHELL = 'powershell' } # Preview dependencies must use the same registry as the downloaded binary. if ($PrVersion) { $env:NPM_CONFIG_REGISTRY = $BridgeRegistry @@ -441,12 +441,15 @@ function Invoke-InstallHandoff { } } finally { $env:VP_SELF_SETUP_SHELL = $previousShell + $env:VP_SHELL = $previousVpShell $env:NPM_CONFIG_REGISTRY = $previousRegistry $env:VP_SELF_SETUP_SUPPORT_CHECK = $previous } } +$previousErrorActionPreference = $ErrorActionPreference try { + $ErrorActionPreference = "Stop" Main } catch { if (Test-IsInstallStopException $_) { @@ -456,4 +459,6 @@ try { exit $global:LASTEXITCODE } throw +} finally { + $ErrorActionPreference = $previousErrorActionPreference }