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:
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user