Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,28 @@ 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 {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
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');
const resolutionRetry = process.argv.includes('resolution-retry');
if (interactive) process.stdin.setRawMode(true);
const home = resolve('home');
const user = resolve('user');
const external = resolve('external');
const resolutionDir = resolve('resolution');
const resolutionFile = join(resolutionDir, 'node-version');
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.
Expand Down Expand Up @@ -75,10 +86,9 @@ 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.');
async function runInstaller() {
const child = spawn(join(external, 'vp'), [], {
stdio: ['ignore', 'pipe', 'inherit'],
stdio: ['ignore', 'pipe', resolutionRetry ? 'pipe' : 'inherit'],
env: {
...process.env,
HOME: user,
Expand All @@ -92,16 +102,49 @@ try {
VP_NODE_MANAGER: 'no',
VP_SHELL: 'zsh',
VP_SKIP_DEPS_INSTALL: '',
VP_SETUP_NODE_VERSION: resolutionRetry ? '20.18.0' : '',
VP_SETUP_NODE_VERSION_FILE: resolutionRetry ? resolutionFile : '',
},
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8').on('data', (chunk) => {
stdout += chunk;
});
child.stderr?.setEncoding('utf8').on('data', (chunk) => {
stderr += chunk;
});
const [code, signal] = await once(child, 'exit');
assert.equal(signal, null);
assert.equal(code, failure ? 1 : 0);
assert.deepEqual(phases, ['prepare', 'download', 'dependencies']);
return { code, stdout, stderr };
}

try {
console.log('Before installation: preserve this output.');
let result = await runInstaller();
if (resolutionRetry) {
assert.equal(result.code, 1, result.stderr);
assert.equal(result.stdout, '');
const installed = readdirSync(home)
.map((name) => join(home, name))
.filter((directory) => existsSync(join(directory, 'node_modules/vite-plus/package.json')));
assert.equal(installed.length, 1);
const marker = join(installed[0], 'bin/.vp-setup-complete');
assert.equal(existsSync(marker), false);
assert.equal(existsSync(resolutionFile), false);
assert.deepEqual(phases, ['download', 'prepare', 'dependencies']);
console.log('A failed resolution-file write leaves dependencies installed and setup incomplete.');

mkdirSync(resolutionDir);
result = await runInstaller();
assert.equal(result.code, 0, result.stderr);
assert.equal(readFileSync(resolutionFile, 'utf8'), '20.18.0');
assert.equal(existsSync(marker), true);
console.log('Retry returns the requested Node version without installing dependencies again.');
}
const { code, stdout, stderr } = result;
assert.equal(code, failure ? 1 : 0, stderr);
assert.deepEqual(phases, ['download', 'prepare', 'dependencies']);
if (failure) {
assert.equal(stdout, '');
const log = readFileSync(join(home, 'upgrade.log'), 'utf8');
Expand All @@ -123,6 +166,6 @@ try {
if (interactive) process.stdin.setRawMode(false);
server.close();
server.closeAllConnections();
for (const directory of [home, user, external])
for (const directory of [home, user, external, resolutionDir])
rmSync(directory, { recursive: true, force: true });
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ 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:prepare:ready" },
{ write-key = "enter" },
{ expect-milestone = "install:dependencies:ready" },
{ write-key = "enter" },
] }]
Expand All @@ -41,14 +41,20 @@ 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:prepare:ready" },
{ write-key = "enter" },
{ expect-milestone = "install:dependencies:ready" },
{ write-key = "enter" },
] }]

[[case]]
name = "installation_resolution_retry"
vp = "global"
skip-platforms = ["windows"]
steps = [{ argv = ["node", "install.mjs", "resolution-retry"], tty = false }]

[[case]]
name = "installation_progress_non_tty"
vp = "global"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@

## `node install.mjs interactive failure`

**→ expect-milestone:** `install:prepare:ready`
**→ expect-milestone:** `install:download:ready`

