See More

#!/bin/bash # Vite+ CLI Installer # https://vite.plus # # Usage: # curl -fsSL https://vite.plus | bash # # Environment variables: # VP_VERSION - Version to install (default: latest) # VP_HOME - Installation directory (default: ~/.vite-plus) # NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org) # VP_NODE_MANAGER - Set to "yes" or "no" to skip interactive prompt (for CI/devcontainers) # VP_LOCAL_TGZ - Path to local vite-plus.tgz (for development/testing) # VP_PR_VERSION - PR number or commit SHA to install from the registry bridge # (for temporary testing of unreleased builds, e.g. VP_PR_VERSION=1569). # When set, overrides VP_VERSION and installs the clearly-defined # 0.0.0-commit. build through the bridge instead of npm. set -e VP_VERSION="${VP_VERSION:-latest}" INSTALL_DIR="${VP_HOME:-$HOME/.vite-plus}" # Use $HOME-relative path for shell config references (portable across sessions) if case "$INSTALL_DIR" in "$HOME"/*) true;; *) false;; esac; then INSTALL_DIR_REF_POSIX="\$HOME${INSTALL_DIR#"$HOME"}" INSTALL_DIR_REF_NU="~${INSTALL_DIR#"$HOME"}" else INSTALL_DIR_REF_POSIX="$INSTALL_DIR" INSTALL_DIR_REF_NU="$INSTALL_DIR" fi # npm registry URL (strip trailing slash if present) NPM_REGISTRY="${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}" NPM_REGISTRY="${NPM_REGISTRY%/}" # Local tarball for development/testing LOCAL_TGZ="${VP_LOCAL_TGZ:-}" # Local binary path (set by install-global-cli.ts for local dev) LOCAL_BINARY="${VP_LOCAL_BINARY:-}" # PR number or commit SHA to install as a test build (registry bridge mode) PR_VERSION="${VP_PR_VERSION:-}" # Registry bridge that serves PR preview builds as clearly-versioned packages. # The pkg.pr.new-style download URL (BRIDGE_DOWNLOAD_BASE) 302-redirects to a # canonical 0.0.0-commit. tarball; the registry (BRIDGE_REGISTRY) resolves # those commit versions (and proxies everything else to npmjs) so a full install # pulls a coherent, clearly-defined test build. BRIDGE_DOWNLOAD_BASE="https://registry-bridge.viteplus.dev/voidzero-dev/vite-plus" BRIDGE_REGISTRY="https://registry-bridge.viteplus.dev/" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' BRIGHT_BLUE='\033[0;94m' BOLD='\033[1m' DIM='\033[2m' BOLD_BRIGHT_BLUE='\033[1;94m' NC='\033[0m' # No Color info() { echo -e "${BLUE}info${NC}: $1" } success() { echo -e "${GREEN}success${NC}: $1" } warn() { echo -e "${YELLOW}warn${NC}: $1" } error() { echo -e "${RED}error${NC}: $1" exit 1 } is_release_age_error() { local log_file="$1" [ -f "$log_file" ] || return 1 # This wrapper install path is pinned to pnpm via packageManager, so this # detection follows pnpm's resolver/reporter output rather than npm/yarn. # # pnpm's PnpmError prefixes internal codes with ERR_PNPM_, so # NO_MATURE_MATCHING_VERSION is normally printed as # ERR_PNPM_NO_MATURE_MATCHING_VERSION. npm-resolver emits that code with the # "does not meet the minimumReleaseAge constraint" message when # publishedBy/minimumReleaseAge rejects a matching version. # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/core/error/src/index.ts#L18-L20 # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/resolving/npm-resolver/src/index.ts#L76-L84 # # default-reporter may append guidance mentioning minimumReleaseAgeExclude # when the error has an immatureVersion, so that token is also a useful # release-age signal. minimum-release-age is pnpm's .npmrc key; npm's # min-release-age is intentionally not treated as a pnpm signal here. # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/cli/default-reporter/src/reportError.ts#L163-L164 # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/config/reader/src/types.ts#L73-L74 grep -Eqi 'ERR_PNPM_NO_MATURE_MATCHING_VERSION|NO_MATURE_MATCHING_VERSION|does not meet the minimumReleaseAge constraint|minimumReleaseAge|minimumReleaseAgeExclude|minimum release age|minimum-release-age' "$log_file" && return 0 # pnpm can also surface ERR_PNPM_NO_MATCHING_VERSION when minimumReleaseAge # filters out all candidates. That code is also used for real missing # versions, so require age-gate context before prompting for a bypass. # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/deps/inspection/outdated/src/createManifestGetter.ts#L66-L76 if grep -Eq 'ERR_PNPM_NO_MATCHING_VERSION' "$log_file"; then grep -Eqi 'minimumReleaseAge|minimumReleaseAgeExclude|minimum release age|minimum-release-age' "$log_file" return $? fi return 1 } confirm_release_age_override() { [ -e /dev/tty ] && [ -t 1 ] || return 1 echo "" > /dev/tty echo -e "${YELLOW}warn${NC}: Your minimumReleaseAge setting prevented installing vite-plus@${VP_VERSION}." > /dev/tty echo "This setting helps protect against newly published compromised packages." > /dev/tty echo "Proceeding will disable this protection for this Vite+ install only." > /dev/tty printf "Do you want to proceed? (y/N): " > /dev/tty local response read -r response < /dev/tty || return 1 case "$response" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac } write_release_age_override() { # Append idempotently so a bridge registry line written for PR builds survives. if [ ! -f "$VERSION_DIR/.npmrc" ] || ! grep -q '^minimum-release-age=' "$VERSION_DIR/.npmrc" 2>/dev/null; then printf 'minimum-release-age=0\n' >> "$VERSION_DIR/.npmrc" fi } normalize_existing_dir() { local dir="${1%/}" if [ -z "$dir" ]; then dir="/" fi if [ -d "$dir" ]; then (cd "$dir" 2>/dev/null && pwd -P) || printf '%s\n' "$dir" else local base parent_dir base="$(basename "$dir")" parent_dir="$(cd "$(dirname "$dir")" 2>/dev/null && pwd -P)" || parent_dir="" if [ -z "$parent_dir" ]; then printf '%s\n' "$dir" elif [ "$parent_dir" = "/" ]; then printf '/%s\n' "$base" else printf '%s/%s\n' "$parent_dir" "$base" fi fi } is_safe_install_dir_to_remove() { local dir="$1" [ -n "$dir" ] || return 1 case "$dir" in "/" | "$HOME" | "/bin" | "/opt" | "/usr" | "/usr/bin" | "/usr/local" | "/usr/local/bin") return 1 ;; esac return 0 } is_vite_plus_install_dir() { local dir="$1" [ -d "$dir" ] || return 1 [ -d "$dir/bin" ] || return 1 [ -e "$dir/current" ] || return 1 [ -e "$dir/bin/vp" ] || [ -e "$dir/bin/vp.exe" ] || [ -e "$dir/bin/vp.cmd" ] } detect_previous_install_dir() { [ -n "${VP_HOME:-}" ] || return 1 local vp_path vp_path="$(command -v vp 2>/dev/null || true)" [ -n "$vp_path" ] || return 1 case "$(basename "$vp_path")" in vp | vp.exe | vp.cmd) ;; *) return 1 ;; esac local old_dir install_dir old_dir="$(normalize_existing_dir "$(dirname "$(dirname "$vp_path")")")" install_dir="$(normalize_existing_dir "$INSTALL_DIR")" [ "$old_dir" != "$install_dir" ] || return 1 is_safe_install_dir_to_remove "$old_dir" || return 1 is_vite_plus_install_dir "$old_dir" || return 1 printf '%s\n' "$old_dir" } is_nested_install_dir() { [ -n "$1" ] && [ -n "$2" ] || return 1 local old_dir install_dir old_dir="$(normalize_existing_dir "$1")" install_dir="$(normalize_existing_dir "$2")" [ "$old_dir" != "$install_dir" ] || return 1 if [ "$old_dir" = "/" ] || [ "$install_dir" = "/" ]; then return 0 fi case "$old_dir" in "$install_dir"/*) return 0 ;; esac case "$install_dir" in "$old_dir"/*) return 0 ;; esac return 1 } prompt_remove_previous_install_dir() { local old_dir="$1" [ -n "$old_dir" ] || return 0 [ -z "${CI:-}" ] || return 0 [ -e /dev/tty ] && [ -t 1 ] || return 0 echo "" > /dev/tty echo -e "${YELLOW}warn${NC}: Found a previous Vite+ install at $old_dir." > /dev/tty echo "The new VP_HOME is $INSTALL_DIR." > /dev/tty printf "Remove the previous install directory? (y/N): " > /dev/tty local response read -r response < /dev/tty || return 0 case "$response" in y | Y | yes | YES) local vp_bin="$old_dir/current/bin/vp" if [ ! -f "$vp_bin" ]; then vp_bin="$old_dir/current/bin/vp.exe" fi if [ ! -f "$vp_bin" ]; then warn "Could not remove previous Vite+ install at $old_dir: vp binary not found." return 0 fi local implode_output if implode_output=$(VP_HOME="$old_dir" "$vp_bin" implode --yes 2>&1); then success "Removed previous Vite+ install at $old_dir." else warn "Could not remove previous Vite+ install at $old_dir." if [ -n "$implode_output" ]; then printf '%s\n' "$implode_output" >&2 fi fi ;; esac } # Resolve a PR number or commit SHA to the registry bridge's immutable commit # version (0.0.0-commit.). A full commit SHA maps directly to the bridge's # deterministic version; a PR number (or short ref) is resolved via the bridge # download URL's `x-commit-key: ::` header (HEAD). resolve_bridge_commit_version() { local ref="$1" local sha="$ref" if [[ ! "$ref" =~ ^[0-9a-fA-F]{40}$ ]]; then sha="$(curl -fsSIL "${BRIDGE_DOWNLOAD_BASE}@${ref}" 2>/dev/null | tr -d '\r' | awk -F ': ' ' tolower($1) == "x-commit-key" { count = split($2, parts, ":"); print parts[count]; exit }')" fi case "$sha" in '' | *[!0-9a-fA-F]*) return 1 ;; esac [ "${#sha}" -eq 40 ] || return 1 printf '0.0.0-commit.%s' "$sha" } print_install_failure() { local install_log="$1" if [ "${CI:-}" = "true" ]; then echo -e "${RED}error${NC}: Failed to install dependencies. Log output:" cat "$install_log" else echo -e "${RED}error${NC}: Failed to install dependencies. See log for details: $install_log" fi } print_release_age_failure() { local install_log="$1" if [ "${CI:-}" = "true" ]; then echo -e "${RED}error${NC}: Install blocked by your minimumReleaseAge setting. Log output:" cat "$install_log" else echo -e "${RED}error${NC}: Install blocked by your minimumReleaseAge setting. Wait until the package is old enough or adjust your package manager configuration explicitly. See log for details: $install_log" fi } # Print user-friendly error message for curl failures # Arguments: exit_code url print_curl_error() { local exit_code="$1" local url="$2" # Map curl exit codes to user-friendly messages local error_desc case $exit_code in 6) error_desc="DNS resolution failed - could not resolve hostname" ;; 7) error_desc="Connection refused - the server may be down or unreachable" ;; 28) error_desc="Connection timed out" ;; 35) error_desc="SSL/TLS connection error" ;; 60) error_desc="SSL certificate verification failed" ;; *) error_desc="Network error" ;; esac echo "" echo -e "${RED}error${NC}: ${error_desc} (curl exit code ${exit_code})" echo "" echo " This may be caused by:" echo " - Network connectivity issues" echo " - Firewall or proxy blocking the connection" echo " - DNS configuration problems" if [ $exit_code -eq 35 ] || [ $exit_code -eq 60 ]; then echo " - Outdated SSL/TLS libraries" fi echo "" if [ -n "$url" ]; then echo " Failed URL: $url" echo "" echo " To debug, run:" echo " curl -v \"$url\"" echo "" fi exit 1 } # Wrapper for curl with user-friendly error messages # Arguments: same as curl # Returns: exits with error message on failure, otherwise returns curl output curl_with_error_handling() { local url="" local args=() # Parse arguments to find the URL (for error messages) for arg in "$@"; do case "$arg" in http://*|https://*) url="$arg" ;; esac args+=("$arg") done # Run curl and capture exit code set +e local output exit_code output=$(curl "${args[@]}" 2>&1) exit_code=$? set -e if [ $exit_code -eq 0 ]; then echo "$output" return 0 fi print_curl_error "$exit_code" "$url" } # Detect libc type on Linux (gnu or musl) detect_libc() { # Prefer positive glibc detection first. # This avoids false musl detection on systems where musl is installed # but the distro itself is glibc-based (common on WSL/Ubuntu). if command -v getconf &> /dev/null; then if getconf GNU_LIBC_VERSION > /dev/null 2>&1; then echo "gnu" return fi fi # Check ldd output for musl/glibc if command -v ldd &> /dev/null; then ldd_out="$(ldd --version 2>&1 || true)" if echo "$ldd_out" | grep -qi musl; then echo "musl" return fi if echo "$ldd_out" | grep -qi 'gnu libc'; then echo "gnu" return fi if echo "$ldd_out" | grep -qi 'glibc'; then echo "gnu" return fi fi # Final fallback: musl loader present usually indicates musl-based distro, # but only check this after glibc detection to avoid false positives. if [ -e /lib/ld-musl-x86_64.so.1 ] || [ -e /lib/ld-musl-aarch64.so.1 ]; then echo "musl" else echo "gnu" fi } # Detect platform detect_platform() { local os arch os="$(uname -s)" arch="$(uname -m)" case "$os" in Darwin) os="darwin" ;; Linux) os="linux" ;; MINGW*|MSYS*|CYGWIN*) os="win32" ;; *) error "Unsupported operating system: $os" ;; esac case "$arch" in x86_64|amd64) arch="x64" ;; arm64|aarch64) arch="arm64" ;; *) error "Unsupported architecture: $arch" ;; esac # For Linux, append libc type to distinguish gnu vs musl if [ "$os" = "linux" ]; then local libc libc=$(detect_libc) echo "${os}-${arch}-${libc}" else echo "${os}-${arch}" fi } # Check for required commands check_requirements() { local missing=() if ! command -v curl &> /dev/null; then missing+=("curl") fi if ! command -v tar &> /dev/null; then missing+=("tar") fi if [ ${#missing[@]} -ne 0 ]; then error "Missing required commands: ${missing[*]}" fi } # Fetch package metadata from npm registry (cached for reuse) # Uses VP_VERSION to fetch the correct version's metadata PACKAGE_METADATA="" fetch_package_metadata() { if [ -z "$PACKAGE_METADATA" ]; then local version_path metadata_url if [ "$VP_VERSION" = "latest" ]; then version_path="latest" else version_path="$VP_VERSION" fi metadata_url="${NPM_REGISTRY}/vite-plus/${version_path}" PACKAGE_METADATA=$(curl_with_error_handling -s "$metadata_url") if [ -z "$PACKAGE_METADATA" ]; then error "Failed to fetch package metadata from: $metadata_url" fi # Check for npm registry error response # npm can return either {"error":"..."} or a plain JSON string like "version not found: test" if echo "$PACKAGE_METADATA" | grep -q '"error"'; then local error_msg error_msg=$(echo "$PACKAGE_METADATA" | grep -o '"error" *: *"[^"]*"' | cut -d'"' -f4) error "Failed to fetch version '${version_path}': ${error_msg:-unknown error}\n URL: $metadata_url" fi # Check if response is a plain error string (not a valid package object) # Use '"version":' to match JSON property, not just the word "version" if ! echo "$PACKAGE_METADATA" | grep -q '"version" *:'; then # Remove surrounding quotes from the error message if present local error_msg error_msg=$(echo "$PACKAGE_METADATA" | sed 's/^"//;s/"$//') error "Failed to fetch version '${version_path}': ${error_msg:-unknown error}\n URL: $metadata_url" fi fi # PACKAGE_METADATA is set as a global variable, no need to echo } # Get the version from package metadata # Sets RESOLVED_VERSION global variable get_version_from_metadata() { # Call fetch_package_metadata to populate PACKAGE_METADATA global # Don't use command substitution as it would swallow the exit from error() fetch_package_metadata RESOLVED_VERSION=$(echo "$PACKAGE_METADATA" | grep -o '"version" *: *"[^"]*"' | head -1 | cut -d'"' -f4) if [ -z "$RESOLVED_VERSION" ]; then error "Failed to extract version from package metadata" fi } # Get platform suffix for CLI package download # Sets PLATFORM_SUFFIX global variable # Platform format from detect_platform(): darwin-arm64, darwin-x64, linux-x64-gnu, linux-arm64-gnu, win32-x64, etc. # CLI package format: @voidzero-dev/vite-plus-cli-darwin-arm64, @voidzero-dev/vite-plus-cli-linux-x64-gnu, etc. get_platform_suffix() { local platform="$1" case "$platform" in win32-*) PLATFORM_SUFFIX="${platform}-msvc" ;; # Windows needs -msvc suffix *) PLATFORM_SUFFIX="$platform" ;; # macOS/Linux map directly esac } # Download and extract file (silent mode - no progress bar) download_and_extract() { local url="$1" local dest_dir="$2" local strip_components="$3" local filter="$4" # Download to temp file (silent mode) local temp_file temp_file=$(mktemp) # Run curl and capture exit code for error handling set +e curl -sL "$url" -o "$temp_file" local exit_code=$? set -e if [ $exit_code -ne 0 ]; then rm -f "$temp_file" print_curl_error "$exit_code" "$url" fi if [ -n "$filter" ]; then tar xzf "$temp_file" -C "$dest_dir" --strip-components="$strip_components" "$filter" 2>/dev/null || \ tar xzf "$temp_file" -C "$dest_dir" --strip-components="$strip_components" else tar xzf "$temp_file" -C "$dest_dir" --strip-components="$strip_components" fi rm -f "$temp_file" } join_by() { local separator="$1" shift local result="" local item for item in "$@"; do if [ -z "$result" ]; then result="$item" else result="${result}${separator}${item}" fi done printf '%s\n' "$result" } abbreviate_path() { local path="$1" if [ "${path#"$HOME"}" != "$path" ]; then printf '~%s\n' "${path#"$HOME"}" else printf '%s\n' "$path" fi } record_shell_summary() { local shell_name="$1" local status="$2" SHELL_CONFIG_SUMMARY+=(" - ${shell_name}: ${status}") } # Add a sourcing line to an existing shell config file. # Returns: 0 = line added, 1 = file missing, 2 = already configured, 3 = failed append_source_to_file() { local shell_config="$1" local source_line="$2" shift 2 local search_patterns=("$@") local pattern if [ ! -f "$shell_config" ]; then return 1 fi if [ ! -w "$shell_config" ]; then warn "Cannot write to $shell_config (permission denied), skipping." return 3 fi for pattern in "${search_patterns[@]}"; do if grep -Fq "$pattern" "$shell_config" 2>/dev/null; then return 2 fi done echo "" >> "$shell_config" echo "# Vite+ bin (https://viteplus.dev)" >> "$shell_config" echo "$source_line" >> "$shell_config" return 0 } # Create or update an installer-managed snippet file. # Returns: 0 = written, 2 = already configured, 3 = failed write_managed_snippet() { local snippet_file="$1" local snippet_content="$2" local snippet_dir snippet_dir=$(dirname "$snippet_file") if ! mkdir -p "$snippet_dir" 2>/dev/null; then warn "Cannot create $snippet_dir, skipping." return 3 fi if [ -f "$snippet_file" ] && [ ! -w "$snippet_file" ]; then warn "Cannot write to $snippet_file (permission denied), skipping." return 3 fi if [ -f "$snippet_file" ] && printf '%s' "$snippet_content" | cmp -s - "$snippet_file"; then return 2 fi if ! printf '%s' "$snippet_content" > "$snippet_file"; then warn "Cannot write to $snippet_file, skipping." return 3 fi return 0 } # Discover Nushell's preferred user-local vendor autoload directory. # Nushell puts the user-local directory at the end of the list. discover_nushell_vendor_autoload_dir() { command -v nu > /dev/null 2>&1 || return 1 local nu_dirs_output nu_dirs_output=$(nu -c '$nu.vendor-autoload-dirs | reverse | each {|dir| $dir } | str join (char nl)' 2>/dev/null) || return 1 while IFS= read -r dir; do [ -n "$dir" ] || continue printf '%s\n' "$dir" return 0 done </dev/null; then warn "Cannot create $zsh_dir, skipping zsh." SHELL_CONFIG_HAS_FAILURE="true" SHELL_CONFIG_FAILED_SHELLS+=("zsh") record_shell_summary "zsh" "failed (could not create $(abbreviate_path "$zsh_dir"))" return fi if [ ! -f "$zshenv" ] && ! touch "$zshenv" 2>/dev/null; then warn "Cannot create $zshenv, skipping zsh." SHELL_CONFIG_HAS_FAILURE="true" SHELL_CONFIG_FAILED_SHELLS+=("zsh") record_shell_summary "zsh" "failed (could not create $(abbreviate_path "$zshenv"))" return fi result=0 append_source_to_file "$zshenv" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? case "$result" in 0) updated+=("$(abbreviate_path "$zshenv")") ;; 2) already+=("$(abbreviate_path "$zshenv")") ;; 3) failed+=("$(abbreviate_path "$zshenv")") ;; esac if [ -f "$zshrc" ]; then result=0 append_source_to_file "$zshrc" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? case "$result" in 0) updated+=("$(abbreviate_path "$zshrc")") ;; 2) already+=("$(abbreviate_path "$zshrc")") ;; 3) failed+=("$(abbreviate_path "$zshrc")") ;; esac fi local details=() if [ ${#updated[@]} -gt 0 ]; then SHELL_CONFIG_HAS_UPDATED="true" SHELL_CONFIG_HAS_CONFIGURED="true" details+=("updated $(join_by ', ' "${updated[@]}")") fi if [ ${#already[@]} -gt 0 ]; then SHELL_CONFIG_HAS_CONFIGURED="true" details+=("already configured $(join_by ', ' "${already[@]}")") fi if [ ${#failed[@]} -gt 0 ]; then SHELL_CONFIG_HAS_FAILURE="true" SHELL_CONFIG_FAILED_SHELLS+=("zsh") details+=("failed $(join_by ', ' "${failed[@]}")") fi if [ ${#details[@]} -eq 0 ]; then record_shell_summary "zsh" "skipped" else record_shell_summary "zsh" "$(join_by '; ' "${details[@]}")" fi } configure_bash_path() { local updated=() local already=() local failed=() local existing=0 local file result for file in "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile"; do if [ ! -f "$file" ]; then continue fi existing=1 result=0 append_source_to_file "$file" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? case "$result" in 0) updated+=("$(abbreviate_path "$file")") ;; 2) already+=("$(abbreviate_path "$file")") ;; 3) failed+=("$(abbreviate_path "$file")") ;; esac done if [ "$existing" -eq 0 ]; then record_shell_summary "bash" "skipped (no existing rc files)" return fi local details=() if [ ${#updated[@]} -gt 0 ]; then SHELL_CONFIG_HAS_UPDATED="true" SHELL_CONFIG_HAS_CONFIGURED="true" details+=("updated $(join_by ', ' "${updated[@]}")") fi if [ ${#already[@]} -gt 0 ]; then SHELL_CONFIG_HAS_CONFIGURED="true" details+=("already configured $(join_by ', ' "${already[@]}")") fi if [ ${#failed[@]} -gt 0 ]; then SHELL_CONFIG_HAS_FAILURE="true" SHELL_CONFIG_FAILED_SHELLS+=("bash") details+=("failed $(join_by ', ' "${failed[@]}")") fi record_shell_summary "bash" "$(join_by '; ' "${details[@]}")" } configure_fish_path() { local fish_config="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/vite-plus.fish" local fish_content="# Vite+ bin (https://viteplus.dev) source \"$INSTALL_DIR_REF_POSIX/env.fish\" " local result=0 write_managed_snippet "$fish_config" "$fish_content" || result=$? case "$result" in 0) SHELL_CONFIG_HAS_UPDATED="true" SHELL_CONFIG_HAS_CONFIGURED="true" record_shell_summary "fish" "updated $(abbreviate_path "$fish_config")" ;; 2) SHELL_CONFIG_HAS_CONFIGURED="true" record_shell_summary "fish" "already configured $(abbreviate_path "$fish_config")" ;; *) SHELL_CONFIG_HAS_FAILURE="true" SHELL_CONFIG_FAILED_SHELLS+=("fish") record_shell_summary "fish" "failed $(abbreviate_path "$fish_config")" ;; esac } configure_nushell_path() { local nushell_dir nushell_dir=$(discover_nushell_vendor_autoload_dir 2>/dev/null) || true if [ -z "$nushell_dir" ]; then SHELL_CONFIG_HAS_FAILURE="true" SHELL_CONFIG_FAILED_SHELLS+=("nushell") record_shell_summary "nushell" "failed (could not determine vendor autoload dir)" return fi local nushell_autoload="$nushell_dir/vite-plus.nu" local nushell_content="# Vite+ bin (https://viteplus.dev) source '$INSTALL_DIR_REF_NU/env.nu' " local result=0 write_managed_snippet "$nushell_autoload" "$nushell_content" || result=$? case "$result" in 0) SHELL_CONFIG_HAS_UPDATED="true" SHELL_CONFIG_HAS_CONFIGURED="true" record_shell_summary "nushell" "updated $(abbreviate_path "$nushell_autoload")" ;; 2) SHELL_CONFIG_HAS_CONFIGURED="true" record_shell_summary "nushell" "already configured $(abbreviate_path "$nushell_autoload")" ;; *) SHELL_CONFIG_HAS_FAILURE="true" SHELL_CONFIG_FAILED_SHELLS+=("nushell") record_shell_summary "nushell" "failed $(abbreviate_path "$nushell_autoload")" ;; esac } # Configure supported shell PATH integrations for all installed shells. configure_shell_path() { SHELL_CONFIG_SUMMARY=() SHELL_CONFIG_FAILED_SHELLS=() SHELL_CONFIG_HAS_UPDATED="false" SHELL_CONFIG_HAS_CONFIGURED="false" SHELL_CONFIG_HAS_FAILURE="false" if command -v zsh > /dev/null 2>&1; then configure_zsh_path else record_shell_summary "zsh" "skipped (not installed)" fi if command -v bash > /dev/null 2>&1; then configure_bash_path else record_shell_summary "bash" "skipped (not installed)" fi if command -v fish > /dev/null 2>&1; then configure_fish_path else record_shell_summary "fish" "skipped (not installed)" fi if command -v nu > /dev/null 2>&1; then configure_nushell_path else record_shell_summary "nushell" "skipped (not installed)" fi } # Run vp env setup --refresh, showing output only on failure # Arguments: vp_bin - path to the vp binary refresh_shims() { local vp_bin="$1" local setup_output if ! setup_output=$("$vp_bin" env setup --refresh 2>&1); then warn "Failed to refresh shims:" echo "$setup_output" >&2 fi } # Setup Node.js version manager (node/npm/npx/corepack shims) # Sets NODE_MANAGER_ENABLED global # Arguments: bin_dir - path to the version's bin directory containing vp setup_node_manager() { local bin_dir="$1" local bin_path="$INSTALL_DIR/bin" NODE_MANAGER_ENABLED="false" # Resolve vp binary name (vp on Unix, vp.exe on Windows) local vp_bin="$bin_dir/vp" if [ -f "$bin_dir/vp.exe" ]; then vp_bin="$bin_dir/vp.exe" fi # Explicit override via environment variable if [ "$VP_NODE_MANAGER" = "yes" ]; then refresh_shims "$vp_bin" NODE_MANAGER_ENABLED="true" return 0 elif [ "$VP_NODE_MANAGER" = "no" ]; then NODE_MANAGER_ENABLED="false" return 0 fi # Check if Vite+ is already managing Node.js (bin/node or bin/node.exe exists) if [ -e "$bin_path/node" ] || [ -e "$bin_path/node.exe" ]; then refresh_shims "$vp_bin" NODE_MANAGER_ENABLED="already" return 0 fi # Auto-enable on CI or devcontainer environments # CI: standard CI environment variable (GitHub Actions, Travis, CircleCI, etc.) # CODESPACES: set by GitHub Codespaces (https://docs.github.com/en/codespaces) # REMOTE_CONTAINERS: set by VS Code Dev Containers extension # DEVPOD: set by DevPod (https://devpod.sh) if [ -n "$CI" ] || [ -n "$CODESPACES" ] || [ -n "$REMOTE_CONTAINERS" ] || [ -n "$DEVPOD" ]; then refresh_shims "$vp_bin" NODE_MANAGER_ENABLED="true" return 0 fi # Check if node is available on the system local node_available="false" if command -v node &> /dev/null; then node_available="true" fi # Auto-enable if no node available on system if [ "$node_available" = "false" ]; then refresh_shims "$vp_bin" NODE_MANAGER_ENABLED="true" return 0 fi # Prompt user in interactive mode if [ -e /dev/tty ] && [ -t 1 ]; then echo "" echo "Would you like Vite+ to manage your Node.js versions?" echo "It adds \`node\`, \`npm\`, \`npx\`, and \`corepack\` shims to $(abbreviate_path "$INSTALL_DIR")/bin/ and automatically uses the right version." echo "Opt out anytime with \`vp env off\`." echo -n "Press Enter to accept (Y/n): " read -r response < /dev/tty if [ -z "$response" ] || [ "$response" = "y" ] || [ "$response" = "Y" ]; then refresh_shims "$vp_bin" NODE_MANAGER_ENABLED="true" fi fi } # Cleanup old versions, keeping only the most recent ones cleanup_old_versions() { local max_versions=3 local versions=() # List version directories (semver format like 0.1.0, 1.2.3-beta.1, 0.0.0-f48af939.20260205-0533) # This excludes 'current' symlink and non-semver directories like 'local-dev' local semver_regex='^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$' for dir in "$INSTALL_DIR"/*/; do local name name=$(basename "$dir") if [ -d "$dir" ] && [[ "$name" =~ $semver_regex ]]; then versions+=("$dir") fi done local count=${#versions[@]} if [ "$count" -le "$max_versions" ]; then return 0 fi # Sort by creation time (oldest first) and delete excess local sorted_versions if [[ "$OSTYPE" == "darwin"* ]]; then # macOS: use stat -f %B for birth time sorted_versions=$(for v in "${versions[@]}"; do echo "$(stat -f %B "$v") $v" done | sort -n | head -n $((count - max_versions)) | cut -d' ' -f2-) else # Linux: use stat -c %W for birth time, fallback to %Y (mtime) sorted_versions=$(for v in "${versions[@]}"; do local btime btime=$(stat -c %W "$v" 2>/dev/null) if [ "$btime" = "0" ] || [ -z "$btime" ]; then btime=$(stat -c %Y "$v") fi echo "$btime $v" done | sort -n | head -n $((count - max_versions)) | cut -d' ' -f2-) fi # Delete oldest versions (silently) for old_version in $sorted_versions; do rm -rf "$old_version" done } main() { echo "" echo -e "Setting up VITE+..." if [ -n "$PR_VERSION" ] && [ -n "$LOCAL_TGZ" ]; then error "VP_PR_VERSION and VP_LOCAL_TGZ cannot be used together" fi check_requirements local previous_install_dir previous_install_dir="$(detect_previous_install_dir || true)" if [ -n "$previous_install_dir" ] && is_nested_install_dir "$previous_install_dir" "$INSTALL_DIR"; then error "Previous Vite+ install at $previous_install_dir overlaps with VP_HOME $INSTALL_DIR. Choose a separate VP_HOME or remove the previous install first." fi local platform platform=$(detect_platform) # Local development mode: use local tgz if [ -n "$LOCAL_TGZ" ]; then # Validate local tgz if [ ! -f "$LOCAL_TGZ" ]; then error "Local tarball not found: $LOCAL_TGZ" fi # Use version as-is (default to "local-dev") if [ "$VP_VERSION" = "latest" ] || [ "$VP_VERSION" = "test" ]; then VP_VERSION="local-dev" fi elif [ -n "$PR_VERSION" ]; then # Registry bridge mode: resolve the requested PR/SHA to the bridge's # immutable commit version (0.0.0-commit.), the clearly-defined test # version we install. The directory label stays non-semver so it keeps out # of cleanup_old_versions and makes the PR build obvious in `~/.vite-plus/`. # `|| true` keeps `set -e` from aborting this assignment when resolution # fails (unregistered ref / transient bridge error), so the actionable # error below is reachable instead of the installer exiting silently. PR_COMMIT_VERSION="$(resolve_bridge_commit_version "$PR_VERSION" || true)" if [ -z "$PR_COMMIT_VERSION" ]; then error "Could not resolve a registry bridge build for ${PR_VERSION}" fi VP_VERSION="pkg-pr-new-${PR_VERSION}" info "Using registry bridge build: ${PR_COMMIT_VERSION}" else # Fetch package metadata and resolve version from npm get_version_from_metadata VP_VERSION="$RESOLVED_VERSION" fi # Set up version-specific directories VERSION_DIR="$INSTALL_DIR/$VP_VERSION" BIN_DIR="$VERSION_DIR/bin" CURRENT_LINK="$INSTALL_DIR/current" local binary_name="vp" if [[ "$platform" == win32* ]]; then binary_name="vp.exe" fi # Create bin directory mkdir -p "$BIN_DIR" if [ -n "$LOCAL_TGZ" ]; then # Local development mode: only need the binary info "Using local tarball: $LOCAL_TGZ" # Copy binary from LOCAL_BINARY env var (set by install-global-cli.ts) if [ -n "$LOCAL_BINARY" ]; then cp "$LOCAL_BINARY" "$BIN_DIR/$binary_name" # On Windows, also copy the trampoline shim binary if available if [[ "$platform" == win32* ]]; then local shim_src shim_src="$(dirname "$LOCAL_BINARY")/vp-shim.exe" if [ -f "$shim_src" ]; then cp "$shim_src" "$BIN_DIR/vp-shim.exe" fi fi else error "VP_LOCAL_BINARY must be set when using VP_LOCAL_TGZ" fi chmod +x "$BIN_DIR/$binary_name" else # Download CLI platform tarball — npm registry or registry bridge (when PR_VERSION is set) get_platform_suffix "$platform" local platform_url if [ -n "$PR_VERSION" ]; then # The registry bridge redirects this URL to the platform tarball for the # matching commit build (0.0.0-commit.). platform_url="${BRIDGE_DOWNLOAD_BASE}/@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}@${PR_VERSION}" else local package_name="@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}" platform_url="${NPM_REGISTRY}/${package_name}/-/vite-plus-cli-${PLATFORM_SUFFIX}-${VP_VERSION}.tgz" fi # Create temp directory for extraction local platform_temp_dir platform_temp_dir=$(mktemp -d) download_and_extract "$platform_url" "$platform_temp_dir" 1 # Copy binary to BIN_DIR cp "$platform_temp_dir/$binary_name" "$BIN_DIR/" chmod +x "$BIN_DIR/$binary_name" # On Windows, also copy the trampoline shim binary if present in the package if [[ "$platform" == win32* ]] && [ -f "$platform_temp_dir/vp-shim.exe" ]; then cp "$platform_temp_dir/vp-shim.exe" "$BIN_DIR/" fi rm -rf "$platform_temp_dir" fi # Generate wrapper package.json that declares vite-plus as a dependency. # pnpm will install vite-plus and all transitive deps via `vp install`. # The packageManager field pins pnpm to a known-good version, ensuring # consistent behavior regardless of the user's global pnpm version. # In PR mode, pin vite-plus to the bridge's clearly-defined commit version and # resolve it (plus its platform binaries and transitive deps) through the # bridge registry written to .npmrc below. The bridge rewrites a preview # tarball's transitive deps to versions, not self-contained URLs, so a full # install must go through the registry rather than the bare download URL. local vite_plus_spec="$VP_VERSION" if [ -n "$PR_VERSION" ]; then vite_plus_spec="$PR_COMMIT_VERSION" # Resolve the commit version + platform binaries through the bridge. Drop any # stale wrapper lockfile: the pkg-pr-new- dir is reused across a PR's # commits and install.sh rewrites this package.json each run, so a leftover # lockfile pinning a prior spec would fail `vp install` with # ERR_PNPM_OUTDATED_LOCKFILE under CI's frozen-lockfile default. Removing it # lets the install regenerate a lockfile matching the spec we just wrote. printf 'registry=%s\n' "$BRIDGE_REGISTRY" > "$VERSION_DIR/.npmrc" rm -f "$VERSION_DIR/pnpm-lock.yaml" fi cat > "$VERSION_DIR/package.json" < "$install_log" 2>&1); then if is_release_age_error "$install_log"; then if confirm_release_age_override; then # Write the override only after explicit consent, then retry once. write_release_age_override if ! (cd "$VERSION_DIR" && CI=true "$vp_install_bin" install > "$install_log" 2>&1); then print_install_failure "$install_log" exit 1 fi else print_release_age_failure "$install_log" exit 1 fi else print_install_failure "$install_log" exit 1 fi fi fi # Create/update current symlink (use relative path for portability) ln -sfn "$VP_VERSION" "$CURRENT_LINK" # Create bin directory and vp entrypoint (always done) mkdir -p "$INSTALL_DIR/bin" if [[ "$platform" == win32* ]]; then # Windows: copy trampoline as vp.exe (matching install.ps1) if [ -f "$INSTALL_DIR/current/bin/vp-shim.exe" ]; then cp "$INSTALL_DIR/current/bin/vp-shim.exe" "$INSTALL_DIR/bin/vp.exe" fi else # Unix: symlink to current/bin/vp ln -sf "../current/bin/vp" "$INSTALL_DIR/bin/vp" fi # Cleanup old versions cleanup_old_versions # Create env files with PATH guard (prevents duplicate PATH entries) # Use current/bin/vp directly (the real binary) instead of bin/vp (trampoline) # to avoid the self-overwrite issue on Windows during --refresh local vp_bin="$INSTALL_DIR/current/bin/vp" if [[ "$platform" == win32* ]]; then vp_bin="$INSTALL_DIR/current/bin/vp.exe" fi "$vp_bin" env setup --env-only > /dev/null # Setup Node.js version manager (shims) - separate component setup_node_manager "$BIN_DIR" prompt_remove_previous_install_dir "$previous_install_dir" # Configure shell PATH after the install is otherwise complete. configure_shell_path # Use ~ shorthand if install dir is under HOME, otherwise show full path local display_dir="${INSTALL_DIR/#$HOME/~}" local display_location="${display_dir}/bin" # Print success message echo "" echo -e "${GREEN}✔${NC} ${BOLD_BRIGHT_BLUE}VITE+${NC} successfully installed!" echo "" echo " The Unified Toolchain for the Web." echo "" echo -e " ${BOLD}Get started:${NC}" echo -e " ${BRIGHT_BLUE}vp create${NC} Create a new project" echo -e " ${BRIGHT_BLUE}vp env${NC} Manage Node.js versions" echo -e " ${BRIGHT_BLUE}vp install${NC} Install dependencies" echo -e " ${BRIGHT_BLUE}vp migrate${NC} Migrate to Vite+" if [ "$NODE_MANAGER_ENABLED" = "true" ] || [ "$NODE_MANAGER_ENABLED" = "already" ]; then echo "" echo -e " Vite+ is now managing Node.js via ${BRIGHT_BLUE}vp env${NC}." echo -e " Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup, or ${BRIGHT_BLUE}vp env off${NC} to opt out." fi echo "" echo -e " Run ${BRIGHT_BLUE}vp help${NC} to see available commands." echo "" echo " Shell configuration:" local summary_line for summary_line in "${SHELL_CONFIG_SUMMARY[@]}"; do echo "$summary_line" done # Show restart note if any shell config was updated if [ "$SHELL_CONFIG_HAS_UPDATED" = "true" ]; then echo "" echo " Note: Restart your terminal to load updated shell configuration." fi # Show manual PATH instructions if no shell was configured or any shell failed if [ "$SHELL_CONFIG_HAS_CONFIGURED" = "false" ] || [ "$SHELL_CONFIG_HAS_FAILURE" = "true" ]; then echo "" echo -e " ${YELLOW}note${NC}: Some shells still need manual setup." echo "" echo -e " vp was installed to: ${BOLD}${display_location}${NC}" echo "" echo " Manual setup instructions:" echo " - Bash/Zsh: add the following to your shell config (~/.bashrc, ~/.zshrc, etc.):" echo " . \"$INSTALL_DIR_REF_POSIX/env\"" echo " - Fish: create ${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/vite-plus.fish with:" echo " source \"$INSTALL_DIR_REF_POSIX/env.fish\"" echo " - Nushell: create a vendor autoload file with:" echo " source '$INSTALL_DIR_REF_NU/env.nu'" echo "" echo " Or run vp directly:" echo "" echo -e " ${display_location}/vp" fi echo "" } main "$@"