Skip to content

Commit e992de3

Browse files
authored
fix(cli): reuse the current Node runtime for built-in tools (#2673)
`vp lint --lsp` and `vp fmt --lsp` can fail when an editor supplies Node but `node` is absent from `PATH`. Built-in tools now use `process.execPath`, passed from the JavaScript CLI through the native binding. This lets [oxc-vscode#384](oxc-project/oxc-vscode#384) use VS Code's bundled Node without temporary runtime shims. Resolver tests cover eight built-in commands. A CLI snapshot starts lint and format with a renamed Node executable and no `node` on `PATH`. Both language servers also passed requests under VS Code's bundled Node on macOS. The 68 Rust tests, `vp check`, CLI Clippy, and snapshot comparison passed. The JavaScript suite passed 1,209 tests. One failure in `resolve-core.spec.ts` also occurs on unchanged `main`.
1 parent e3bd9db commit e992de3

9 files changed

Lines changed: 111 additions & 8 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const assert = require('node:assert/strict');
2+
const { spawnSync } = require('node:child_process');
3+
const { chmodSync, copyFileSync, mkdirSync } = require('node:fs');
4+
const path = require('node:path');
5+
6+
const runtimeDir = path.resolve('custom-runtime');
7+
mkdirSync(runtimeDir);
8+
const runtime = path.join(runtimeDir, process.platform === 'win32' ? 'custom node.exe' : 'custom node');
9+
copyFileSync(process.execPath, runtime);
10+
chmodSync(runtime, 0o755);
11+
const vp = path.join(path.dirname(require.resolve('vite-plus/package.json')), 'bin', 'vp');
12+
const env = { ...process.env };
13+
for (const key of Object.keys(env)) {
14+
if (key.toUpperCase() === 'PATH') delete env[key];
15+
}
16+
env.PATH = runtimeDir;
17+
18+
for (const tool of ['lint', 'fmt']) {
19+
const child = spawnSync(runtime, [vp, tool, '--version'], { env, encoding: 'utf8', timeout: 30000 });
20+
assert.equal(child.status, 0, child.stderr || child.error?.message);
21+
assert.match(child.stdout, /\d+\.\d+\.\d+/);
22+
console.log(`${tool} reused the current runtime without node on PATH`);
23+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "current-node-runtime",
3+
"private": true
4+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[[case]]
2+
name = "builtin_tools_without_node_on_path"
3+
vp = "local"
4+
steps = [
5+
{ argv = ["node", "assert-runtime.cjs"], comment = "Built-in tools reuse the current runtime even when its filename is not node and PATH has no node executable." },
6+
]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# builtin_tools_without_node_on_path
2+
3+
## `node assert-runtime.cjs`
4+
5+
Built-in tools reuse the current runtime even when its filename is not node and PATH has no node executable.
6+
7+
```
8+
lint reused the current runtime without node on PATH
9+
fmt reused the current runtime without node on PATH
10+
```

packages/cli/binding/index.d.cts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3456,6 +3456,8 @@ export interface BatchRewriteResult {
34563456

34573457
/** Configuration options passed from JavaScript to Rust. */
34583458
export interface CliOptions {
3459+
/** The current JavaScript runtime (`process.execPath`). */
3460+
nodeExecPath: string;
34593461
lint: (err: Error | null, arg: JsCommandContext) => Promise<JsCommandResolvedResult>;
34603462
fmt: (err: Error | null, arg: JsCommandContext) => Promise<JsCommandResolvedResult>;
34613463
vite: (err: Error | null, arg: JsCommandContext) => Promise<JsCommandResolvedResult>;

packages/cli/binding/src/cli/resolver.rs

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ impl SubcommandResolver {
9393
}
9494

9595
Ok(ResolvedSubcommand {
96-
program: Arc::from(OsStr::new("node")),
96+
program: Arc::clone(&cli_options.node_exec_path),
9797
args: iter::once(Str::from("--disable-warning=MODULE_TYPELESS_PACKAGE_JSON"))
9898
.chain(iter::once(Str::from(js_path_str)))
9999
.chain(args.into_iter().map(Str::from))
@@ -130,7 +130,7 @@ impl SubcommandResolver {
130130
}
131131

132132
Ok(ResolvedSubcommand {
133-
program: Arc::from(OsStr::new("node")),
133+
program: Arc::clone(&cli_options.node_exec_path),
134134
args: iter::once(Str::from(js_path_str))
135135
.chain(args.into_iter().map(Str::from))
136136
.collect(),
@@ -152,7 +152,7 @@ impl SubcommandResolver {
152152
.ok_or_else(|| anyhow::anyhow!("vite JS path is not valid UTF-8"))?;
153153

154154
Ok(ResolvedSubcommand {
155-
program: Arc::from(OsStr::new("node")),
155+
program: Arc::clone(&cli_options.node_exec_path),
156156
args: iter::once(Str::from(js_path_str))
157157
.chain(iter::once(Str::from("build")))
158158
.chain(args.into_iter().map(Str::from))
@@ -187,7 +187,7 @@ impl SubcommandResolver {
187187
};
188188

189189
Ok(ResolvedSubcommand {
190-
program: Arc::from(OsStr::new("node")),
190+
program: Arc::clone(&cli_options.node_exec_path),
191191
args: iter::once(Str::from(js_path_str)).chain(vitest_args).collect(),
192192
cache_config: UserCacheConfig::with_config(EnabledCacheConfig {
193193
env: None,
@@ -213,7 +213,7 @@ impl SubcommandResolver {
213213
.ok_or_else(|| anyhow::anyhow!("pack JS path is not valid UTF-8"))?;
214214

215215
Ok(ResolvedSubcommand {
216-
program: Arc::from(OsStr::new("node")),
216+
program: Arc::clone(&cli_options.node_exec_path),
217217
args: iter::once(Str::from(js_path_str))
218218
.chain(args.into_iter().map(Str::from))
219219
.collect(),
@@ -235,7 +235,7 @@ impl SubcommandResolver {
235235
.ok_or_else(|| anyhow::anyhow!("vite JS path is not valid UTF-8"))?;
236236

237237
Ok(ResolvedSubcommand {
238-
program: Arc::from(OsStr::new("node")),
238+
program: Arc::clone(&cli_options.node_exec_path),
239239
args: iter::once(Str::from(js_path_str))
240240
.chain(iter::once(Str::from("dev")))
241241
.chain(args.into_iter().map(Str::from))
@@ -253,7 +253,7 @@ impl SubcommandResolver {
253253
.ok_or_else(|| anyhow::anyhow!("vite JS path is not valid UTF-8"))?;
254254

255255
Ok(ResolvedSubcommand {
256-
program: Arc::from(OsStr::new("node")),
256+
program: Arc::clone(&cli_options.node_exec_path),
257257
args: iter::once(Str::from(js_path_str))
258258
.chain(iter::once(Str::from("preview")))
259259
.chain(args.into_iter().map(Str::from))
@@ -271,7 +271,7 @@ impl SubcommandResolver {
271271
.ok_or_else(|| anyhow::anyhow!("doc JS path is not valid UTF-8"))?;
272272

273273
Ok(ResolvedSubcommand {
274-
program: Arc::from(OsStr::new("node")),
274+
program: Arc::clone(&cli_options.node_exec_path),
275275
args: iter::once(Str::from(js_path_str))
276276
.chain(args.into_iter().map(Str::from))
277277
.collect(),
@@ -343,3 +343,55 @@ fn merge_resolved_envs_with_version(
343343
.or_insert_with(|| Arc::from(OsStr::new(env!("CARGO_PKG_VERSION"))));
344344
merged
345345
}
346+
347+
#[cfg(test)]
348+
mod tests {
349+
use vt_path::AbsolutePathBuf;
350+
351+
use super::*;
352+
use crate::cli::types::{BoxedResolverFn, ResolveCommandResult};
353+
354+
fn tool_resolver() -> BoxedResolverFn {
355+
Box::new(|_, _| {
356+
Box::pin(async {
357+
Ok(ResolveCommandResult {
358+
bin_path: Arc::from(OsStr::new("tool.js")),
359+
envs: Vec::new(),
360+
})
361+
})
362+
})
363+
}
364+
365+
#[tokio::test]
366+
async fn builtins_reuse_the_calling_node_runtime() {
367+
let temp = tempfile::tempdir().unwrap();
368+
let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap();
369+
let runtime: Arc<OsStr> = Arc::from(cwd.join("custom runtime").as_path().as_os_str());
370+
let resolver = SubcommandResolver::new(cwd.clone().into()).with_cli_options(CliOptions {
371+
node_exec_path: Arc::clone(&runtime),
372+
lint: tool_resolver(),
373+
fmt: tool_resolver(),
374+
vite: tool_resolver(),
375+
test: tool_resolver(),
376+
pack: tool_resolver(),
377+
doc: tool_resolver(),
378+
toolchain_manifest_path: String::new(),
379+
vite_plus_package_path: String::new(),
380+
resolve_universal_vite_config: Arc::new(|_| Box::pin(async { Ok("{}".to_string()) })),
381+
});
382+
let envs = Arc::new(FxHashMap::default());
383+
for command in [
384+
SynthesizableSubcommand::Lint { args: vec![] },
385+
SynthesizableSubcommand::Fmt { args: vec![] },
386+
SynthesizableSubcommand::Build { args: vec![] },
387+
SynthesizableSubcommand::Test { args: vec![] },
388+
SynthesizableSubcommand::Pack { args: vec![] },
389+
SynthesizableSubcommand::Dev { args: vec![] },
390+
SynthesizableSubcommand::Preview { args: vec![] },
391+
SynthesizableSubcommand::Doc { args: vec![] },
392+
] {
393+
let resolved = resolver.resolve(command, None, &envs, &cwd).await.unwrap();
394+
assert_eq!(resolved.program, runtime);
395+
}
396+
}
397+
}

packages/cli/binding/src/cli/types.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ pub type ViteConfigResolverFn = Arc<
156156

157157
/// CLI options containing JavaScript resolver functions (using boxed futures for simplicity)
158158
pub struct CliOptions {
159+
pub node_exec_path: Arc<OsStr>,
159160
pub lint: BoxedResolverFn,
160161
pub fmt: BoxedResolverFn,
161162
pub vite: BoxedResolverFn,

packages/cli/binding/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ pub fn ensure_blocking_stdio() {
6767
/// Configuration options passed from JavaScript to Rust.
6868
#[napi(object, object_to_js = false)]
6969
pub struct CliOptions {
70+
/// The current JavaScript runtime (`process.execPath`).
71+
pub node_exec_path: String,
7072
pub lint: Arc<ThreadsafeFunction<JsCommandContext, Promise<JsCommandResolvedResult>>>,
7173
pub fmt: Arc<ThreadsafeFunction<JsCommandContext, Promise<JsCommandResolvedResult>>>,
7274
pub vite: Arc<ThreadsafeFunction<JsCommandContext, Promise<JsCommandResolvedResult>>>,
@@ -199,6 +201,7 @@ pub async fn run(options: CliOptions) -> Result<i32> {
199201
let explicit_chdir = options.explicit_chdir.unwrap_or(false);
200202
let toolchain_manifest_path = options.toolchain_manifest_path;
201203
let vite_plus_package_path = options.vite_plus_package_path;
204+
let node_exec_path = Arc::from(OsStr::new(&options.node_exec_path));
202205

203206
// Create a channel to receive the result from the worker thread
204207
let (tx, rx) = tokio::sync::oneshot::channel();
@@ -209,6 +212,7 @@ pub async fn run(options: CliOptions) -> Result<i32> {
209212
std::thread::spawn(move || {
210213
// Create the resolvers inside the thread (BoxedResolverFn is not Send)
211214
let cli_options = ViteTaskCliOptions {
215+
node_exec_path,
212216
lint: create_resolver(lint_tsf, "Failed to resolve lint command"),
213217
fmt: create_resolver(fmt_tsf, "Failed to resolve fmt command"),
214218
vite: create_resolver(vite_tsf, "Failed to resolve vite command"),

packages/cli/src/bin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ if (maybePrintCommandHelp(args)) {
146146
}
147147

148148
const exitCode = await run({
149+
nodeExecPath: process.execPath,
149150
lint,
150151
pack,
151152
fmt,

0 commit comments

Comments
 (0)