fix(setup): 完善 Node 版本检测与升级引导

This commit is contained in:
晴天
2026-06-08 22:04:55 +08:00
parent a4937225e1
commit 634fe66556
9 changed files with 510 additions and 21 deletions

View File

@@ -140,6 +140,146 @@ fn parse_version(value: &str) -> Vec<u32> {
.collect()
}
fn parse_node_version_triplet(value: &str) -> Option<[u32; 3]> {
let parts = parse_version(value);
if parts.is_empty() {
return None;
}
Some([
*parts.first().unwrap_or(&0),
*parts.get(1).unwrap_or(&0),
*parts.get(2).unwrap_or(&0),
])
}
fn cmp_version_triplet(left: [u32; 3], right: [u32; 3]) -> std::cmp::Ordering {
left.cmp(&right)
}
fn node_version_satisfies_clause(version: [u32; 3], clause: &str) -> bool {
let clause = clause.trim();
if clause.is_empty() || clause == "*" {
return true;
}
if let Some(raw) = clause.strip_prefix(">=") {
return parse_node_version_triplet(raw)
.map(|min| cmp_version_triplet(version, min).is_ge())
.unwrap_or(false);
}
if let Some(raw) = clause.strip_prefix('>') {
return parse_node_version_triplet(raw)
.map(|min| cmp_version_triplet(version, min).is_gt())
.unwrap_or(false);
}
if let Some(raw) = clause.strip_prefix('^') {
let Some(min) = parse_node_version_triplet(raw) else {
return false;
};
let max = [min[0].saturating_add(1), 0, 0];
return cmp_version_triplet(version, min).is_ge()
&& cmp_version_triplet(version, max).is_lt();
}
parse_node_version_triplet(clause)
.map(|target| version == target)
.unwrap_or(false)
}
fn node_version_satisfies_requirement(version: &str, requirement: &str) -> bool {
let Some(version) = parse_node_version_triplet(version) else {
return false;
};
let requirement = requirement.trim();
if requirement.is_empty() {
return true;
}
requirement.split("||").any(|range| {
range
.split_whitespace()
.all(|clause| node_version_satisfies_clause(version, clause))
})
}
fn read_package_json_field(path: &std::path::Path, pointer: &str) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str::<Value>(&content)
.ok()?
.pointer(pointer)?
.as_str()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
fn find_openclaw_package_json(cli_path: &std::path::Path) -> Option<PathBuf> {
let dir = cli_path.parent()?;
let cli_source = crate::utils::classify_cli_source(&cli_path.to_string_lossy());
let pkg_names: &[&str] = if cli_source == "npm-zh" || cli_source == "standalone" {
&["@qingchencloud/openclaw-zh", "openclaw"]
} else {
&["openclaw", "@qingchencloud/openclaw-zh"]
};
let mut current = Some(dir);
while let Some(candidate_dir) = current {
let own_pkg = candidate_dir.join("package.json");
if let Some(name) = read_package_json_field(&own_pkg, "/name") {
if name == "openclaw" || name == "@qingchencloud/openclaw-zh" {
return Some(own_pkg);
}
}
current = candidate_dir.parent();
}
for base in [Some(dir), dir.parent()].into_iter().flatten() {
for pkg_name in pkg_names {
let pkg = base
.join("node_modules")
.join(pkg_name)
.join("package.json");
if pkg.is_file() {
return Some(pkg);
}
}
}
None
}
pub(crate) fn openclaw_node_requirement() -> Option<String> {
let cli_path = crate::utils::resolve_openclaw_cli_path()?;
let pkg_json = find_openclaw_package_json(std::path::Path::new(&cli_path))?;
read_package_json_field(&pkg_json, "/engines/node")
}
pub(crate) fn ensure_node_runtime_compatible() -> Result<(), String> {
let node = check_node()?;
let installed = node
.get("installed")
.and_then(Value::as_bool)
.unwrap_or(false);
if !installed {
return Err("Node.js 未安装或未检测到,请先安装 Node.js 后重新检测".into());
}
let compatible = node
.get("compatible")
.and_then(Value::as_bool)
.unwrap_or(true);
if compatible {
return Ok(());
}
let version = node
.get("version")
.and_then(Value::as_str)
.unwrap_or("unknown");
let requirement = node
.get("requiredVersion")
.and_then(Value::as_str)
.unwrap_or("当前 OpenClaw 要求的版本");
let path = node.get("path").and_then(Value::as_str).unwrap_or("");
Err(format!(
"Node.js 版本过低:当前检测到 {version},当前 OpenClaw 要求 {requirement}。请升级 Node.js 后重新检测。检测路径:{path}"
))
}
/// 提取基础版本号(去掉 -zh.x / -nightly.xxx 等后缀,只保留主版本数字部分)
/// "2026.3.13-zh.1" → "2026.3.13", "2026.3.13" → "2026.3.13"
fn base_version(v: &str) -> String {
@@ -4643,16 +4783,28 @@ pub fn check_node() -> Result<Value, String> {
Ok(o) if o.status.success() => {
let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
let detected_from = detect_node_source(&path);
let required_version = openclaw_node_requirement();
let compatible = required_version
.as_deref()
.map(|req| node_version_satisfies_requirement(&ver, req))
.unwrap_or(true);
result.insert("installed".into(), Value::Bool(true));
result.insert("version".into(), Value::String(ver));
result.insert("path".into(), Value::String(path));
result.insert("detectedFrom".into(), Value::String(detected_from));
result.insert("compatible".into(), Value::Bool(compatible));
result.insert(
"requiredVersion".into(),
required_version.map(Value::String).unwrap_or(Value::Null),
);
}
_ => {
result.insert("installed".into(), Value::Bool(false));
result.insert("version".into(), Value::Null);
result.insert("path".into(), Value::Null);
result.insert("detectedFrom".into(), Value::Null);
result.insert("compatible".into(), Value::Bool(false));
result.insert("requiredVersion".into(), Value::Null);
}
}
} else {
@@ -4660,6 +4812,8 @@ pub fn check_node() -> Result<Value, String> {
result.insert("version".into(), Value::Null);
result.insert("path".into(), Value::Null);
result.insert("detectedFrom".into(), Value::Null);
result.insert("compatible".into(), Value::Bool(false));
result.insert("requiredVersion".into(), Value::Null);
}
Ok(Value::Object(result))
}
@@ -4788,6 +4942,8 @@ pub fn check_node_at_path(node_dir: String) -> Result<Value, String> {
if !node_bin.exists() {
result.insert("installed".into(), Value::Bool(false));
result.insert("version".into(), Value::Null);
result.insert("compatible".into(), Value::Bool(false));
result.insert("requiredVersion".into(), Value::Null);
return Ok(Value::Object(result));
}
@@ -4798,13 +4954,25 @@ pub fn check_node_at_path(node_dir: String) -> Result<Value, String> {
match cmd.output() {
Ok(o) if o.status.success() => {
let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
let required_version = openclaw_node_requirement();
let compatible = required_version
.as_deref()
.map(|req| node_version_satisfies_requirement(&ver, req))
.unwrap_or(true);
result.insert("installed".into(), Value::Bool(true));
result.insert("version".into(), Value::String(ver));
result.insert("path".into(), Value::String(node_dir));
result.insert("compatible".into(), Value::Bool(compatible));
result.insert(
"requiredVersion".into(),
required_version.map(Value::String).unwrap_or(Value::Null),
);
}
_ => {
result.insert("installed".into(), Value::Bool(false));
result.insert("version".into(), Value::Null);
result.insert("compatible".into(), Value::Bool(false));
result.insert("requiredVersion".into(), Value::Null);
}
}
Ok(Value::Object(result))
@@ -4815,6 +4983,7 @@ pub fn check_node_at_path(node_dir: String) -> Result<Value, String> {
pub fn scan_node_paths() -> Result<Value, String> {
let mut found: Vec<Value> = vec![];
let home = dirs::home_dir().unwrap_or_default();
let required_version = openclaw_node_requirement();
let mut candidates: Vec<(String, String)> = vec![]; // (path, source)
@@ -5006,10 +5175,27 @@ pub fn scan_node_paths() -> Result<Value, String> {
if let Ok(o) = cmd.output() {
if o.status.success() {
let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
let compatible = required_version
.as_deref()
.map(|req| node_version_satisfies_requirement(&ver, req))
.unwrap_or(true);
let node_dir = node_bin
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| dir.clone());
let mut entry = serde_json::Map::new();
entry.insert("path".into(), Value::String(node_path_str));
entry.insert("dir".into(), Value::String(node_dir));
entry.insert("version".into(), Value::String(ver));
entry.insert("source".into(), Value::String(source.clone()));
entry.insert("compatible".into(), Value::Bool(compatible));
entry.insert(
"requiredVersion".into(),
required_version
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
);
// 标记是否激活
let is_active = source.contains("ACTIVE");
entry.insert("active".into(), Value::Bool(is_active));
@@ -5059,6 +5245,33 @@ fn is_nvm_active_version(nvm_dir: &str, version_dir: &std::path::Path) -> bool {
/// 保存用户自定义的 Node.js 路径到 ~/.openclaw/clawpanel.json
#[tauri::command]
pub fn save_custom_node_path(node_dir: String) -> Result<(), String> {
let detected = check_node_at_path(node_dir.clone())?;
if !detected
.get("installed")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Err("该目录下未找到 node 可执行文件,请确认路径正确。".into());
}
if detected
.get("compatible")
.and_then(Value::as_bool)
.unwrap_or(true)
== false
{
let version = detected
.get("version")
.and_then(Value::as_str)
.unwrap_or("unknown");
let requirement = detected
.get("requiredVersion")
.and_then(Value::as_str)
.unwrap_or("当前 OpenClaw 要求的版本");
return Err(format!(
"Node.js 版本过低:当前 {version},要求 {requirement}。请升级 Node.js 后再使用该路径。"
));
}
let config_path = super::panel_config_path();
if let Some(parent) = config_path.parent() {
let _ = std::fs::create_dir_all(parent);
@@ -7175,6 +7388,122 @@ pub async fn auto_install_git(app: tauri::AppHandle) -> Result<String, String> {
}
}
/// 尝试自动安装或升级 Node.js LTS
#[tauri::command]
pub async fn auto_install_node(app: tauri::AppHandle) -> Result<String, String> {
use std::process::Stdio;
use tauri::Emitter;
let _ = app.emit("upgrade-log", "正在尝试安装或升级 Node.js LTS...");
#[cfg(target_os = "windows")]
{
use std::io::{BufRead, BufReader};
let run_winget = |mode: &str| -> Result<std::process::Child, String> {
let mut args = vec![
mode,
"--id",
"OpenJS.NodeJS.LTS",
"-e",
"--source",
"winget",
"--accept-package-agreements",
"--accept-source-agreements",
];
if mode == "upgrade" {
args.push("--silent");
}
let mut cmd = Command::new("winget");
cmd.args(args)
.creation_flags(0x08000000)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("winget 不可用,请手动升级 Node.js: {e}"))
};
let stream_child_logs = |app_handle: &tauri::AppHandle, child: &mut std::process::Child| {
let stderr = child.stderr.take();
let stdout = child.stdout.take();
let app_for_stderr = app_handle.clone();
let stderr_handle = std::thread::spawn(move || {
if let Some(pipe) = stderr {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
let _ = app_for_stderr.emit("upgrade-log", &line);
}
}
});
if let Some(pipe) = stdout {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
let _ = app_handle.emit("upgrade-log", &line);
}
}
let _ = stderr_handle.join();
};
let _ = app.emit("upgrade-progress", 20);
let _ = app.emit("upgrade-log", "尝试通过 winget 升级 Node.js LTS...");
let mut child = run_winget("upgrade")?;
stream_child_logs(&app, &mut child);
let status = child
.wait()
.map_err(|e| format!("等待 winget 升级 Node.js 失败: {e}"))?;
if !status.success() {
let _ = app.emit("upgrade-progress", 45);
let _ = app.emit("upgrade-log", "升级命令未成功,尝试改用 winget install...");
let mut install_child = run_winget("install")?;
stream_child_logs(&app, &mut install_child);
let install_status = install_child
.wait()
.map_err(|e| format!("等待 winget 安装 Node.js 失败: {e}"))?;
if !install_status.success() {
let requirement =
openclaw_node_requirement().unwrap_or_else(|| "22.19.0 或更高版本".to_string());
return Err(format!(
"winget 安装/升级 Node.js 失败,请手动安装满足 {requirement} 的 Node.jshttps://nodejs.org/"
));
}
}
let _ = app.emit("upgrade-progress", 75);
let _ = app.emit("upgrade-log", "正在刷新 PATH 并重新检测 Node.js...");
super::refresh_enhanced_path();
crate::commands::service::invalidate_cli_detection_cache();
let node = check_node()?;
if node
.get("compatible")
.and_then(Value::as_bool)
.unwrap_or(false)
{
let _ = app.emit("upgrade-progress", 100);
return Ok("Node.js 已安装或升级,请重新检测后启动 Gateway".into());
}
let version = node
.get("version")
.and_then(Value::as_str)
.unwrap_or("unknown");
let requirement = node
.get("requiredVersion")
.and_then(Value::as_str)
.unwrap_or("当前 OpenClaw 要求的版本");
return Err(format!(
"Node.js 升级后仍不满足要求:当前 {version},要求 {requirement}。请重启 ClawPanel 或手动安装新版 Node.js。"
));
}
#[cfg(target_os = "macos")]
{
Err("请通过官网、Homebrew、nvm 或 fnm 升级 Node.js 后重新检测。".into())
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
Err("请使用系统包管理器、nvm 或 fnm 升级 Node.js 后重新检测。".into())
}
}
/// 配置 Git 使用 HTTPS 替代 SSH解决国内用户 SSH 不通的问题
#[tauri::command]
pub fn configure_git_https() -> Result<String, String> {
@@ -7200,6 +7529,7 @@ pub fn invalidate_path_cache() -> Result<(), String> {
#[cfg(test)]
mod write_openclaw_config_merge_tests {
use super::merge_configs_preserving_fields;
use super::node_version_satisfies_requirement;
#[cfg(target_os = "windows")]
use super::resolve_openclaw_cli_input_path;
use serde_json::json;
@@ -7283,4 +7613,31 @@ mod write_openclaw_config_merge_tests {
assert_eq!(resolved, Some(cmd));
}
#[test]
fn node_requirement_rejects_versions_below_minimum() {
assert!(!node_version_satisfies_requirement("v22.17.0", ">=22.19.0"));
}
#[test]
fn node_requirement_accepts_minimum_and_newer_major() {
assert!(node_version_satisfies_requirement("v22.19.0", ">=22.19.0"));
assert!(node_version_satisfies_requirement("v24.0.0", ">=22.19.0"));
}
#[test]
fn node_requirement_supports_common_or_ranges() {
assert!(node_version_satisfies_requirement(
"v22.20.0",
"^22.19.0 || >=24.0.0"
));
assert!(!node_version_satisfies_requirement(
"v23.0.0",
"^22.19.0 || >=24.0.0"
));
assert!(node_version_satisfies_requirement(
"v24.1.0",
"^22.19.0 || >=24.0.0"
));
}
}

View File

@@ -1677,7 +1677,7 @@ mod platform {
fs::create_dir_all(openclaw_dir).map_err(|e| format!("创建 OpenClaw 目录失败: {e}"))?;
let runner_path = openclaw_dir.join("clawpanel-gateway.cmd");
let content = format!(
"@echo off\r\ntitle {GATEWAY_WINDOW_TITLE}\r\necho OpenClaw Gateway is running. Keep this window open.\r\necho Close this window to stop Gateway.\r\necho.\r\n{}\r\necho.\r\necho Gateway exited. You can close this window.\r\n",
"@echo off\r\ntitle {GATEWAY_WINDOW_TITLE}\r\necho Starting OpenClaw Gateway. Keep this window open after it starts.\r\necho Close this window to stop Gateway.\r\necho.\r\n{}\r\necho.\r\necho Gateway exited. You can close this window.\r\n",
gateway_terminal_command(cli)
);
fs::write(&runner_path, content).map_err(|e| format!("写入 Gateway 启动脚本失败: {e}"))?;
@@ -1699,6 +1699,7 @@ mod platform {
.into(),
);
}
crate::commands::config::ensure_node_runtime_compatible()?;
let (running, pid) = check_service_status(0, "");
if running {
@@ -2081,6 +2082,7 @@ mod platform {
.into(),
);
}
crate::commands::config::ensure_node_runtime_compatible()?;
// 启动前检查端口是否已被占用,防止重复拉起导致端口冲突和内存浪费
let port = crate::commands::gateway_listen_port();