Wave 3.1:老板十项返工(kimi 换代 kimi-code/授权检测/可更新判定/模型列表+测试连通/系统环境诊断/外链/命令复制/官方图标主色)
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -15,11 +15,12 @@ use agentdock_secrets::{SecretStore, redact, service_name};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::error::EngineError;
|
||||
use crate::errors_zh::map_exec_error;
|
||||
use crate::errors_zh::{map_exec_error, runtime_label_zh};
|
||||
use crate::process::{
|
||||
resolve_exe, run_capture, run_streaming, run_streaming_cancellable, run_terminal, run_with_stdin,
|
||||
which_all, RunningProcess,
|
||||
};
|
||||
use crate::runtime_install::{fetch_http_text, is_url_host_allowed};
|
||||
use crate::types::*;
|
||||
|
||||
/// 授权会话 key:`<cli_id>:<mode>`。
|
||||
@@ -345,6 +346,30 @@ impl Engine {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 系统环境诊断(Wave 3.1 Req 7):检查缺失/过低的系统依赖,标注影响哪些 CLI。
|
||||
pub fn diagnose_system(&self, env: &agentdock_platform::model::PlatformEnv) -> Result<SystemEnvReport, EngineError> {
|
||||
let adapters = self.adapters()?;
|
||||
Ok(diagnose_system(env, &adapters))
|
||||
}
|
||||
|
||||
/// 「可更新」判定(Wave 3.1 Req 4):官方源最新版本 vs 本地已装版本 semver 对比。
|
||||
pub fn check_update(&self, id: &str) -> Result<UpdateCheckResult, EngineError> {
|
||||
let adapter = self.adapter(id)?;
|
||||
Ok(check_update(&adapter))
|
||||
}
|
||||
|
||||
/// 模型列表(Wave 3.1 Req 5):优先 CLI 自带列举命令,否则适配器维护清单。
|
||||
pub fn list_models(&self, id: &str) -> Result<ModelListResult, EngineError> {
|
||||
let adapter = self.adapter(id)?;
|
||||
Ok(list_models(&adapter))
|
||||
}
|
||||
|
||||
/// 测试连通(Wave 3.1 Req 5):密钥从密钥库读取,直连官方接口测一次。
|
||||
pub fn test_connection(&self, id: &str) -> Result<ConnectionTestResult, EngineError> {
|
||||
let adapter = self.adapter(id)?;
|
||||
Ok(test_connection(&adapter, self.secrets.as_ref()))
|
||||
}
|
||||
|
||||
// ---- 执行 ----
|
||||
|
||||
pub fn run<F>(&self, id: &str, action: AdapterAction, opts: &ActionOpts, mut emit: F) -> Result<(), EngineError>
|
||||
@@ -840,7 +865,23 @@ pub fn auth_status(adapter: &Adapter, secrets: &dyn SecretStore) -> AuthStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. keyring
|
||||
// 2. credential_files(Wave 3.1 Req 3):无 status_command 的 CLI 用登录态/OAuth 凭据文件判定
|
||||
if let Some(auth) = adapter.authorization.as_ref() {
|
||||
for cred in &auth.credential_files {
|
||||
let p = cfg::resolve_path(cred);
|
||||
if p.exists() {
|
||||
return AuthStatus {
|
||||
cli_id: adapter.id.clone(),
|
||||
status: "authorized".into(),
|
||||
via: "credential_file".into(),
|
||||
detail_zh: format!("检测到登录凭据({})", cred),
|
||||
checked_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. keyring
|
||||
if let Some(cfg) = adapter.configuration.as_ref() {
|
||||
for field in &cfg.fields {
|
||||
if field.sensitive && secrets.has(&service, &field.id) {
|
||||
@@ -855,7 +896,7 @@ pub fn auth_status(adapter: &Adapter, secrets: &dyn SecretStore) -> AuthStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. unknown
|
||||
// 4. unknown
|
||||
AuthStatus {
|
||||
cli_id: adapter.id.clone(),
|
||||
status: "unknown".into(),
|
||||
@@ -1175,6 +1216,435 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Wave 3.1:可更新判定 / 模型列表 / 测试连通 / 系统环境诊断
|
||||
// =====================================================================
|
||||
|
||||
/// 版本号 → 数值元组(忽略 v 前缀与非数字段,供版本比较)。
|
||||
fn version_parts(s: &str) -> Vec<u64> {
|
||||
s.split(|c: char| !c.is_ascii_digit())
|
||||
.filter(|seg| !seg.is_empty())
|
||||
.filter_map(|seg| seg.parse::<u64>().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 比较两个版本号,返回 Ordering(缺失段按 0 处理)。
|
||||
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
let pa = version_parts(a);
|
||||
let pb = version_parts(b);
|
||||
let n = pa.len().max(pb.len());
|
||||
for i in 0..n {
|
||||
let x = pa.get(i).copied().unwrap_or(0);
|
||||
let y = pb.get(i).copied().unwrap_or(0);
|
||||
match x.cmp(&y) {
|
||||
std::cmp::Ordering::Equal => continue,
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
std::cmp::Ordering::Equal
|
||||
}
|
||||
|
||||
/// 官方源最新版本查询结果。
|
||||
enum LatestVersion {
|
||||
Found(String),
|
||||
Unsupported(String),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// 从官方源查询最新版本(npm registry / PyPI / GitHub Releases),均经 allowed_hosts 白名单。
|
||||
fn fetch_latest_version(adapter: &Adapter) -> LatestVersion {
|
||||
let allowed = adapter
|
||||
.official
|
||||
.as_ref()
|
||||
.map(|o| o.allowed_hosts.clone())
|
||||
.unwrap_or_default();
|
||||
let src = match adapter.update.as_ref().and_then(|u| u.source.as_ref()) {
|
||||
Some(s) => s,
|
||||
None => return LatestVersion::Unsupported("适配器未声明 update.source(可更新依据来源)".into()),
|
||||
};
|
||||
let url = match src.kind.as_str() {
|
||||
"npm" => format!("https://registry.npmjs.org/{}", src.package.as_deref().unwrap_or("")),
|
||||
"pypi" => format!("https://pypi.org/pypi/{}/json", src.package.as_deref().unwrap_or("")),
|
||||
"github" => format!("https://api.github.com/repos/{}/releases/latest", src.repo.as_deref().unwrap_or("")),
|
||||
other => return LatestVersion::Unsupported(format!("不支持的更新来源 {other}")),
|
||||
};
|
||||
if !is_url_host_allowed(&url, &allowed) {
|
||||
return LatestVersion::Failed(format!("来源主机不在白名单内:{url}"));
|
||||
}
|
||||
match fetch_http_text(&url, &[]) {
|
||||
Ok(text) => {
|
||||
let v: serde_json::Value = match serde_json::from_str(&text) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return LatestVersion::Failed("官方源返回非 JSON".into()),
|
||||
};
|
||||
let latest = match src.kind.as_str() {
|
||||
"npm" => v.pointer("/dist-tags/latest").and_then(|x| x.as_str()),
|
||||
"pypi" => v.pointer("/info/version").and_then(|x| x.as_str()),
|
||||
"github" => v.pointer("/tag_name").and_then(|x| x.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
match latest {
|
||||
Some(l) => LatestVersion::Found(l.trim_start_matches('v').to_string()),
|
||||
None => LatestVersion::Failed("官方源未返回版本号".into()),
|
||||
}
|
||||
}
|
||||
Err(e) => LatestVersion::Failed(format!("查询官方源失败:{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 「可更新」判定(Wave 3.1 Req 4):官方源最新版本 vs 本地已装版本 semver 对比。
|
||||
pub fn check_update(adapter: &Adapter) -> UpdateCheckResult {
|
||||
let cli_id = adapter.id.clone();
|
||||
let detect = detect_one(adapter);
|
||||
let current = if detect.status == "installed" { detect.version.clone() } else { None };
|
||||
|
||||
let (source_zh, source_url) = match adapter.update.as_ref().and_then(|u| u.source.as_ref()) {
|
||||
Some(s) => match s.kind.as_str() {
|
||||
"npm" => (
|
||||
"npm registry".to_string(),
|
||||
format!("https://registry.npmjs.org/{}", s.package.as_deref().unwrap_or("")),
|
||||
),
|
||||
"pypi" => (
|
||||
"PyPI".to_string(),
|
||||
format!("https://pypi.org/pypi/{}", s.package.as_deref().unwrap_or("")),
|
||||
),
|
||||
"github" => (
|
||||
"GitHub Releases".to_string(),
|
||||
format!("https://github.com/{}/releases", s.repo.as_deref().unwrap_or("")),
|
||||
),
|
||||
_ => ("官方源".to_string(), String::new()),
|
||||
},
|
||||
None => ("官方源".to_string(), String::new()),
|
||||
};
|
||||
|
||||
match fetch_latest_version(adapter) {
|
||||
LatestVersion::Found(latest) => {
|
||||
let update_available = match ¤t {
|
||||
Some(c) => compare_versions(c, &latest) == std::cmp::Ordering::Less,
|
||||
None => false,
|
||||
};
|
||||
UpdateCheckResult {
|
||||
cli_id,
|
||||
current,
|
||||
latest: Some(latest),
|
||||
update_available,
|
||||
source_zh,
|
||||
source_url,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
LatestVersion::Unsupported(m) => UpdateCheckResult {
|
||||
cli_id, current, latest: None, update_available: false, source_zh, source_url, error: Some(m),
|
||||
},
|
||||
LatestVersion::Failed(m) => UpdateCheckResult {
|
||||
cli_id, current, latest: None, update_available: false, source_zh, source_url, error: Some(m),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 模型列表(Wave 3.1 Req 5):优先 CLI 自带列举命令,否则适配器维护清单。
|
||||
pub fn list_models(adapter: &Adapter) -> ModelListResult {
|
||||
let cli_id = adapter.id.clone();
|
||||
let models = match adapter.models.as_ref() {
|
||||
None => {
|
||||
return ModelListResult {
|
||||
cli_id,
|
||||
models: vec![],
|
||||
source: "empty".into(),
|
||||
detail_zh: "该 CLI 未声明模型清单".into(),
|
||||
}
|
||||
}
|
||||
Some(m) => m,
|
||||
};
|
||||
|
||||
// 优先:CLI 自带列举命令(已安装时实时调取)
|
||||
if !models.command.is_empty() {
|
||||
let prog = &models.command[0];
|
||||
let args: Vec<String> = models.command[1..].to_vec();
|
||||
if agentdock_exec::validate_argv(prog, &args).is_ok() {
|
||||
if let Some(exe) = which_all(prog).first() {
|
||||
let exe = exe.to_string_lossy().to_string();
|
||||
if let Ok((true, text)) = run_capture(std::path::Path::new(&exe), &args) {
|
||||
let parsed = parse_model_lines(&text);
|
||||
if !parsed.is_empty() {
|
||||
return ModelListResult {
|
||||
cli_id,
|
||||
models: parsed,
|
||||
source: "cli_command".into(),
|
||||
detail_zh: format!("已通过 {} 实时列举", models.command.join(" ")),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:适配器按官方文档维护的模型清单
|
||||
let list: Vec<ModelInfo> = models
|
||||
.list
|
||||
.iter()
|
||||
.map(|e| ModelInfo { id: e.id.clone(), label_zh: e.label_zh.clone() })
|
||||
.collect();
|
||||
let source = if list.is_empty() { "empty" } else { "adapter" };
|
||||
ModelListResult {
|
||||
cli_id,
|
||||
models: list,
|
||||
source: source.to_string(),
|
||||
detail_zh: "按官方文档维护的模型清单".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从模型列举命令输出解析模型 ID(逐行清理,剔除明显表头)。
|
||||
fn parse_model_lines(text: &str) -> Vec<ModelInfo> {
|
||||
text.lines()
|
||||
.map(|l| {
|
||||
l.trim()
|
||||
.trim_start_matches('-')
|
||||
.trim_start_matches('*')
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
.filter(|l| !l.is_empty() && l.len() <= 200)
|
||||
.filter(|l| !l.contains("模型") && !l.to_lowercase().starts_with("available") && !l.to_lowercase().starts_with("model"))
|
||||
.take(200)
|
||||
.map(|l| ModelInfo { id: l, label_zh: None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// curl 探测(不 --fail,返回 HTTP 状态码 + 响应体,用于测试连通时区分 401/403/网络失败)。
|
||||
fn curl_probe(url: &str, headers: &[String]) -> (Option<u16>, String) {
|
||||
let mut cmd = std::process::Command::new("curl.exe");
|
||||
cmd.args(["-L", "--silent", "--show-error", "--max-time", "20", "-w", "\n__HTTP_STATUS__%{http_code}"]);
|
||||
for h in headers {
|
||||
cmd.arg("-H").arg(h);
|
||||
}
|
||||
cmd.arg(url);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
match cmd.output() {
|
||||
Ok(out) => {
|
||||
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let status = text
|
||||
.rsplit("__HTTP_STATUS__")
|
||||
.next()
|
||||
.and_then(|s| s.trim().parse::<u16>().ok());
|
||||
let body = text.split("__HTTP_STATUS__").next().unwrap_or("").trim().to_string();
|
||||
(status, body)
|
||||
}
|
||||
Err(_) => (None, String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试连通(Wave 3.1 Req 5):密钥从密钥库读取直连官方接口测一次,全程不落盘不进日志。
|
||||
pub fn test_connection(adapter: &Adapter, secrets: &dyn SecretStore) -> ConnectionTestResult {
|
||||
let cli_id = adapter.id.clone();
|
||||
let test = match adapter.authorization.as_ref().and_then(|a| a.test.as_ref()) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return ConnectionTestResult {
|
||||
cli_id,
|
||||
ok: false,
|
||||
message_zh: "该 CLI 未声明测试连通端点".into(),
|
||||
http_status: None,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let allowed = adapter
|
||||
.official
|
||||
.as_ref()
|
||||
.map(|o| o.allowed_hosts.clone())
|
||||
.unwrap_or_default();
|
||||
if !is_url_host_allowed(&test.url, &allowed) {
|
||||
return ConnectionTestResult {
|
||||
cli_id,
|
||||
ok: false,
|
||||
message_zh: "测试端点不在白名单内".into(),
|
||||
http_status: None,
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
let service = service_name(&adapter.id);
|
||||
let key = match secrets.get(&service, "api_key") {
|
||||
Ok(k) if !k.is_empty() => k,
|
||||
_ => {
|
||||
return ConnectionTestResult {
|
||||
cli_id,
|
||||
ok: false,
|
||||
message_zh: "未找到已保存的 API Key,请先在「授权 / 登录」里保存".into(),
|
||||
http_status: None,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let header_name = test
|
||||
.key_header
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Authorization".to_string());
|
||||
let header_value = if header_name.eq_ignore_ascii_case("authorization") || test.bearer {
|
||||
format!("Bearer {key}")
|
||||
} else {
|
||||
key.clone()
|
||||
};
|
||||
let header = format!("{header_name}: {header_value}");
|
||||
let mut headers = vec![header];
|
||||
headers.extend(test.extra_headers.clone());
|
||||
|
||||
let (status, body) = curl_probe(&test.url, &headers);
|
||||
match status {
|
||||
Some(s) if (200..300).contains(&s) => ConnectionTestResult {
|
||||
cli_id,
|
||||
ok: true,
|
||||
message_zh: "连通正常,官方接口响应成功".into(),
|
||||
http_status: Some(s),
|
||||
detail: Some(redact(&body).chars().take(200).collect()),
|
||||
},
|
||||
Some(401) | Some(403) => ConnectionTestResult {
|
||||
cli_id,
|
||||
ok: false,
|
||||
message_zh: "密钥无效或权限不足(官方接口返回 401/403)".into(),
|
||||
http_status: status,
|
||||
detail: Some(redact(&body).chars().take(200).collect()),
|
||||
},
|
||||
Some(s) => ConnectionTestResult {
|
||||
cli_id,
|
||||
ok: false,
|
||||
message_zh: format!("官方接口返回 HTTP {s}").into(),
|
||||
http_status: Some(s),
|
||||
detail: Some(redact(&body).chars().take(200).collect()),
|
||||
},
|
||||
None => ConnectionTestResult {
|
||||
cli_id,
|
||||
ok: false,
|
||||
message_zh: "网络请求失败,无法连通官方接口".into(),
|
||||
http_status: None,
|
||||
detail: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 程序名 → 运行时 id(安装命令用到 npm 视为依赖 Node.js,等等)。
|
||||
fn prog_to_runtime(prog: &str) -> Option<&'static str> {
|
||||
match prog.to_ascii_lowercase().as_str() {
|
||||
"npm" | "npx" | "node" => Some("node"),
|
||||
"uv" | "uvx" => Some("uv"),
|
||||
"python" | "python3" | "pip" => Some("python"),
|
||||
"git" => Some("git"),
|
||||
"winget" => Some("winget"),
|
||||
"apt" | "apt-get" | "sudo" => Some("apt"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断适配器是否依赖某运行时(runtime_deps 声明,或其安装/更新/卸载命令用到对应程序)。
|
||||
fn adapter_depends_on(a: &Adapter, runtime_id: &str) -> bool {
|
||||
if a.runtime_deps.iter().any(|d| d.id == runtime_id) {
|
||||
return true;
|
||||
}
|
||||
let mut progs: Vec<String> = Vec::new();
|
||||
if let Some(i) = a.install.as_ref() {
|
||||
for c in &i.channels {
|
||||
if let Some(p) = c.command.first() {
|
||||
progs.push(p.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(u) = a.update.as_ref() {
|
||||
if let Some(p) = u.command.first() {
|
||||
progs.push(p.to_lowercase());
|
||||
}
|
||||
}
|
||||
if let Some(u) = a.uninstall.as_ref() {
|
||||
if let Some(p) = u.command.first() {
|
||||
progs.push(p.to_lowercase());
|
||||
}
|
||||
}
|
||||
progs.iter().any(|p| prog_to_runtime(p) == Some(runtime_id))
|
||||
}
|
||||
|
||||
/// 系统环境诊断(Wave 3.1 Req 7):检查缺失/过低的系统依赖,每项标注影响哪些 CLI。
|
||||
pub fn diagnose_system(env: &agentdock_platform::model::PlatformEnv, adapters: &[Adapter]) -> SystemEnvReport {
|
||||
let rt = &env.runtimes;
|
||||
let ids: &[&str] = if env.os == "windows" {
|
||||
&["node", "python", "uv", "git", "winget"]
|
||||
} else {
|
||||
&["node", "python", "uv", "git", "apt"]
|
||||
};
|
||||
let mut findings = Vec::new();
|
||||
|
||||
for id in ids {
|
||||
let info: Option<&agentdock_platform::model::RuntimeInfo> = match *id {
|
||||
"node" => rt.node.as_ref(),
|
||||
"npm" => rt.npm.as_ref(),
|
||||
"python" => rt.python.as_ref(),
|
||||
"uv" => rt.uv.as_ref(),
|
||||
"git" => rt.git.as_ref(),
|
||||
"winget" => rt.winget.as_ref(),
|
||||
"apt" => rt.apt.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let affected: Vec<String> = adapters
|
||||
.iter()
|
||||
.filter(|a| adapter_depends_on(a, id))
|
||||
.map(|a| a.id.clone())
|
||||
.collect();
|
||||
if affected.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (status, version, message_zh) = match info {
|
||||
Some(i) if i.status == "installed" => {
|
||||
let mut below_min = false;
|
||||
let mut range_used = String::new();
|
||||
for a in adapters {
|
||||
for d in &a.runtime_deps {
|
||||
if d.id == *id {
|
||||
if let Some(range) = &d.semver_range {
|
||||
if let Some(v) = i.version.as_deref() {
|
||||
if !diag::version_satisfies(v, range) {
|
||||
below_min = true;
|
||||
range_used = range.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if below_min {
|
||||
(
|
||||
"below_min".to_string(),
|
||||
i.version.clone(),
|
||||
format!(
|
||||
"版本 {} 不满足受影响 CLI 要求的 {}",
|
||||
i.version.as_deref().unwrap_or("?"),
|
||||
range_used
|
||||
),
|
||||
)
|
||||
} else {
|
||||
("installed".to_string(), i.version.clone(), "已安装且版本满足要求".to_string())
|
||||
}
|
||||
}
|
||||
Some(i) => (i.status.clone(), i.version.clone(), format!("状态:{}", i.status)),
|
||||
None => ("not_installed".to_string(), None, "未检测到该运行时".to_string()),
|
||||
};
|
||||
|
||||
findings.push(SystemEnvFinding {
|
||||
runtime_id: id.to_string(),
|
||||
label_zh: runtime_label_zh(id).to_string(),
|
||||
status,
|
||||
version,
|
||||
affected_cli: affected,
|
||||
message_zh,
|
||||
});
|
||||
}
|
||||
|
||||
SystemEnvReport { findings }
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 测试
|
||||
// =====================================================================
|
||||
@@ -1833,4 +2303,78 @@ configuration:
|
||||
assert!(url.contains("openai.com") || url.contains("auth.openai"), "验证链接应指向官方域名: {url}");
|
||||
assert!(!code.is_empty(), "设备码不应为空");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_versions_ordering() {
|
||||
use std::cmp::Ordering;
|
||||
assert_eq!(compare_versions("1.2.3", "1.2.4"), Ordering::Less);
|
||||
assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater);
|
||||
assert_eq!(compare_versions("v1.2.3", "1.2.3"), Ordering::Equal);
|
||||
assert_eq!(compare_versions("2026.08.11", "2026.08.10"), Ordering::Greater);
|
||||
assert_eq!(compare_versions("1.2.3", "1.2.3.0"), Ordering::Equal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prog_to_runtime_maps_commands() {
|
||||
assert_eq!(prog_to_runtime("npm"), Some("node"));
|
||||
assert_eq!(prog_to_runtime("uv"), Some("uv"));
|
||||
assert_eq!(prog_to_runtime("winget"), Some("winget"));
|
||||
assert_eq!(prog_to_runtime("apt-get"), Some("apt"));
|
||||
assert_eq!(prog_to_runtime("opencode"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_depends_on_via_install_command() {
|
||||
let adapter = codex_adapter(); // codex 走 npm install → 依赖 node
|
||||
assert!(adapter_depends_on(&adapter, "node"));
|
||||
assert!(!adapter_depends_on(&adapter, "uv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnose_system_reports_missing_runtime_with_affected_cli() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
|
||||
let adapters = load_adapters(&dir).expect("适配器目录应可加载");
|
||||
let mut env = agentdock_platform::model::PlatformEnv {
|
||||
os: "windows".into(),
|
||||
..Default::default()
|
||||
};
|
||||
env.runtimes.node = Some(agentdock_platform::model::RuntimeInfo {
|
||||
status: "installed".into(),
|
||||
version: Some("24.18.0".into()),
|
||||
path: None,
|
||||
});
|
||||
env.runtimes.uv = None; // uv 未安装
|
||||
|
||||
let report = diagnose_system(&env, &adapters);
|
||||
let uv = report.findings.iter().find(|f| f.runtime_id == "uv").expect("应包含 uv 依赖项");
|
||||
assert_eq!(uv.status, "not_installed");
|
||||
assert!(uv.affected_cli.iter().any(|c| c == "kimi" || c == "aider"), "uv 应标注影响 kimi/aider");
|
||||
assert!(!uv.message_zh.is_empty());
|
||||
|
||||
let node = report.findings.iter().find(|f| f.runtime_id == "node").expect("应包含 node 依赖项");
|
||||
assert_eq!(node.status, "installed");
|
||||
assert!(node.affected_cli.iter().any(|c| c == "gemini"), "node 应标注影响 gemini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_models_falls_back_to_adapter_list() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
|
||||
let adapters = load_adapters(&dir).expect("适配器目录应可加载");
|
||||
let kimi = adapters.iter().find(|a| a.id == "kimi").unwrap();
|
||||
let r = list_models(kimi);
|
||||
assert!(!r.models.is_empty(), "kimi 应维护模型清单");
|
||||
assert!(r.models.iter().any(|m| m.id == "kimi-code/k3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_update_returns_error_when_no_source() {
|
||||
// warp 未声明 update.source,应如实返回「未声明来源」而非误报可更新
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
|
||||
let adapters = load_adapters(&dir).expect("适配器目录应可加载");
|
||||
let warp = adapters.iter().find(|a| a.id == "warp").unwrap();
|
||||
let r = check_update(warp);
|
||||
assert!(!r.update_available);
|
||||
assert!(r.latest.is_none());
|
||||
assert!(r.error.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,12 @@ pub fn runtime_for_prog(prog: &str) -> Option<&'static str> {
|
||||
pub fn runtime_label_zh(id: &str) -> &'static str {
|
||||
match id {
|
||||
"node" => "Node.js",
|
||||
"npm" => "npm",
|
||||
"python" => "Python",
|
||||
"git" => "Git",
|
||||
"winget" => "winget",
|
||||
"uv" => "uv",
|
||||
"apt" => "apt",
|
||||
"choco" => "choco",
|
||||
"scoop" => "scoop",
|
||||
_ => "运行时",
|
||||
|
||||
@@ -16,12 +16,14 @@ pub use engine::{
|
||||
parse_version, read_config, run_action, strip_ansi, verify_config, write_config,
|
||||
};
|
||||
pub use error::EngineError;
|
||||
pub use errors_zh::{map_exec_error, runtime_for_prog};
|
||||
pub use errors_zh::{map_exec_error, runtime_for_prog, runtime_label_zh};
|
||||
pub use process::{open_with_shell, resolve_exe, run_capture, run_streaming, run_terminal, run_with_stdin, which_all, RunningProcess};
|
||||
pub use runtime_install::{
|
||||
download_with_curl, is_url_host_allowed, source_for, supported_runtimes, RuntimeSource,
|
||||
download_with_curl, fetch_http_text, is_url_host_allowed, source_for, supported_runtimes,
|
||||
RuntimeSource,
|
||||
};
|
||||
pub use types::{
|
||||
ActionEvent, AuthFlowEvent, AuthStatus, AuthModeInfo, ConfigFieldState, ConfigFileState, ConfigFormState,
|
||||
DetectResult, EnvFieldState, ErrorHint, WriteResult, ConfigVerifyResult, now_secs,
|
||||
ConnectionTestResult, DetectResult, EnvFieldState, ErrorHint, ModelInfo, ModelListResult,
|
||||
SystemEnvFinding, SystemEnvReport, UpdateCheckResult, WriteResult, ConfigVerifyResult, now_secs,
|
||||
};
|
||||
|
||||
@@ -134,6 +134,31 @@ pub fn download_with_curl(url: &str, dest: &std::path::Path) -> std::io::Result<
|
||||
}
|
||||
}
|
||||
|
||||
/// 用系统 `curl.exe` 拉取 HTTPS 文本到 stdout(用于官方源版本查询 / 测试连通)。
|
||||
/// 仅应配合 `is_url_host_allowed` 白名单校验后使用。`headers` 为额外请求头(如鉴权)。
|
||||
pub fn fetch_http_text(url: &str, headers: &[String]) -> std::io::Result<String> {
|
||||
let mut cmd = std::process::Command::new("curl.exe");
|
||||
cmd.args(["-L", "--fail", "--silent", "--show-error", "--max-time", "20"]);
|
||||
for h in headers {
|
||||
cmd.arg("-H").arg(h);
|
||||
}
|
||||
cmd.arg(url);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
let out = cmd.output()?;
|
||||
if out.status.success() {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
} else {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"curl 请求失败(网络错误或来源不可用)",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// 极简 URL 主机提取(仅用于白名单比对,不解析完整 URL)。
|
||||
fn extract_host(url: &str) -> Option<String> {
|
||||
let s = url.trim();
|
||||
|
||||
@@ -291,3 +291,76 @@ pub fn now_secs() -> u64 {
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Wave 3.1:可更新判定 / 模型列表 / 测试连通 / 系统环境诊断
|
||||
// =====================================================================
|
||||
|
||||
/// 「可更新」判定结果(官方源最新版本 vs 本地已装版本 semver 对比)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateCheckResult {
|
||||
pub cli_id: String,
|
||||
/// 本地已装版本(未安装为 None)
|
||||
pub current: Option<String>,
|
||||
/// 官方源最新版本(查询失败为 None)
|
||||
pub latest: Option<String>,
|
||||
/// 是否可更新(本地版本 < 官方最新版本)
|
||||
pub update_available: bool,
|
||||
/// 依据来源(中文,如「npm registry」)
|
||||
pub source_zh: String,
|
||||
/// 依据来源链接
|
||||
pub source_url: String,
|
||||
/// 查询失败原因(成功为 None)
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 模型信息(id + 中文名)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelInfo {
|
||||
pub id: String,
|
||||
pub label_zh: Option<String>,
|
||||
}
|
||||
|
||||
/// 模型列表结果(来源:CLI 自带列举命令或适配器维护清单)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelListResult {
|
||||
pub cli_id: String,
|
||||
pub models: Vec<ModelInfo>,
|
||||
/// cli_command | adapter | empty
|
||||
pub source: String,
|
||||
pub detail_zh: String,
|
||||
}
|
||||
|
||||
/// 「测试连通」结果(已授权工具直连官方接口测一次)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionTestResult {
|
||||
pub cli_id: String,
|
||||
pub ok: bool,
|
||||
pub message_zh: String,
|
||||
pub http_status: Option<u16>,
|
||||
/// 脱敏后的补充信息(不含密钥明文)
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// 系统环境诊断:单个环境依赖项的状态及其影响的 CLI。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SystemEnvFinding {
|
||||
/// 依赖 id:node / npm / python / uv / git / winget / apt
|
||||
pub runtime_id: String,
|
||||
/// 中文名(Node.js / Python / Git / uv / winget / apt)
|
||||
pub label_zh: String,
|
||||
/// installed | not_installed | not_in_path | below_min
|
||||
pub status: String,
|
||||
/// 当前版本(可为 None)
|
||||
pub version: Option<String>,
|
||||
/// 该依赖缺失/版本过低时受影响的 CLI id 列表
|
||||
pub affected_cli: Vec<String>,
|
||||
/// 中文结论
|
||||
pub message_zh: String,
|
||||
}
|
||||
|
||||
/// 系统环境诊断报告。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SystemEnvReport {
|
||||
pub findings: Vec<SystemEnvFinding>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user