```
Before installation: preserve this output.
info: installing vite-plus@<version>...
⠿ Preparing Node.js and pnpm... <duration>
⠿ Downloading pnpm <version>... <size> B (<size> B/s)
```

**← write-key:** `enter`

**→ expect-milestone:** `install:download:ready`
**→ expect-milestone:** `install:prepare:ready`

```
Before installation: preserve this output.
info: installing vite-plus@<version>...
⠿ Downloading pnpm <version>... <size> B (<size> B/s)
⠿ Preparing Node.js and pnpm... <duration>
```

**← write-key:** `enter`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@

## `node install.mjs interactive`

**→ expect-milestone:** `install:prepare:ready`
**→ expect-milestone:** `install:download:ready`

```
Before installation: preserve this output.
info: installing vite-plus@<version>...
⠿ Preparing Node.js and pnpm... <duration>
⠿ Downloading pnpm <version>... <size> B (<size> B/s)
```

**← write-key:** `enter`

**→ expect-milestone:** `install:download:ready`
**→ expect-milestone:** `install:prepare:ready`

```
Before installation: preserve this output.
info: installing vite-plus@<version>...
⠿ Downloading pnpm <version>... <size> B (<size> B/s)
⠿ Preparing Node.js and pnpm... <duration>
```

**← write-key:** `enter`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# installation_resolution_retry

## `node install.mjs resolution-retry`

```
Before installation: preserve this output.
A failed resolution-file write leaves dependencies installed and setup incomplete.
Retry returns the requested Node version without installing dependencies again.
Bootstrap stdout contains only shell assignments.
After installation.
```
2 changes: 1 addition & 1 deletion crates/vp_global_cli/src/commands/upgrade/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,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, !silent).await?;
install::install_production_deps(version_dir, registry, !silent, None).await?;

// Save previous version for rollback
let previous_version = install::save_previous_version(install_dir).await?;
Expand Down
20 changes: 18 additions & 2 deletions crates/vp_global_cli/src/self_setup.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! First-start installation followed by command execution through the deployed binary.

mod external;
mod runtime;
mod shell;

