Dev: install Node.js with winget when a web run finds none

A dev web launch on a PC without Node.js - a fresh one, or another PC the dev
drive was plugged into - failed with nothing but "stopped while starting (exit
code: 1)": `cmd /c npm run dev` had no npm to run, so nothing said why.

New nodejs.rs finds the folder holding node.exe AND npm.cmd - this process's
PATH first, then %ProgramFiles%\nodejs, where the installer puts it (an
install adds itself to the SYSTEM PATH, which the running launcher never sees).
Launcher::ensure_node runs before a web run's build/start: with no Node.js it
logs why and runs `winget install --id OpenJS.NodeJS.LTS --exact --source
winget --accept-*-agreements` in its own console (progress, Windows' permission
prompt and any failure stay on screen), then judges by what is on disk - not
by winget's exit code, which is non-zero for "already installed" too. No Node
afterwards stops the launch with the manual step spelled out.

`npm run dev` (web_command) and `npm install` (install_web) now get that folder
first on their PATH, so the launch goes on straight after the install without
restarting the launcher. start_web keeps a guard with a real message.

Spot-check: `winget show --id OpenJS.NodeJS.LTS --exact --source winget` names
the nodejs.org x64 MSI; nodejs::tests cover PATH-first, the fresh-install
folder, npm-without-node, and the argument list.
This commit is contained in:
JIMMY_JFEXP
2026-09-29 14:48:28 +02:00
parent 2d79664dc7
commit b46b81ac12
4 changed files with 218 additions and 5 deletions
+54
View File
@@ -1936,9 +1936,14 @@ impl Launcher {
return Ok(()); return Ok(());
} }
self.log(app, "info", format!("Installing the web client's dependencies in {}", web.display())); self.log(app, "info", format!("Installing the web client's dependencies in {}", web.display()));
let node_dir = crate::nodejs::find();
let status = tauri::async_runtime::spawn_blocking(move || { let status = tauri::async_runtime::spawn_blocking(move || {
let mut cmd = std::process::Command::new("cmd"); let mut cmd = std::process::Command::new("cmd");
cmd.arg("/c").arg("npm").arg("install").current_dir(&web); cmd.arg("/c").arg("npm").arg("install").current_dir(&web);
// Found, not assumed on PATH: right after ensure_node installed it, it is not.
if let Some(dir) = &node_dir {
cmd.env("PATH", crate::nodejs::path_with(dir));
}
#[cfg(windows)] #[cfg(windows)]
stack::give_console(&mut cmd); stack::give_console(&mut cmd);
cmd.status() cmd.status()
@@ -1952,7 +1957,56 @@ impl Launcher {
Ok(()) Ok(())
} }
/// Node.js for this run's web client, installed through winget when this PC has none.
///
/// A dev drive moves between PCs, and the web client needs Node.js on whichever one it
/// lands on - without it the launch failed with npm's bare "exit code: 1". winget runs in
/// a console of its own: the install shows its progress there, Windows asks for
/// permission, and a failure (no network, usually) leaves its reason on screen.
async fn ensure_node(&self, app: &AppHandle) -> Result<(), String> {
if crate::nodejs::find().is_some() {
return Ok(());
}
self.log(
app,
"warn",
format!(
"Node.js is not installed, and the web client runs on it: installing Node.js LTS with winget ({}). Windows may ask for permission.",
crate::nodejs::WINGET_ID
),
);
let status = tauri::async_runtime::spawn_blocking(|| {
let mut cmd = crate::nodejs::winget_command();
#[cfg(windows)]
stack::give_console(&mut cmd);
cmd.status()
})
.await
.map_err(|e| format!("could not run winget: {e}"))?;
// Judged by what is on disk afterwards, not by winget's exit code: "already installed"
// and "no newer version" are non-zero too, and both leave a working Node.js.
if let Some(dir) = crate::nodejs::find() {
self.log(app, "info", format!("Node.js is installed in {}.", dir.display()));
return Ok(());
}
let manual = "Install Node.js LTS from https://nodejs.org, then launch again.";
Err(match status {
Err(e) => format!("Node.js is not installed, and winget could not be started ({e}). {manual}"),
Ok(s) => format!(
"Node.js is not installed, and installing it with winget did not work ({s}); its window says why - no network is the usual reason. {manual}"
),
})
}
async fn run_stack(&self, app: &AppHandle, config: StackConfig) { async fn run_stack(&self, app: &AppHandle, config: StackConfig) {
// Before the build too, which runs `npm install` for the web client.
if config.runs_web() {
if let Err(message) = self.ensure_node(app).await {
self.log(app, "error", message.clone());
self.finish(app, Some(message)).await;
return;
}
}
// Asked for, and only in dev: a Launch that quietly took twenty minutes to compile // Asked for, and only in dev: a Launch that quietly took twenty minutes to compile
// would be a worse surprise than a stale binary. // would be a worse surprise than a stale binary.
if config.dev_engine.is_some() && self.inner.lock().await.settings.dev_build { if config.dev_engine.is_some() && self.inner.lock().await.settings.dev_build {
+1
View File
@@ -8,6 +8,7 @@ mod install;
mod launcher; mod launcher;
mod localdb; mod localdb;
mod market; mod market;
mod nodejs;
mod prune; mod prune;
mod settings; mod settings;
mod site; mod site;
+133
View File
@@ -0,0 +1,133 @@
//! Node.js for dev mode's web client: where it is, and installing it when it is not.
//!
//! The web client runs under `npm run dev` in the checkout's `_WEB`, so a dev PC without
//! Node.js failed its launch with nothing but "stopped while starting (exit code: 1)": npm
//! was not there to say anything. Seen on a fresh PC, and again when the dev drive moved to
//! another one.
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::Command;
/// The winget package: Node.js LTS, which the web client's Vite accepts (18, or 20 and up).
pub const WINGET_ID: &str = "OpenJS.NodeJS.LTS";
/// Folders that may hold npm, in the order they are tried: this process's PATH, then where
/// the Node.js installer puts it. The second is what makes an install usable at once: the
/// installer adds its folder to the SYSTEM PATH, which a process already running - this
/// launcher - never sees until it is restarted.
fn candidates(path_var: Option<&OsStr>, program_files: Option<&OsStr>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = path_var.map(|p| std::env::split_paths(p).collect()).unwrap_or_default();
if let Some(program_files) = program_files {
dirs.push(Path::new(program_files).join("nodejs"));
}
dirs
}
/// The folder holding both `node.exe` and `npm.cmd`, from the given PATH and Program Files.
/// Both, because npm is a script node runs: an `npm.cmd` left beside no `node.exe` (a
/// half-removed install) fails exactly like no npm at all.
pub fn find_in(path_var: Option<&OsStr>, program_files: Option<&OsStr>) -> Option<PathBuf> {
candidates(path_var, program_files)
.into_iter()
.find(|dir| dir.join("node.exe").is_file() && dir.join("npm.cmd").is_file())
}
/// Node.js on this PC, if any.
pub fn find() -> Option<PathBuf> {
find_in(std::env::var_os("PATH").as_deref(), std::env::var_os("ProgramFiles").as_deref())
}
/// A PATH for a child that runs npm: `node_dir` first, then this process's own.
pub fn path_with(node_dir: &Path) -> OsString {
let rest = std::env::var_os("PATH").unwrap_or_default();
let dirs = std::iter::once(node_dir.to_path_buf()).chain(std::env::split_paths(&rest));
// Only fails on a folder name holding the separator itself, which no real PATH entry has.
std::env::join_paths(dirs).unwrap_or(rest)
}
/// `winget install` for Node.js LTS. Through `cmd` because winget is an app-execution alias
/// in WindowsApps, which cmd resolves the way a developer's own prompt would. No `--silent`
/// on purpose: the console it runs in is the only place its progress and its reason for
/// failing (no network, no winget source) are ever shown.
pub fn winget_command() -> Command {
let mut cmd = Command::new("cmd");
cmd.args([
"/c",
"winget",
"install",
"--id",
WINGET_ID,
"--exact",
"--source",
"winget",
"--accept-package-agreements",
"--accept-source-agreements",
]);
cmd
}
#[cfg(test)]
mod tests {
use super::*;
/// A throwaway folder, optionally holding the two files a Node.js install has.
fn folder(with_node: bool, with_npm: bool) -> PathBuf {
let dir = std::env::temp_dir().join(format!("odian-nodejs-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
if with_node {
std::fs::write(dir.join("node.exe"), b"").unwrap();
}
if with_npm {
std::fs::write(dir.join("npm.cmd"), b"").unwrap();
}
dir
}
#[test]
fn node_is_found_on_path_first() {
let (on_path, program_files) = (folder(true, true), folder(false, false));
std::fs::create_dir_all(program_files.join("nodejs")).unwrap();
let path = std::env::join_paths([folder(false, false), on_path.clone()]).unwrap();
assert_eq!(find_in(Some(&path), Some(program_files.as_os_str())), Some(on_path));
}
#[test]
fn a_fresh_install_is_found_where_the_installer_puts_it() {
// What winget has just done: Program Files\nodejs exists, and this process's PATH -
// read when the launcher started - does not name it.
let program_files = folder(false, false);
let nodejs = program_files.join("nodejs");
std::fs::create_dir_all(&nodejs).unwrap();
std::fs::write(nodejs.join("node.exe"), b"").unwrap();
std::fs::write(nodejs.join("npm.cmd"), b"").unwrap();
let path = std::env::join_paths([folder(false, false)]).unwrap();
assert_eq!(find_in(Some(&path), Some(program_files.as_os_str())), Some(nodejs));
}
#[test]
fn npm_without_node_is_no_node() {
let half = folder(false, true);
let path = std::env::join_paths([half]).unwrap();
assert_eq!(find_in(Some(&path), None), None);
assert_eq!(find_in(None, None), None);
}
#[test]
fn the_child_path_puts_node_first() {
let dir = PathBuf::from(r"C:\Program Files\nodejs");
let path = path_with(&dir);
assert_eq!(std::env::split_paths(&path).next(), Some(dir));
}
#[test]
fn winget_installs_node_lts_without_asking_on_the_console() {
let cmd = winget_command();
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert_eq!(&args[..3], ["/c", "winget", "install"]);
assert!(args.windows(2).any(|w| w == ["--id", WINGET_ID]), "{args:?}");
// Unanswered, these two prompts wait on the console forever on a first run.
assert!(args.iter().any(|a| a == "--accept-package-agreements"));
assert!(args.iter().any(|a| a == "--accept-source-agreements"));
}
}
+30 -5
View File
@@ -613,9 +613,11 @@ async fn start_client(config: &StackConfig, emit: Emit<'_>, running: &mut Runnin
/// ///
/// Its own function so the argument list is testable: a wrong flag here would only show as /// Its own function so the argument list is testable: a wrong flag here would only show as
/// a failed launch, and the bare `--` matters - without it npm keeps the rest for itself /// a failed launch, and the bare `--` matters - without it npm keeps the rest for itself
/// instead of passing it to Vite. /// instead of passing it to Vite. `node_dir` goes first on its PATH: right after the
fn web_command(dir: &Path) -> Command { /// launcher installed Node.js, this process's own PATH does not name it yet.
fn web_command(dir: &Path, node_dir: &Path) -> Command {
let mut web = Command::new("cmd"); let mut web = Command::new("cmd");
web.env("PATH", crate::nodejs::path_with(node_dir));
// --strictPort because without it Vite quietly takes the next free port, and the address // --strictPort because without it Vite quietly takes the next free port, and the address
// the launcher opens - and prints - would then be somebody else's server. // the launcher opens - and prints - would then be somebody else's server.
web web
@@ -659,7 +661,16 @@ async fn start_web(
format!("{} has no node_modules yet. Tick \"Build it first, every launch\" in Settings, which installs them, or run `npm install` there once.", dir.display()), format!("{} has no node_modules yet. Tick \"Build it first, every launch\" in Settings, which installs them, or run `npm install` there once.", dir.display()),
)); ));
} }
let mut web = web_command(&dir); // The launcher installs Node.js before a web run starts (Launcher::ensure_node), so this
// only guards a PC where that was refused - and says so, instead of npm's bare exit 1.
let Some(node_dir) = crate::nodejs::find() else {
return Err(failed(
emit,
Component::Web,
format!("Node.js is not installed, and the web client runs on it. Install Node.js LTS (winget install {}), then launch again.", crate::nodejs::WINGET_ID),
));
};
let mut web = web_command(&dir, &node_dir);
give_console(&mut web); give_console(&mut web);
let child = running.spawn(Component::Web, &mut web).map_err(|e| failed(emit, Component::Web, e))?; let child = running.spawn(Component::Web, &mut web).map_err(|e| failed(emit, Component::Web, e))?;
// A cold start compiles Cesium, so this is patient where the others are not. // A cold start compiles Cesium, so this is patient where the others are not.
@@ -1401,7 +1412,7 @@ mod tests {
// Checked as ARGUMENTS rather than by reading the binary: these are short literals that // Checked as ARGUMENTS rather than by reading the binary: these are short literals that
// a release build merges away, so "is the flag in the .exe" answers nothing. A wrong // a release build merges away, so "is the flag in the .exe" answers nothing. A wrong
// flag here would surface only as a failed launch. // flag here would surface only as a failed launch.
let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB")); let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), Path::new(r"C:\Program Files\nodejs"));
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert_eq!(args, ["/c", "npm", "run", "dev", "--", "--port", "5173", "--strictPort"]); assert_eq!(args, ["/c", "npm", "run", "dev", "--", "--port", "5173", "--strictPort"]);
// The bare `--` is load-bearing: without it npm eats the flags instead of passing them on. // The bare `--` is load-bearing: without it npm eats the flags instead of passing them on.
@@ -1418,7 +1429,7 @@ mod tests {
fn the_web_draws_its_map_from_this_stacks_tile_server() { fn the_web_draws_its_map_from_this_stacks_tile_server() {
// Offline, the public tile host is unreachable; this stack's tile server still has // Offline, the public tile host is unreachable; this stack's tile server still has
// the PC's installed tiles. _WEB's vite.config.ts reads this variable. // the PC's installed tiles. _WEB's vite.config.ts reads this variable.
let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB")); let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), Path::new(r"C:\Program Files\nodejs"));
let tiles = cmd let tiles = cmd
.get_envs() .get_envs()
.find(|(key, _)| *key == "ODIAN_TILE_SERVER_URL") .find(|(key, _)| *key == "ODIAN_TILE_SERVER_URL")
@@ -1427,6 +1438,20 @@ mod tests {
assert_eq!(tiles.as_deref(), Some("http://127.0.0.1:18081")); assert_eq!(tiles.as_deref(), Some("http://127.0.0.1:18081"));
} }
#[test]
fn npm_finds_the_node_the_launcher_found() {
// Right after the launcher installs Node.js, its own PATH does not name it yet; npm
// runs with the found folder first, so the launch goes on without a restart.
let node = Path::new(r"C:\Program Files\nodejs");
let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), node);
let path = cmd
.get_envs()
.find(|(key, _)| *key == "PATH")
.and_then(|(_, value)| value)
.expect("web_command sets PATH");
assert_eq!(std::env::split_paths(path).next().as_deref(), Some(node));
}
#[test] #[test]
fn a_query_value_cannot_end_its_own_parameter() { fn a_query_value_cannot_end_its_own_parameter() {
// The address is built here, but the operator name comes from a text field. // The address is built here, but the operator name comes from a text field.