Implemented
Currently, the vite+ global CLI (vite-plus-cli in packages/global) uses Node.js as its entry point:
bin/vite (shell script) → src/index.ts (Node.js) → Rust bindings (NAPI)
This architecture requires users to have Node.js pre-installed before they can use the global CLI. While the core functionality is already implemented in Rust via NAPI bindings, the Node.js requirement creates friction for new users who want to try vite+.
- Installation Prerequisite: Users must install Node.js before using vite+
- Version Compatibility: Different Node.js versions may cause compatibility issues
- Onboarding Friction: New users cannot simply download and run the CLI
- Distribution Complexity: Need to manage both npm package and native bindings
The vite_js_runtime crate already provides robust Node.js download and management capabilities:
- Automatic Node.js version resolution and download
- Multi-platform support (Linux, macOS, Windows; x64, arm64)
- Intelligent caching with ETag support
- Hash verification for security
- Per-project version control via
devEngines.runtimein package.json
By making the global CLI a Rust binary entry point:
- Users can download and run it immediately without pre-installing Node.js
- Projects control their JS runtime version via
devEngines.runtimeconfiguration - Consistent development environments across teams - everyone uses the same runtime version
- No system-wide Node.js conflicts - each project can specify its required version
The core innovation is enhancing JS runtime management, not eliminating Node.js usage. The CLI will automatically download and manage Node.js to execute package managers and JS scripts.
- Remove Node.js installation prerequisite: Create a standalone Rust binary that users can download and run immediately, without needing to pre-install Node.js on their system
- Enhanced JS Runtime Management: Use
vite_js_runtimeto automatically download, cache, and manage Node.js versions, enabling:- Automatic Node.js provisioning for package manager and CLI operations
- Per-project runtime version control via
devEngines.runtimein package.json - Consistent runtime versions across development environments
- Maintain current functionality: All commands from
packages/globalcontinue to work via bundled JS scripts - Maintain backward compatibility: Existing command-line interface and behaviors remain unchanged
- Cross-platform distribution: Support Linux, macOS, and Windows via platform-specific binaries
- Replacing the local CLI (
packages/cli) - that remains a Node.js package - Removing the NAPI bindings - they will coexist for the local CLI use case
- Changing the command syntax or behavior
- Supporting JavaScript-only execution mode (always uses managed runtime)
# Before (requires Node.js)
npm install -g vite-plus-cli
vp create my-app
# After (no Node.js required)
curl -fsSL https://vite.plus | bash
# or
brew install vite-plus
# or download binary directly
vp create my-app # Works immediately# User runs install command (no Node.js pre-installed on system)
vp install lodash
# CLI automatically:
# 1. Checks if managed Node.js is cached
# 2. Downloads Node.js 22.22.0 if not present
# 3. Detects workspace package manager (pnpm/npm/yarn)
# 4. Downloads package manager if needed
# 5. Executes: node /path/to/pnpm install lodashNote: Package managers (pnpm, npm, yarn) are Node.js programs, so the CLI uses managed Node.js to run them. The key benefit is that users don't need to pre-install Node.js - the CLI handles it automatically.
# User runs a command that needs JS
vp create --template create-vite my-app
# CLI automatically:
# 1. Checks if managed Node.js is cached
# 2. Downloads Node.js 22.22.0 if not present
# 3. Executes create-vite using managed Node.jsCreate a new crate at crates/vite_global_cli that compiles to a standalone binary.
crates/
├── vite_global_cli/ # New crate
│ ├── Cargo.toml
│ └── src/
│ ├── main.rs # Entry point
│ ├── cli.rs # CLI parsing (clap)
│ ├── commands/ # Command implementations
│ │ ├── mod.rs
│ │ ├── pm.rs # Package manager commands
│ │ ├── new.rs # Project scaffolding
│ │ ├── migrate.rs # Migration command
│ │ └── ...
│ ├── js_executor.rs # JS execution via vite_js_runtime
│ └── workspace.rs # Workspace detection (reuse from vite_task)
├── vite_js_runtime/ # Existing - Node.js management
├── vite_task/ # Existing - Task execution
└── ...
Based on the current global CLI analysis, commands fall into four categories:
These commands wrap existing package managers (pnpm/npm/yarn), which are Node.js programs. The Rust CLI handles argument parsing and workspace detection, then uses managed Node.js to execute the actual package manager:
| Command | Description | Implementation |
|---|---|---|
install [packages] |
Install dependencies | Rust CLI → Managed Node.js → pnpm/npm/yarn |
add <packages> |
Add packages | Rust CLI → Managed Node.js → pnpm/npm/yarn |
remove <packages> |
Remove packages | Rust CLI → Managed Node.js → pnpm/npm/yarn |
update [packages] |
Update packages | Rust CLI → Managed Node.js → pnpm/npm/yarn |
outdated [packages] |
Check outdated | Rust CLI → Managed Node.js → pnpm/npm/yarn |
dedupe |
Deduplicate deps | Rust CLI → Managed Node.js → pnpm/npm/yarn |
why <package> |
Explain dependency | Rust CLI → Managed Node.js → pnpm/npm/yarn |
info <package> |
View package info | Rust CLI → Managed Node.js → pnpm/npm/yarn |
link [package] |
Link packages | Rust CLI → Managed Node.js → pnpm/npm/yarn |
unlink [package] |
Unlink packages | Rust CLI → Managed Node.js → pnpm/npm/yarn |
dlx <package> |
Execute package | Rust CLI → Managed Node.js → pnpm/npm dlx |
pm <subcommand> |
Forward to PM | Rust CLI → Managed Node.js → pnpm/npm/yarn |
Note: Since pnpm, npm, and yarn are all Node.js programs, these commands require Node.js to execute. The global CLI will use vite_js_runtime to download and manage Node.js automatically when running any PM command.
These commands execute JavaScript scripts bundled with the CLI:
| Command | JS Dependency | Implementation |
|---|---|---|
new [template] |
Remote templates (create-vite, etc.) | Rust CLI → Managed Node.js → JS scripts |
migrate [path] |
Migration rules and transformations | Rust CLI → Managed Node.js → JS scripts |
--version |
Version display logic | Rust CLI → Managed Node.js → JS scripts |
These commands delegate to the local vite-plus package through the JS entry point (dist/index.js), which handles detecting/installing local vite-plus:
| Command | Implementation |
|---|---|
dev, build, test, lint, fmt, run, preview, cache |
Rust CLI → Managed Node.js → dist/index.js → local CLI |
Note: The global CLI uses vite_js_runtime to ensure Node.js is available, resolving the version from the project's devEngines.runtime configuration. The JS entry point handles detecting if vite-plus is installed locally and delegating to the local CLI's dist/bin.js.
Only these commands can run without any Node.js:
| Command | Description | Implementation |
|---|---|---|
help |
Show help | Pure Rust (clap) |
Note: Even help might trigger Node.js download if the user runs vite help new and needs to display JS-specific help.
┌──────────────────────────────────────────────────────────────────────────────┐
│ vite_global_cli (Rust Binary) │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ CLI Parser │ │ Workspace Detect │ │ VITE_GLOBAL_CLI_JS_SCRIPTS_DIR│ │
│ │ (clap) │ │ (from vite_task) │ │ (bundled scripts path) │ │
│ └────────┬─────────┘ └────────┬─────────┘ └────────────┬─────────────┘ │
│ │ │ │ │
│ ┌────────▼─────────────────────▼─────────────────────────▼───────────────┐ │
│ │ Command Router │ │
│ └───┬──────────────────┬──────────────────┬──────────────────┬───────────┘ │
│ │ │ │ │ │
│ ┌───▼────────────┐ ┌───▼────────────┐ ┌───▼────────────┐ ┌───▼──────────┐ │
│ │ Category A │ │ Category B │ │ Category C │ │ Category D │ │
│ │ PM Commands │ │ JS Scripts │ │ Delegation │ │ Pure Rust │ │
│ │ - install │ │ - new │ │ - dev │ │ - help │ │
│ │ - add │ │ - migrate │ │ - build │ │ │ │
│ │ - remove │ │ - --version │ │ - test │ │ │ │
│ │ - update │ │ │ │ - lint │ │ │ │
│ │ - ... │ │ │ │ - ... │ │ │ │
│ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ └──────────────┘ │
│ │ │ │ │
└──────────┼──────────────────┼──────────────────┼───────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────┐ ┌────────────────────────────────┐
│ Flow 1: CLI Runtime │ │ Flow 2: Project Runtime │
│ (Categories A & B) │ │ (Category C) │
│ │ │ │
│ download_runtime_for_project( │ │ download_runtime_for_project( │
│ cli_package_json_dir │ │ project_dir │
│ ) │ │ ) │
│ │ │ │
│ vite_js_runtime reads: │ │ vite_js_runtime reads: │
│ packages/global/package.json │ │ <project>/package.json │
│ └─> devEngines.runtime: "22.22.0" │ │ └─> devEngines.runtime │
│ │ │ │
└─────────────┬───────────────────────┘ └─────────────┬──────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ vite_js_runtime crate │
│ │
│ Built-in logic (same for both flows): │
│ 1. Read package.json from provided path │
│ 2. Extract devEngines.runtime.version │
│ 3. Resolve semver range if needed │
│ 4. Check cache (~/.vite-plus/js_runtime/node/{version}/) │
│ 5. Download Node.js if not cached │
│ 6. Return JsRuntime with binary path │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────┐ ┌────────────────────────────────┐
│ Managed Node.js │ │ Managed Node.js │
│ (CLI's version: 22.22.0) │ │ (Project's version) │
│ │ │ │
│ ┌─────────────┐ ┌──────────────┐ │ │ ┌──────────────────────────┐ │
│ │ pnpm/npm/ │ │ Bundled │ │ │ │ dist/index.js │ │
│ │ yarn │ │ JS Scripts │ │ │ │ → detects/installs local │ │
│ │ (Cat. A) │ │ (Cat. B) │ │ │ │ → delegates to local CLI │ │
│ └─────────────┘ └──────────────┘ │ │ └──────────────────────────┘ │
└─────────────────────────────────────┘ └────────────────────────────────┘
Legend:
- Both flows use download_runtime_for_project(), just with different directory paths
- vite_js_runtime handles all devEngines.runtime logic internally
- Category C delegates through dist/index.js which handles local CLI detection
- Category D: No Node.js required (pure Rust)
When JavaScript execution is needed, the executor uses download_runtime_for_project() with different directory paths:
// crates/vite_global_cli/src/js_executor.rs
use vite_js_runtime::download_runtime_for_project;
use std::process::Command;
pub struct JsExecutor {
cli_runtime: Option<JsRuntime>, // Cached runtime for CLI commands
project_runtime: Option<JsRuntime>, // Cached runtime for project delegation
scripts_dir: PathBuf, // From VITE_GLOBAL_CLI_JS_SCRIPTS_DIR
}
impl JsExecutor {
pub fn new(scripts_dir: PathBuf) -> Self {
Self {
cli_runtime: None,
project_runtime: None,
scripts_dir,
}
}
/// Get the CLI's package.json directory (parent of scripts_dir)
fn get_cli_package_dir(&self) -> PathBuf {
self.scripts_dir.parent().unwrap().to_path_buf()
}
/// Get runtime for CLI's own commands (Categories A & B)
/// Uses CLI's package.json devEngines.runtime (e.g., "22.22.0")
pub async fn ensure_cli_runtime(&mut self) -> Result<&JsRuntime, Error> {
if self.cli_runtime.is_none() {
// download_runtime_for_project reads devEngines.runtime from
// the package.json in the given directory
let cli_dir = self.get_cli_package_dir();
let runtime = download_runtime_for_project(&cli_dir).await?;
self.cli_runtime = Some(runtime);
}
Ok(self.cli_runtime.as_ref().unwrap())
}
/// Get runtime for project delegation (Category C)
/// Uses project's package.json devEngines.runtime
pub async fn ensure_project_runtime(&mut self, project_path: &Path) -> Result<&JsRuntime, Error> {
if self.project_runtime.is_none() {
// download_runtime_for_project reads devEngines.runtime from
// the project's package.json
let runtime = download_runtime_for_project(project_path).await?;
self.project_runtime = Some(runtime);
}
Ok(self.project_runtime.as_ref().unwrap())
}
/// Execute CLI's bundled JS script (Categories A & B)
pub async fn execute_cli_script(&mut self, script_name: &str, args: &[&str]) -> Result<ExitStatus, Error> {
let runtime = self.ensure_cli_runtime().await?;
let script_path = self.scripts_dir.join(script_name);
let status = Command::new(runtime.get_binary_path())
.arg(&script_path)
.args(args)
.status()?;
Ok(status)
}
/// Execute package manager command (Category A)
pub async fn execute_pm_command(&mut self, pm: &str, args: &[&str]) -> Result<ExitStatus, Error> {
let runtime = self.ensure_cli_runtime().await?;
// PM binaries are in the same bin directory as node
let pm_path = runtime.get_bin_prefix().join(pm);
let status = Command::new(runtime.get_binary_path())
.arg(&pm_path)
.args(args)
.status()?;
Ok(status)
}
/// Delegate to local vite-plus CLI (Category C)
///
/// Passes the command through `dist/index.js` which handles:
/// - Detecting if vite-plus is installed locally
/// - Auto-installing if it's a dependency but not installed
/// - Prompting user to add it if not found
/// - Delegating to the local CLI's `dist/bin.js`
pub async fn delegate_to_local_cli(
&mut self,
project_path: &Path,
args: &[&str]
) -> Result<ExitStatus, Error> {
// Use project's runtime version via download_runtime_for_project
let runtime = self.ensure_project_runtime(project_path).await?;
// Get the JS entry point (dist/index.js)
let entry_point = self.scripts_dir.join("index.js");
// Execute dist/index.js with the command and args
// The JS layer handles detecting/installing local vite-plus
let status = Command::new(runtime.get_binary_path())
.arg(&entry_point)
.args(args)
.current_dir(project_path)
.status()?;
Ok(status)
}
}Key points:
- Both flows use
download_runtime_for_project()- the only difference is the directory path vite_js_runtimehandles alldevEngines.runtimelogic internally (reading package.json, resolving versions, caching)- CLI commands use CLI's package.json directory (e.g.,
packages/global/) - Project delegation uses project's directory and passes commands through
dist/index.js - The JS entry point handles local CLI detection and delegation
Scope:
- Set up
vite_global_clicrate structure - Implement CLI parsing with clap
- Implement workspace detection (reuse from
vite_task) - Implement package manager detection and wrapping
- Implement ALL package manager commands:
install [packages]/i- Install dependencies or add packagesadd <packages>- Add packages to dependenciesremove <packages>/rm,un,uninstall- Remove packagesupdate [packages]/up- Update packagesoutdated [packages]- Check for outdated packagesdedupe- Deduplicate dependencieswhy <package>/explain- Explain why a package is installedinfo <package>/view,show- View package info from registrylink [package|dir]/ln- Link packagesunlink [package|dir]- Unlink packagesdlx <package>- Execute package without installingpm <subcommand>- Forward to package manager (list, prune, pack)
Files to create:
crates/vite_global_cli/Cargo.tomlcrates/vite_global_cli/src/main.rscrates/vite_global_cli/src/cli.rscrates/vite_global_cli/src/commands/mod.rscrates/vite_global_cli/src/commands/add.rs# Add packages (struct-based: AddCommand)crates/vite_global_cli/src/commands/install.rs# Install dependencies (struct-based: InstallCommand)crates/vite_global_cli/src/commands/remove.rs# Remove packages (struct-based: RemoveCommand)crates/vite_global_cli/src/commands/update.rs# Update packages (struct-based: UpdateCommand)crates/vite_global_cli/src/commands/dedupe.rs# Deduplicate deps (struct-based: DedupeCommand)crates/vite_global_cli/src/commands/outdated.rs# Check outdated (struct-based: OutdatedCommand)crates/vite_global_cli/src/commands/why.rs# Explain dependency (struct-based: WhyCommand)crates/vite_global_cli/src/commands/link.rs# Link packages (struct-based: LinkCommand)crates/vite_global_cli/src/commands/unlink.rs# Unlink packages (struct-based: UnlinkCommand)crates/vite_global_cli/src/commands/dlx.rs# Execute package (struct-based: DlxCommand)crates/vite_global_cli/src/commands/pm.rs# PM subcommands (prune, pack, list, etc.)crates/vite_global_cli/src/commands/new.rs# Project scaffoldingcrates/vite_global_cli/src/commands/migrate.rs# Migration commandcrates/vite_global_cli/src/commands/delegate.rs# Local CLI delegationcrates/vite_global_cli/src/commands/version.rs# Version displaycrates/vite_global_cli/src/js_executor.rscrates/vite_global_cli/src/error.rs
Success Criteria:
- All PM commands work without pre-installed Node.js (uses managed Node.js)
- Managed Node.js is downloaded automatically when first PM command runs
- Auto-detects pnpm/npm/yarn in the project
- Package manager is downloaded via managed Node.js if not available
- All PM commands work identically to current Node.js CLI
-
--helpdocumentation matches current CLI - Command aliases work correctly (i, rm, up, etc.)
Scope:
- Implement
newcommand for built-in templates (vite:monorepo, etc.) - Implement JS executor for remote templates
- Integrate with
vite_js_runtimefor Node.js download
Success Criteria:
-
vp create vite:monorepoworks without Node.js -
vp create create-vitedownloads Node.js and executes correctly
Scope:
- Implement
migratecommand - Implement local CLI delegation
- Implement
--versionand help system
Success Criteria:
-
vp migrateworks correctly - Local commands delegate properly
- Full feature parity with Node.js CLI
Scope:
- Set up cross-platform builds (Linux, macOS, Windows)
- Create installation scripts
- Add to Homebrew, cargo install, etc.
- Comprehensive testing
Success Criteria:
- Binary available via multiple channels
- Installation scripts work on all platforms
- All snap tests pass
New dependencies for vite_global_cli:
[dependencies]
vite_js_runtime = { path = "../vite_js_runtime" }
vite_shared = { path = "../vite_shared" } # For cache dir, etc.
vite_path = { path = "../vite_path" }
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
thiserror = "1"The global CLI will use the same configuration locations as the current CLI:
- Home directory:
~/.vite-plus/(viavite_shared::get_vite_plus_home) - Node.js runtime:
~/.vite-plus/js_runtime/node/{version}/ - Package manager: Auto-detected from lockfile or package.json
There are two distinct runtime resolution strategies based on the command category:
For package manager commands, new, migrate, and --version, the runtime version comes from the global CLI's own package.json (packages/global/package.json):
{
"name": "vite-plus-cli",
"devEngines": {
"runtime": {
"name": "node",
"version": "22.22.0"
}
}
}Rationale:
- These commands are part of the global CLI's functionality
- They should use a consistent, tested Node.js version
- The version can be updated with CLI releases
- Users don't need a project to run
vp createorvp install
For commands delegated to local vite-plus (dev, build, test, lint, etc.), the runtime version comes from the current project's package.json:
{
"name": "my-project",
"devEngines": {
"runtime": {
"name": "node",
"version": "^20.18.0"
}
}
}Resolution order for Category C:
- Project's
devEngines.runtime(if present) - Fallback to CLI's default version (from
packages/global/package.json)
Rationale:
- Projects may require specific Node.js versions for their builds
- Team members need consistent runtime versions for reproducibility
- Different projects can use different Node.js versions
| Command Category | Runtime Source | Example Commands |
|---|---|---|
| A: PM Commands | CLI's package.json | install, add, remove, update |
| B: JS Scripts | CLI's package.json | new, migrate, --version |
| C: Delegation | Project's package.json → CLI fallback | dev, build, test, lint |
| D: Pure Rust | None | help |
Benefits:
- Separation of concerns: CLI commands use CLI's runtime, project commands use project's runtime
- Per-project control: Each project specifies its required runtime version for builds
- Team consistency: All developers use the same runtime version for a project
- No system conflicts: Different projects can use different Node.js versions
- Automatic provisioning: Runtime is downloaded automatically if not cached
This integrates with the existing vite_js_runtime crate's capabilities (see js-runtime RFC).
Since new and migrate commands are still implemented via JS scripts, we need a hybrid distribution strategy that provides both the Rust binary and the JS scripts.
Create platform-specific npm packages containing only the native binary:
| Package Name | Platform | Architecture |
|---|---|---|
@voidzero-dev/vite-plus-cli-darwin-arm64 |
macOS | ARM64 (Apple Silicon) |
@voidzero-dev/vite-plus-cli-darwin-x64 |
macOS | Intel x64 |
@voidzero-dev/vite-plus-cli-linux-arm64 |
Linux | ARM64 |
@voidzero-dev/vite-plus-cli-linux-x64 |
Linux | Intel x64 |
@voidzero-dev/vite-plus-cli-win32-arm64 |
Windows | ARM64 |
@voidzero-dev/vite-plus-cli-win32-x64 |
Windows | Intel x64 |
Package structure:
@voidzero-dev/vite-plus-cli-darwin-arm64/
├── package.json
└── vite # Native binary (no extension on Unix)
@voidzero-dev/vite-plus-cli-win32-x64/
├── package.json
└── vite.exe # Native binary (Windows)
Platform package.json:
{
"name": "@voidzero-dev/vite-plus-cli-darwin-arm64",
"version": "1.0.0",
"os": ["darwin"],
"cpu": ["arm64"],
"main": "vite",
"files": ["vite"]
}The main vite-plus-cli package uses optionalDependencies to install the correct platform binary:
{
"name": "vite-plus-cli",
"version": "1.0.0",
"bin": {
"vite": "./bin/vite"
},
"optionalDependencies": {
"@voidzero-dev/vite-plus-cli-darwin-arm64": "1.0.0",
"@voidzero-dev/vite-plus-cli-darwin-x64": "1.0.0",
"@voidzero-dev/vite-plus-cli-linux-arm64": "1.0.0",
"@voidzero-dev/vite-plus-cli-linux-x64": "1.0.0",
"@voidzero-dev/vite-plus-cli-win32-arm64": "1.0.0",
"@voidzero-dev/vite-plus-cli-win32-x64": "1.0.0"
}
}Binary resolution (bin/vite):
The bin/vite script needs to be refactored to find and execute the Rust binary from optionalDependencies:
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
// Platform to package mapping
const PLATFORMS = {
'darwin-arm64': '@voidzero-dev/vite-plus-cli-darwin-arm64',
'darwin-x64': '@voidzero-dev/vite-plus-cli-darwin-x64',
'linux-arm64': '@voidzero-dev/vite-plus-cli-linux-arm64',
'linux-x64': '@voidzero-dev/vite-plus-cli-linux-x64',
'win32-arm64': '@voidzero-dev/vite-plus-cli-win32-arm64',
'win32-x64': '@voidzero-dev/vite-plus-cli-win32-x64',
};
function getBinaryPath() {
const binaryName = process.platform === 'win32' ? 'vp.exe' : 'vp';
// 1. First check for local binary in same directory (local development)
const localBinaryPath = join(__dirname, binaryName);
if (existsSync(localBinaryPath)) {
return localBinaryPath;
}
// 2. Find binary from platform-specific optionalDependency
const platform = `${process.platform}-${process.arch}`;
const packageName = PLATFORMS[platform];
if (!packageName) {
throw new Error(`Unsupported platform: ${platform}`);
}
// Try to find the binary in node_modules
const binaryPath = join(__dirname, '..', 'node_modules', packageName, binaryName);
if (existsSync(binaryPath)) {
return binaryPath;
}
// Fallback: try require.resolve
const packagePath = require.resolve(`${packageName}/package.json`);
return join(dirname(packagePath), binaryName);
}
const binaryPath = getBinaryPath();
// Set VITE_GLOBAL_CLI_JS_SCRIPTS_DIR to point to dist/index.js location
const jsScriptsDir = join(__dirname, '..');
execFileSync(binaryPath, process.argv.slice(2), {
stdio: 'inherit',
env: {
...process.env,
VITE_GLOBAL_CLI_JS_SCRIPTS_DIR: jsScriptsDir,
},
});How it works:
bin/vitefinds the Rust binary (vp) from the platform-specific optional dependency- Sets
VITE_GLOBAL_CLI_JS_SCRIPTS_DIRpointing to the package root (wheredist/index.jsis) - Executes the Rust binary with all arguments
- The Rust binary uses the JS entry point at
$VITE_GLOBAL_CLI_JS_SCRIPTS_DIR/dist/index.js
This ensures npm installation works the same way as standalone installation.
For users who prefer standalone installation without npm:
#!/bin/bash
# https://vite.plus
#
# Environment variables:
# VITE_PLUS_VERSION - Version to install (default: latest)
# VITE_PLUS_INSTALL_DIR - Installation directory (default: ~/.vite-plus)
# NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org)
set -e
VITE_PLUS_VERSION="${VITE_PLUS_VERSION:-latest}"
INSTALL_DIR="${VITE_PLUS_INSTALL_DIR:-$HOME/.vite-plus}"
NPM_REGISTRY="${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}"
NPM_REGISTRY="${NPM_REGISTRY%/}"
# Detect platform and get version...
# (platform detection code omitted for brevity)
# Set up version-specific directories
VERSION_DIR="$INSTALL_DIR/$VITE_PLUS_VERSION"
BIN_DIR="$VERSION_DIR/bin"
DIST_DIR="$VERSION_DIR/dist"
CURRENT_LINK="$INSTALL_DIR/current"
# Create directories
mkdir -p "$BIN_DIR" "$DIST_DIR"
# Download platform package (binary + .node files)
platform_url="${NPM_REGISTRY}/${package_name}/-/vite-plus-cli-${package_suffix}-${VITE_PLUS_VERSION}.tgz"
# Extract to temp dir, copy binary to BIN_DIR, copy .node files to DIST_DIR
# Download main package (JS scripts + package.json)
main_url="${NPM_REGISTRY}/vite-plus-cli/-/vite-plus-cli-${VITE_PLUS_VERSION}.tgz"
# Extract dist/* to DIST_DIR, copy package.json to VERSION_DIR
# Create/update current symlink
ln -sfn "$VITE_PLUS_VERSION" "$CURRENT_LINK"
# Cleanup old versions (keep max 5)
cleanup_old_versions
# Add ~/.vite-plus/current/bin to PATH
# (shell profile update code omitted for brevity)See packages/global/install.sh for the full implementation.
For Windows users, provide a PowerShell script:
# https://vite.plus/ps1
#
# Environment variables:
# VITE_PLUS_VERSION - Version to install (default: latest)
# VITE_PLUS_INSTALL_DIR - Installation directory (default: $env:USERPROFILE\.vite-plus)
# NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org)
$ErrorActionPreference = "Stop"
$ViteVersion = if ($env:VITE_PLUS_VERSION) { $env:VITE_PLUS_VERSION } else { "latest" }
$InstallDir = if ($env:VITE_PLUS_INSTALL_DIR) { $env:VITE_PLUS_INSTALL_DIR } else { "$env:USERPROFILE\.vite-plus" }
$NpmRegistry = if ($env:NPM_CONFIG_REGISTRY) { $env:NPM_CONFIG_REGISTRY.TrimEnd('/') } else { "https://registry.npmjs.org" }
# Detect architecture and get version...
# (detection code omitted for brevity)
# Set up version-specific directories
$VersionDir = "$InstallDir\$ViteVersion"
$BinDir = "$VersionDir\bin"
$DistDir = "$VersionDir\dist"
$CurrentLink = "$InstallDir\current"
# Create directories
New-Item -ItemType Directory -Force -Path $BinDir | Out-Null
New-Item -ItemType Directory -Force -Path $DistDir | Out-Null
# Download platform package (binary + .node files)
# Extract binary to BinDir, .node files to DistDir
# Download main package (JS scripts + package.json)
# Extract dist/* to DistDir, package.json to VersionDir
# Create/update current junction (Windows symlink equivalent)
if (Test-Path $CurrentLink) {
cmd /c rmdir "$CurrentLink" 2>$null
}
cmd /c mklink /J "$CurrentLink" "$VersionDir" | Out-Null
# Cleanup old versions (keep max 5)
Cleanup-OldVersions -InstallDir $InstallDir
# Add $InstallDir\current\bin to user PATHSee packages/global/install.ps1 for the full implementation.
Windows installation options:
-
PowerShell one-liner:
irm https://vite.plus/ps1 | iex
-
npm (if Node.js is available):
npm install -g vite-plus-cli
-
Scoop (future):
scoop install vite-plus
The installer supports multiple versions with symlinks, allowing version switching without PATH changes:
~/.vite-plus/
├── current -> 0.0.0-abc123 # Symlink to active version
├── 0.0.0-abc123/ # Version directory
│ ├── bin/
│ │ └── vp # Native Rust binary
│ ├── dist/
│ │ ├── index.js # Bundled JS entry point
│ │ └── *.node # NAPI bindings
│ └── package.json # For devEngines.runtime configuration
├── 0.0.0-def456/ # Another version
│ └── ...
└── ...
Key features:
- PATH points to
~/.vite-plus/current/bin(stable location) - Installing a new version updates the
currentsymlink - Old versions are automatically cleaned up (keeps max 5 versions)
When the Rust binary needs to execute JS (for new, migrate, --version, or PM commands):
- Check
VITE_GLOBAL_CLI_JS_SCRIPTS_DIRenvironment variable (optional) - If not set, auto-detect by looking for
dist/index.jsrelative to the binary - Download Node.js via
vite_js_runtimeif not cached (version frompackage.jsondevEngines.runtime) - Execute the JS entry point with managed Node.js, passing command and arguments
Auto-detection logic:
- For npm installation: binary is in
node_modules/vite-plus-cli/bin/, JS entry point isnode_modules/vite-plus-cli/dist/index.js - For standalone installation: binary is in
~/.vite-plus/current/bin/, JS entry point is~/.vite-plus/current/dist/index.js - For local development: binary is in
packages/global/bin/, JS entry point ispackages/global/dist/index.js
Standalone installation contents:
bin/vp- Native Rust binarydist/index.js- Bundled JS entry pointdist/*.node- NAPI bindings for JS scriptspackage.json- Contains devEngines.runtime configuration
// In the Rust binary
fn get_js_scripts_dir() -> Result<PathBuf, Error> {
// 1. Check environment variable first
if let Ok(dir) = std::env::var("VITE_GLOBAL_CLI_JS_SCRIPTS_DIR") {
return Ok(PathBuf::from(dir));
}
// 2. Auto-detect based on binary location
// Binary is at ~/.vite-plus/current/bin/vp
// Scripts are at ~/.vite-plus/current/dist/
let exe_path = std::env::current_exe()?;
let exe_dir = exe_path.parent().ok_or(Error::JsEntryPointNotFound)?;
// JS scripts dir is always at ../dist/ relative to bin/
let scripts_dir = exe_dir.join("../dist");
if scripts_dir.exists() {
return Ok(scripts_dir.canonicalize()?);
}
Err(Error::JsEntryPointNotFound)
}
async fn run_js_command(&self, command: &str, args: &[&str]) -> Result<(), Error> {
let scripts_dir = get_js_scripts_dir()?;
let entry_point = scripts_dir.join("index.js");
// Ensure Node.js is available (version from package.json devEngines.runtime)
let runtime = self.js_executor.ensure_cli_runtime().await?;
// Execute JS entry point with command and arguments
// The JS entry point handles routing to the appropriate handler
let status = Command::new(runtime.get_binary_path())
.arg(&entry_point)
.arg(command) // e.g., "new", "migrate", "--version"
.args(args)
.status()?;
Ok(())
}The existing packages/global/publish-native-addons.ts script already publishes platform-specific packages via @napi-rs/cli. We only need to modify it to also include the Rust binary.
Current artifact structure (see @voidzero-dev/vite-plus-cli-darwin-arm64 on unpkg):
@voidzero-dev/vite-plus-cli-darwin-arm64/
├── package.json
├── vite-plus-cli.darwin-arm64.node # NAPI binding (existing)
└── vp # Rust binary (to be added)
Changes to publish-native-addons.ts:
- Before publishing, copy the compiled Rust binary to each platform's directory
- Add the binary to the package's
filesarray - Publish as usual
// packages/global/publish-native-addons.ts
// ... existing code ...
// NEW: Copy Rust binary to platform package before publishing
const rustBinaryName = platform === 'win32' ? 'vp.exe' : 'vp';
const rustBinarySource = `../../target/${rustTarget}/release/${rustBinaryName}`;
const rustBinaryDest = `npm/${platform}-${arch}/${rustBinaryName}`;
if (fs.existsSync(rustBinarySource)) {
fs.copyFileSync(rustBinarySource, rustBinaryDest);
console.log(`Copied Rust binary to ${rustBinaryDest}`);
}
// ... existing publish code ...Rust binary targets:
| Platform Package | Rust Target |
|---|---|
| darwin-arm64 | aarch64-apple-darwin |
| darwin-x64 | x86_64-apple-darwin |
| linux-arm64 | aarch64-unknown-linux-gnu |
| linux-x64 | x86_64-unknown-linux-gnu |
| win32-arm64 | aarch64-pc-windows-msvc |
| win32-x64 | x86_64-pc-windows-msvc |
CI/CD Integration:
The existing CI workflow builds NAPI bindings for all platforms. We add a step to also build the Rust binary:
# In existing CI workflow
- name: Build Rust CLI
run: cargo build --release --target ${{ matrix.target }} -p vite_global_cli#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("No package manager detected. Please run in a project directory.")]
NoPackageManager,
#[error("Failed to download Node.js runtime: {0}")]
RuntimeDownload(#[from] vite_js_runtime::Error),
#[error("Command execution failed: {0}")]
CommandExecution(std::io::Error),
// ... more variants
}Note: Local CLI detection errors are handled by the JS layer (dist/index.js), which provides user-friendly messages.
During local development, the Rust binary needs to be available alongside the JS scripts in packages/global/.
Installation script:
The script packages/tools/src/install-global-cli.ts handles copying the compiled Rust binary to the correct location:
packages/global/
├── bin/
│ └── vp # Rust binary copied here by install-global-cli.ts
├── src/
│ ├── new/
│ ├── migration/
│ ├── version.ts
│ └── ...
└── package.json # Contains devEngines.runtime: "22.22.0"
Development workflow:
- Build the Rust binary:
cargo build -p vite_global_cli - Build JS:
pnpm -F vite-plus-cli build - Run install script:
pnpm bootstrap-cli(which internally runsinstall-global-cli.ts) - The script copies the binary to
packages/global/bin/vp - Local development and snap tests work unchanged
Directory structure after setup:
packages/global/
├── bin/
│ └── vp # Rust binary copied here
├── dist/
│ └── index.js # Bundled JS entry point
└── package.json # Contains devEngines.runtime: "22.22.0"
Benefits:
- Consistent experience with production
- Snap tests run against the actual Rust binary
- Auto-detection finds
dist/index.jsrelative to binary location - No wrapper scripts or environment variables needed
Unit Tests:
- CLI argument parsing
- Workspace detection
- Command routing
Integration Tests:
- Full command execution in test fixtures
- Cross-platform behavior
- JS executor with real Node.js download
Snap Tests:
- Reuse existing snap test infrastructure
- Add new tests for Rust binary behavior
- Tests run against the Rust binary in
packages/global/bin/vp
#[test]
fn test_install_command_parsing() {
let args = cli::parse(&["vite", "install", "lodash", "--save-dev"]);
assert!(matches!(args.command, Command::Install { .. }));
}
#[tokio::test]
async fn test_js_executor_downloads_node() {
let mut executor = JsExecutor::new();
let runtime = executor.ensure_runtime().await.unwrap();
assert!(runtime.get_binary_path().exists());
}Node.js 22 is the current LTS line with long-term support. Version 22.22.0 is chosen as a stable point release.
Configuration approach:
- Default version is configured in
packages/global/package.jsonviadevEngines.runtime - Can be updated in future releases without rebuilding the Rust binary
- Projects can override via their own
devEngines.runtimeconfiguration
Version resolution priority:
- Project's
devEngines.runtime(if present) - CLI's default from bundled
package.json
Bundling Node.js would significantly increase binary size (~100MB+). Instead, downloading on-demand:
- Keeps initial download small (~20MB)
- Allows version flexibility
- Leverages existing
vite_js_runtimecaching
Reimplementing pnpm/npm/yarn would be a massive undertaking with subtle compatibility issues. Wrapping existing package managers:
- Ensures compatibility
- Reduces maintenance burden
- Allows users to use their preferred PM
The NAPI bindings serve the local CLI (vite-plus package) use case where Node.js is already available. This allows the same Rust code to be used in both:
- Standalone binary (for global CLI)
- Node.js addon (for local CLI performance)
This approach (used by esbuild, swc, rolldown, etc.) provides several benefits:
- npm compatibility: Users can still
npm install -g vite-plus-cli - Automatic platform detection: npm handles installing the correct binary
- Dual-use distribution: Same binaries work for both npm and standalone installation
- No binary in main package: Main package stays small, only platform-specific binaries are downloaded
- CDN distribution: Unpkg/jsdelivr can serve binaries directly
These commands involve:
- Complex template rendering with user prompts (@clack/prompts)
- Remote template downloads and execution (create-vite, etc.)
- Code transformation rules that may change frequently
- Integration with the existing vite-plus ecosystem
Rewriting these in Rust would be significant effort with limited benefit. Instead:
- JS scripts continue to work as-is
- Rust binary invokes them via managed Node.js runtime
- Updates to templates/migrations don't require binary rebuilds
- Users with
vite-plus-clivia npm continue to work - New installation methods become available (brew, curl, cargo)
- Eventual deprecation of npm-based global CLI (with ample warning period)
# Before
- run: npm install -g vite-plus-cli
# After (recommended)
- run: curl -fsSL https://vite.plus | bash
# or
- uses: voidzero-dev/setup-vite-plus-action@v1- Support Bun/Deno as alternative JS runtimes
- Self-update command (
vp upgrade) - Plugin system for custom commands
- Shell completions generation
- Offline mode with cached templates
- Binary runs on Linux, macOS, and Windows without pre-installed Node.js
- Managed Node.js is downloaded automatically when needed (PM commands, new, migrate)
- All current commands work identically to the existing Node.js CLI
- Cold start time < 100ms (excluding Node.js/PM download)
- Binary size < 30MB
- Existing snap tests pass
- Platform-specific npm packages published and installable
-
npm install -g vite-plus-cliworks on all supported platforms - Standalone installation via
curl | bashworks - JS scripts for
newandmigratecorrectly bundled and executed