Skip to content

Commit 1b87fe8

Browse files
authored
perf(setup): allow an explicit Node runtime for installation (#3087)
CI setup can download two Node.js versions when the project selection differs from the installer's LTS runtime. The installer keeps the latest Node.js LTS as its default. Project files, session overrides, and user defaults do not change its installation runtime. `VP_SETUP_NODE_VERSION` selects another Node.js version for dependency installation. The installer resolves that value once and uses it when the CLI and pinned `pnpm` support it. Otherwise, installation uses LTS. `VP_SETUP_NODE_VERSION_FILE` returns the exact requested version, including when installation needs LTS. Project discovery belongs to `setup-vp`, which supplies the environment override. Related integration: [setup-vp #202](voidzero-dev/setup-vp#202).
1 parent 0ace463 commit 1b87fe8

16 files changed

Lines changed: 351 additions & 36 deletions

File tree

‎crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs‎

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,28 @@ import assert from 'node:assert/strict';
22
import { spawn } from 'node:child_process';
33
import { randomBytes } from 'node:crypto';
44
import { once } from 'node:events';
5-
import { copyFileSync, mkdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
5+
import {
6+
copyFileSync,
7+
existsSync,
8+
mkdirSync,
9+
readFileSync,
10+
readdirSync,
11+
rmSync,
12+
symlinkSync,
13+
} from 'node:fs';
614
import { createServer } from 'node:http';
715
import { join, resolve } from 'node:path';
816
import { createArchive } from './archive.mjs';
917

1018
const interactive = process.argv.includes('interactive');
1119
const failure = process.argv.includes('failure');
20+
const resolutionRetry = process.argv.includes('resolution-retry');
1221
if (interactive) process.stdin.setRawMode(true);
1322
const home = resolve('home');
1423
const user = resolve('user');
1524
const external = resolve('external');
25+
const resolutionDir = resolve('resolution');
26+
const resolutionFile = join(resolutionDir, 'node-version');
1627
const nodeBin = join(home, 'js_runtime/node/99.0.0/bin');
1728
for (const directory of [nodeBin, user, external]) mkdirSync(directory, { recursive: true });
1829
// Use the runner's real Node for the mock pnpm program, without a network download.
@@ -75,10 +86,9 @@ server.listen(0, '127.0.0.1');
7586
await once(server, 'listening');
7687
const mirror = `http://127.0.0.1:${server.address().port}`;
7788

78-
try {
79-
console.log('Before installation: preserve this output.');
89+
async function runInstaller() {
8090
const child = spawn(join(external, 'vp'), [], {
81-
stdio: ['ignore', 'pipe', 'inherit'],
91+
stdio: ['ignore', 'pipe', resolutionRetry ? 'pipe' : 'inherit'],
8292
env: {
8393
...process.env,
8494
HOME: user,
@@ -92,16 +102,49 @@ try {
92102
VP_NODE_MANAGER: 'no',
93103
VP_SHELL: 'zsh',
94104
VP_SKIP_DEPS_INSTALL: '',
105+
VP_SETUP_NODE_VERSION: resolutionRetry ? '20.18.0' : '',
106+
VP_SETUP_NODE_VERSION_FILE: resolutionRetry ? resolutionFile : '',
95107
},
96108
});
97109
let stdout = '';
110+
let stderr = '';
98111
child.stdout.setEncoding('utf8').on('data', (chunk) => {
99112
stdout += chunk;
100113
});
114+
child.stderr?.setEncoding('utf8').on('data', (chunk) => {
115+
stderr += chunk;
116+
});
101117
const [code, signal] = await once(child, 'exit');
102118
assert.equal(signal, null);
103-
assert.equal(code, failure ? 1 : 0);
104-
assert.deepEqual(phases, ['prepare', 'download', 'dependencies']);
119+
return { code, stdout, stderr };
120+
}
121+
122+
try {
123+
console.log('Before installation: preserve this output.');
124+
let result = await runInstaller();
125+
if (resolutionRetry) {
126+
assert.equal(result.code, 1, result.stderr);
127+
assert.equal(result.stdout, '');
128+
const installed = readdirSync(home)
129+
.map((name) => join(home, name))
130+
.filter((directory) => existsSync(join(directory, 'node_modules/vite-plus/package.json')));
131+
assert.equal(installed.length, 1);
132+
const marker = join(installed[0], 'bin/.vp-setup-complete');
133+
assert.equal(existsSync(marker), false);
134+
assert.equal(existsSync(resolutionFile), false);
135+
assert.deepEqual(phases, ['download', 'prepare', 'dependencies']);
136+
console.log('A failed resolution-file write leaves dependencies installed and setup incomplete.');
137+
138+
mkdirSync(resolutionDir);
139+
result = await runInstaller();
140+
assert.equal(result.code, 0, result.stderr);
141+
assert.equal(readFileSync(resolutionFile, 'utf8'), '20.18.0');
142+
assert.equal(existsSync(marker), true);
143+
console.log('Retry returns the requested Node version without installing dependencies again.');
144+
}
145+
const { code, stdout, stderr } = result;
146+
assert.equal(code, failure ? 1 : 0, stderr);
147+
assert.deepEqual(phases, ['download', 'prepare', 'dependencies']);
105148
if (failure) {
106149
assert.equal(stdout, '');
107150
const log = readFileSync(join(home, 'upgrade.log'), 'utf8');
@@ -123,6 +166,6 @@ try {
123166
if (interactive) process.stdin.setRawMode(false);
124167
server.close();
125168
server.closeAllConnections();
126-
for (const directory of [home, user, external])
169+
for (const directory of [home, user, external, resolutionDir])
127170
rmSync(directory, { recursive: true, force: true });
128171
}

‎crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ name = "installation_progress_interactive"
2828
vp = "global"
2929
skip-platforms = ["windows"]
3030
steps = [{ argv = ["node", "install.mjs", "interactive"], interactions = [
31-
{ expect-milestone = "install:prepare:ready" },
32-
{ write-key = "enter" },
3331
{ expect-milestone = "install:download:ready" },
3432
{ write-key = "enter" },
33+
{ expect-milestone = "install:prepare:ready" },
34+
{ write-key = "enter" },
3535
{ expect-milestone = "install:dependencies:ready" },
3636
{ write-key = "enter" },
3737
] }]
@@ -41,14 +41,20 @@ name = "installation_progress_failure"
4141
vp = "global"
4242
skip-platforms = ["windows"]
4343
steps = [{ argv = ["node", "install.mjs", "interactive", "failure"], interactions = [
44-
{ expect-milestone = "install:prepare:ready" },
45-
{ write-key = "enter" },
4644
{ expect-milestone = "install:download:ready" },
4745
{ write-key = "enter" },
46+
{ expect-milestone = "install:prepare:ready" },
47+
{ write-key = "enter" },
4848
{ expect-milestone = "install:dependencies:ready" },
4949
{ write-key = "enter" },
5050
] }]
5151

52+
[[case]]
53+
name = "installation_resolution_retry"
54+
vp = "global"
55+
skip-platforms = ["windows"]
56+
steps = [{ argv = ["node", "install.mjs", "resolution-retry"], tty = false }]
57+
5258
[[case]]
5359
name = "installation_progress_non_tty"
5460
vp = "global"

‎crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,22 @@
22

33
## `node install.mjs interactive failure`
44

5-
**→ expect-milestone:** `install:prepare:ready`
5+
**→ expect-milestone:** `install:download:ready`
66

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

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

15-
**→ expect-milestone:** `install:download:ready`
15+
**→ expect-milestone:** `install:prepare:ready`
1616

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

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

‎crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,22 @@
22

33
## `node install.mjs interactive`
44

5-
**→ expect-milestone:** `install:prepare:ready`
5+
**→ expect-milestone:** `install:download:ready`
66

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

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

15-
**→ expect-milestone:** `install:download:ready`
15+
**→ expect-milestone:** `install:prepare:ready`
1616

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

2323
**← write-key:** `enter`
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# installation_resolution_retry
2+
3+
## `node install.mjs resolution-retry`
4+
5+
```
6+
Before installation: preserve this output.
7+
A failed resolution-file write leaves dependencies installed and setup incomplete.
8+
Retry returns the requested Node version without installing dependencies again.
9+
Bootstrap stdout contains only shell assignments.
10+
After installation.
11+
```

‎crates/vp_global_cli/src/commands/upgrade/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ async fn install_platform_and_main(
231231
install::generate_wrapper_package_json(version_dir, new_version).await?;
232232

233233
// Install production dependencies (pnpm installs vite-plus + all transitive deps)
234-
install::install_production_deps(version_dir, registry, !silent).await?;
234+
install::install_production_deps(version_dir, registry, !silent, None).await?;
235235

236236
// Save previous version for rollback
237237
let previous_version = install::save_previous_version(install_dir).await?;

‎crates/vp_global_cli/src/self_setup.rs‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! First-start installation followed by command execution through the deployed binary.
22
33
mod external;
4+
mod runtime;
45
mod shell;
56

67
use std::{path::Path, process::ExitCode};
@@ -237,13 +238,28 @@ async fn run(source: &Path, bundled: bool) -> Result<AbsolutePathBuf, Error> {
237238
temporary.persist(binary.as_path()).map_err(|error| error.error)?;
238239
}
239240
}
240-
if !version_dir.join("node_modules/vite-plus/package.json").as_path().is_file() {
241+
let needs_deps = !version_dir.join("node_modules/vite-plus/package.json").as_path().is_file();
242+
// A standalone setup retry still needs to return its Node selection when dependencies exist.
243+
// In-place upgrades and bundled packages keep their already prepared runtime.
244+
let node_version =
245+
if !skip_deps && (deploy || needs_deps) { runtime::resolve().await? } else { None };
246+
if needs_deps {
241247
output::info(&format!("installing vite-plus@{version}..."));
242248
install::generate_wrapper_package_json(&version_dir, version).await?;
243249
if !skip_deps {
244-
install::install_production_deps(&version_dir, registry, true).await?;
250+
let preferred =
251+
node_version.as_deref().filter(|version| runtime::supports_cli(version));
252+
if let Some(version) = node_version.as_deref().filter(|_| preferred.is_none()) {
253+
output::info(&format!(
254+
"Requested Node.js {version} does not support this Vite+ release; using a separate installation runtime."
255+
));
256+
}
257+
install::install_production_deps(&version_dir, registry, true, preferred).await?;
245258
}
246259
}
260+
if let Some(version) = node_version {
261+
runtime::write_resolution(&version).await?;
262+
}
247263
#[cfg(windows)]
248264
if !bundled && !version_dir.join("bin/vp-shim.exe").as_path().is_file() {
249265
let sibling = source.with_file_name("vp-shim.exe");
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
//! Opt in to a compatible Node runtime for first-start dependency installation.
2+
3+
use js_semver::{Range, Version};
4+
use vp_js_runtime::NodeProvider;
5+
use vp_shared::EnvConfig;
6+
7+
use crate::{commands::env::config, error::Error};
8+
9+
pub(super) async fn resolve() -> Result<Option<String>, Error> {
10+
let env = EnvConfig::get();
11+
let Some(selector) = env.setup_node_version.as_deref().map(str::trim).filter(|v| !v.is_empty())
12+
else {
13+
// Keep the installer on LTS unless a caller explicitly opts in.
14+
// Project discovery and normal Node overrides do not apply here.
15+
return Ok(None);
16+
};
17+
config::resolve_version_alias(selector, &NodeProvider::new()).await.map(Some)
18+
}
19+
20+
pub(super) fn supports_cli(version: &str) -> bool {
21+
// This binary installs its own release. Read that release's requirement
22+
// from the same package manifest used to publish it.
23+
let package: serde_json::Value =
24+
serde_json::from_str(include_str!("../../../../packages/cli/package.json"))
25+
.expect("CLI package.json must be valid JSON");
26+
let range = package["engines"]["node"].as_str().expect("CLI must declare engines.node");
27+
Version::parse(version).is_ok_and(|version| {
28+
Range::parse(range).expect("CLI Node range must be valid").satisfies(&version)
29+
})
30+
}
31+
32+
pub(super) async fn write_resolution(version: &str) -> Result<(), Error> {
33+
if let Some(file) =
34+
EnvConfig::get().setup_node_version_file.as_deref().filter(|file| !file.is_empty())
35+
{
36+
tokio::fs::write(file, version).await?;
37+
}
38+
Ok(())
39+
}
40+
41+
#[cfg(test)]
42+
mod tests {
43+
use vp_shared::env_vars;
44+
45+
use super::*;
46+
47+
async fn with_selector(selector: Option<&str>) -> Option<String> {
48+
let root = tempfile::tempdir().unwrap();
49+
EnvConfig::with_vars_async(
50+
[
51+
(env_vars::VP_HOME, root.path().to_str()),
52+
(env_vars::VP_SETUP_NODE_VERSION, selector),
53+
(env_vars::VP_NODE_VERSION, Some("20.18.0")),
54+
],
55+
|_| async move {
56+
config::write_session_version("20.18.0").await.unwrap();
57+
let mut defaults = config::load_config().await.unwrap();
58+
defaults.default_node_version = Some("20.18.0".into());
59+
config::save_config(&defaults).await.unwrap();
60+
resolve().await.unwrap()
61+
},
62+
)
63+
.await
64+
}
65+
66+
#[tokio::test]
67+
async fn explicit_selector_overrides_normal_node_configuration() {
68+
assert_eq!(with_selector(Some(" v22.23.1 ")).await.as_deref(), Some("22.23.1"));
69+
}
70+
71+
#[tokio::test]
72+
async fn absent_or_empty_selector_keeps_the_lts_default() {
73+
for selector in [None, Some(""), Some(" ")] {
74+
assert_eq!(with_selector(selector).await, None);
75+
}
76+
}
77+
78+
#[test]
79+
fn accepts_supported_project_versions() {
80+
assert!(supports_cli("22.23.1"));
81+
assert!(supports_cli("24.11.0"));
82+
assert!(supports_cli("26.0.0"));
83+
assert!(!supports_cli("20.18.0"));
84+
assert!(!supports_cli("22.17.0"));
85+
assert!(!supports_cli("24.10.0"));
86+
assert!(!supports_cli("invalid"));
87+
}
88+
89+
#[tokio::test]
90+
async fn returns_the_project_version_even_when_bootstrap_needs_a_fallback() {
91+
let root = tempfile::tempdir().unwrap();
92+
let file = root.path().join("selected-node");
93+
let version = with_selector(Some("20.18.0")).await.unwrap();
94+
assert!(!supports_cli(&version));
95+
EnvConfig::with_vars_async([(env_vars::VP_SETUP_NODE_VERSION_FILE, &file)], |_| async {
96+
write_resolution(&version).await.unwrap();
97+
})
98+
.await;
99+
assert_eq!(tokio::fs::read_to_string(file).await.unwrap(), "20.18.0");
100+
}
101+
}

‎crates/vp_installer/src/legacy.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,8 @@ async fn install_new_version(
137137
if !opts.quiet {
138138
print_info("installing dependencies (this may take a moment)...");
139139
}
140-
install::install_production_deps(version_dir, opts.registry.as_deref(), !opts.quiet).await?;
140+
install::install_production_deps(version_dir, opts.registry.as_deref(), !opts.quiet, None)
141+
.await?;
141142

142143
let previous_version =
143144
if has_previous { install::save_previous_version(install_dir).await? } else { None };

‎crates/vp_setup/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ junction = { workspace = true }
3030
[dev-dependencies]
3131
httpmock = { workspace = true }
3232
tempfile = { workspace = true }
33+
vp_shared = { workspace = true, features = ["test-utils"] }
3334

3435
[lib]
3536
doctest = false

0 commit comments

Comments
 (0)