use std::{path::Path, process::ExitCode};
Expand Down Expand Up @@ -237,13 +238,28 @@ async fn run(source: &Path, bundled: bool) -> Result<AbsolutePathBuf, Error> {
temporary.persist(binary.as_path()).map_err(|error| error.error)?;
}
}
if !version_dir.join("node_modules/vite-plus/package.json").as_path().is_file() {
let needs_deps = !version_dir.join("node_modules/vite-plus/package.json").as_path().is_file();
// A standalone setup retry still needs to return its Node selection when dependencies exist.
// In-place upgrades and bundled packages keep their already prepared runtime.
let node_version =
if !skip_deps && (deploy || needs_deps) { runtime::resolve().await? } else { None };
if needs_deps {
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, true).await?;
let preferred =
node_version.as_deref().filter(|version| runtime::supports_cli(version));
if let Some(version) = node_version.as_deref().filter(|_| preferred.is_none()) {
output::info(&format!(
"Requested Node.js {version} does not support this Vite+ release; using a separate installation runtime."
));
}
install::install_production_deps(&version_dir, registry, true, preferred).await?;
}
}
if let Some(version) = node_version {
runtime::write_resolution(&version).await?;
}
#[cfg(windows)]
if !bundled && !version_dir.join("bin/vp-shim.exe").as_path().is_file() {
let sibling = source.with_file_name("vp-shim.exe");
Expand Down
101 changes: 101 additions & 0 deletions crates/vp_global_cli/src/self_setup/runtime.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! Opt in to a compatible Node runtime for first-start dependency installation.

use js_semver::{Range, Version};
use vp_js_runtime::NodeProvider;
use vp_shared::EnvConfig;

use crate::{commands::env::config, error::Error};

pub(super) async fn resolve() -> Result<Option<String>, Error> {
let env = EnvConfig::get();
let Some(selector) = env.setup_node_version.as_deref().map(str::trim).filter(|v| !v.is_empty())
else {
// Keep the installer on LTS unless a caller explicitly opts in.
// Project discovery and normal Node overrides do not apply here.
return Ok(None);
};
config::resolve_version_alias(selector, &NodeProvider::new()).await.map(Some)
}

pub(super) fn supports_cli(version: &str) -> bool {
// This binary installs its own release. Read that release's requirement
// from the same package manifest used to publish it.
let package: serde_json::Value =
serde_json::from_str(include_str!("../../../../packages/cli/package.json"))
.expect("CLI package.json must be valid JSON");
let range = package["engines"]["node"].as_str().expect("CLI must declare engines.node");
Version::parse(version).is_ok_and(|version| {
Range::parse(range).expect("CLI Node range must be valid").satisfies(&version)
})
}

pub(super) async fn write_resolution(version: &str) -> Result<(), Error> {
if let Some(file) =
EnvConfig::get().setup_node_version_file.as_deref().filter(|file| !file.is_empty())
{
tokio::fs::write(file, version).await?;
}
Ok(())
}

#[cfg(test)]
mod tests {
use vp_shared::env_vars;

use super::*;

async fn with_selector(selector: Option<&str>) -> Option<String> {
let root = tempfile::tempdir().unwrap();
EnvConfig::with_vars_async(
[
(env_vars::VP_HOME, root.path().to_str()),
(env_vars::VP_SETUP_NODE_VERSION, selector),
(env_vars::VP_NODE_VERSION, Some("20.18.0")),
],
|_| async move {
config::write_session_version("20.18.0").await.unwrap();
let mut defaults = config::load_config().await.unwrap();
defaults.default_node_version = Some("20.18.0".into());
config::save_config(&defaults).await.unwrap();
resolve().await.unwrap()
},
)
.await
}

#[tokio::test]
async fn explicit_selector_overrides_normal_node_configuration() {
assert_eq!(with_selector(Some(" v22.23.1 ")).await.as_deref(), Some("22.23.1"));
}

#[tokio::test]
async fn absent_or_empty_selector_keeps_the_lts_default() {
for selector in [None, Some(""), Some(" ")] {
assert_eq!(with_selector(selector).await, None);
}
}

#[test]
fn accepts_supported_project_versions() {
assert!(supports_cli("22.23.1"));
assert!(supports_cli("24.11.0"));
assert!(supports_cli("26.0.0"));
assert!(!supports_cli("20.18.0"));
assert!(!supports_cli("22.17.0"));
assert!(!supports_cli("24.10.0"));
assert!(!supports_cli("invalid"));
}

#[tokio::test]
async fn returns_the_project_version_even_when_bootstrap_needs_a_fallback() {
let root = tempfile::tempdir().unwrap();
let file = root.path().join("selected-node");
let version = with_selector(Some("20.18.0")).await.unwrap();
assert!(!supports_cli(&version));
EnvConfig::with_vars_async([(env_vars::VP_SETUP_NODE_VERSION_FILE, &file)], |_| async {
write_resolution(&version).await.unwrap();
})
.await;
assert_eq!(tokio::fs::read_to_string(file).await.unwrap(), "20.18.0");
}
}
3 changes: 2 additions & 1 deletion crates/vp_installer/src/legacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ 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(), !opts.quiet).await?;
install::install_production_deps(version_dir, opts.registry.as_deref(), !opts.quiet, None)
.await?;

let previous_version =
if has_previous { install::save_previous_version(install_dir).await? } else { None };
Expand Down
1 change: 1 addition & 0 deletions crates/vp_setup/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ junction = { workspace = true }
[dev-dependencies]
httpmock = { workspace = true }
tempfile = { workspace = true }
vp_shared = { workspace = true, features = ["test-utils"] }

[lib]
doctest = false
Expand Down
Loading
Loading