Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6713756
feat(env): add shell activation guidance and reminders
fengmk2 Sep 19, 2026
e179b6e
fix(env): suppress activation reminders in delegated commands
fengmk2 Sep 19, 2026
03d5347
refactor(env): limit activation guidance to setup
fengmk2 Sep 19, 2026
1e63436
fix(env): skip profile guidance when already configured
fengmk2 Sep 19, 2026
071770e
refactor(env): simplify setup guidance and fixtures
fengmk2 Sep 19, 2026
4a547e0
fix(env): qualify shell profile activation guidance
fengmk2 Sep 19, 2026
d3e7151
test(env): isolate interactive Bash profile sessions
fengmk2 Sep 19, 2026
f371c5c
test(env): suppress Ubuntu startup hint in profile fixture
fengmk2 Sep 19, 2026
c6d2fc3
fix(env): preserve PowerShell activation state and priority
fengmk2 Sep 19, 2026
1184855
fix(test): repair PowerShell activation CI coverage
fengmk2 Sep 19, 2026
217e0dd
fix(env): retain Zsh and cmd activation guidance
fengmk2 Sep 19, 2026
1b5c696
fix(test): run login Zsh directly in its PTY
fengmk2 Sep 19, 2026
998e3ed
fix(test): skip global completion prompts in Zsh profile test
fengmk2 Sep 19, 2026
b5357e0
fix(env): tailor activation instructions to available shells
fengmk2 Sep 19, 2026
a475844
feat(install): show dependency installation progress
fengmk2 Sep 20, 2026
66d4a5c
refactor(progress): share spinner and download rendering
fengmk2 Sep 20, 2026
d4fc4f1
refactor(env): simplify shell activation guidance
fengmk2 Sep 20, 2026
6b3091d
fix(env): adapt activation to fallback shim layout
fengmk2 Sep 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion .github/scripts/test-install-bootstrap.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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') {
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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 ?? '<unset>'}:`);
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 ?? '<unset>'}:`);
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'));
}
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading