chore: wave-0 baseline (Tauri2+React shell, platform detect, fake catalog)
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "agentdock-adapter"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "适配器 schema 加载、版本校验、dry-run、执行器接口(架构 §3)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
@@ -0,0 +1,110 @@
|
||||
//! 目录索引与占位条目加载(Wave 0)
|
||||
//!
|
||||
//! 从 `adapters/catalog.yaml` 读取索引,再逐个读取 `tools/*.yaml`,
|
||||
//! 返回目录条目。仅消费 `id / name / name_zh / vendor / status` 五个字段。
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AdapterError;
|
||||
|
||||
/// 目录索引(adapters/catalog.yaml)
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Catalog {
|
||||
#[serde(rename = "catalog_version")]
|
||||
pub version: u32,
|
||||
pub tools: Vec<CatalogRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct CatalogRef {
|
||||
pub id: String,
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
/// 目录条目(占位 schema,Wave 0 仅五字段;完整字段见架构 §3.1)
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct CatalogEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "name_zh")]
|
||||
pub name_zh: String,
|
||||
pub vendor: String,
|
||||
/// available | watch
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// 加载目录索引与全部工具占位 YAML
|
||||
pub fn load_catalog<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<CatalogEntry>, AdapterError> {
|
||||
let dir = adapters_dir.as_ref();
|
||||
let catalog_text = fs::read_to_string(dir.join("catalog.yaml"))
|
||||
.map_err(|e| AdapterError::Io(format!("读取 catalog.yaml 失败: {e}")))?;
|
||||
let catalog: Catalog = serde_yaml::from_str(&catalog_text)
|
||||
.map_err(|e| AdapterError::Parse(format!("解析 catalog.yaml 失败: {e}")))?;
|
||||
|
||||
let mut entries = Vec::with_capacity(catalog.tools.len());
|
||||
for r in &catalog.tools {
|
||||
let text = fs::read_to_string(dir.join(&r.file))
|
||||
.map_err(|e| AdapterError::Io(format!("读取 {} 失败: {e}", r.file)))?;
|
||||
let entry: CatalogEntry = serde_yaml::from_str(&text)
|
||||
.map_err(|e| AdapterError::Parse(format!("解析 {} 失败: {e}", r.file)))?;
|
||||
if entry.id != r.id {
|
||||
return Err(AdapterError::Parse(format!(
|
||||
"索引 id 与文件内 id 不一致: 索引={} 文件={}",
|
||||
r.id, entry.id
|
||||
)));
|
||||
}
|
||||
if entry.status != "available" && entry.status != "watch" {
|
||||
return Err(AdapterError::Parse(format!(
|
||||
"{} 的 status 非法: {}(应为 available | watch)",
|
||||
entry.id, entry.status
|
||||
)));
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_minimal_tool_yaml() {
|
||||
let yaml = "id: codex\nname: Codex CLI\nname_zh: Codex CLI\nvendor: OpenAI\nstatus: available\n";
|
||||
let entry: CatalogEntry = serde_yaml::from_str(yaml).expect("应能解析");
|
||||
assert_eq!(entry.id, "codex");
|
||||
assert_eq!(entry.name_zh, "Codex CLI");
|
||||
assert_eq!(entry.vendor, "OpenAI");
|
||||
assert_eq!(entry.status, "available");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_status() {
|
||||
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: unknown\n";
|
||||
let entry: Result<CatalogEntry, _> = serde_yaml::from_str(yaml);
|
||||
// 解析本身成功,非法 status 由 load_catalog 校验;此处确认 schema 字段可读
|
||||
assert!(entry.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_real_catalog_from_repo() {
|
||||
// 以真实 adapters/ 目录做集成测试(相对本 crate 位于 ../../adapters)
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
|
||||
let entries = load_catalog(&dir).expect("真实目录应可加载");
|
||||
assert_eq!(entries.len(), 14, "第一批应为 14 个工具");
|
||||
for e in &entries {
|
||||
assert!(!e.id.is_empty());
|
||||
assert!(!e.name.is_empty());
|
||||
assert!(!e.name_zh.is_empty());
|
||||
assert!(!e.vendor.is_empty());
|
||||
}
|
||||
let ids: Vec<&str> = entries.iter().map(|e| e.id.as_str()).collect();
|
||||
for want in ["codex", "claude-code", "gemini", "copilot", "kimi", "qwen", "codebuddy",
|
||||
"opencode", "crush", "goose", "aider", "cursor", "cline", "warp"] {
|
||||
assert!(ids.contains(&want), "目录应包含 {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! 适配器层错误类型
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AdapterError {
|
||||
Io(String),
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for AdapterError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
AdapterError::Io(m) => write!(f, "IO: {m}"),
|
||||
AdapterError::Parse(m) => write!(f, "Parse: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AdapterError {}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! agentdock-adapter —— 适配器层
|
||||
//!
|
||||
//! 负责适配器 schema 加载、版本校验、dry-run 与执行器接口(架构 §3)。
|
||||
//! Wave 0:仅落地目录索引与占位条目加载(`catalog` 模块);
|
||||
//! 完整 schema 校验、执行器接口随 Wave 1 实现。
|
||||
|
||||
pub mod catalog;
|
||||
pub mod error;
|
||||
|
||||
pub use catalog::{Catalog, CatalogEntry, CatalogRef, load_catalog};
|
||||
pub use error::AdapterError;
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "agentdock-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,18 @@
|
||||
//! agentdock-config —— 配置读写层(架构 §6)
|
||||
//!
|
||||
//! 职责:原子写入 + 自动备份、多格式统一编解码(ConfigCodec trait:
|
||||
//! toml / json / jsonc / yaml / env / crushrc)。
|
||||
//! Wave 0:空骨架,随 Wave 1/2 落地。
|
||||
|
||||
/// 配置层能力标记(占位)
|
||||
pub const LAYER: &str = "agentdock-config";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_identity() {
|
||||
assert_eq!(LAYER, "agentdock-config");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "agentdock-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,17 @@
|
||||
//! agentdock-core —— 编排层(架构 §2 模块关系:UI --invoke--> commands --→ core orchestrator)
|
||||
//!
|
||||
//! 职责:发现 / 安装 / 配置 / 授权 / 诊断 流水线编排。
|
||||
//! Wave 0:空骨架,流水线自 Wave 1 起逐波落地。
|
||||
|
||||
/// 编排层能力标记(占位)
|
||||
pub const LAYER: &str = "agentdock-core";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_identity() {
|
||||
assert_eq!(LAYER, "agentdock-core");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "agentdock-diag"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,17 @@
|
||||
//! agentdock-diag —— 诊断规则引擎(架构 §10)
|
||||
//!
|
||||
//! 职责:PATH / 依赖缺失 / 版本冲突 / 配置损坏 四类检查的规则引擎。
|
||||
//! Wave 0:空骨架,随 Wave 2 落地。
|
||||
|
||||
/// 诊断层能力标记(占位)
|
||||
pub const LAYER: &str = "agentdock-diag";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_identity() {
|
||||
assert_eq!(LAYER, "agentdock-diag");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "agentdock-exec"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,17 @@
|
||||
//! agentdock-exec —— 安全进程执行层(架构 §3.2 / §4.2)
|
||||
//!
|
||||
//! 职责:仅执行适配器声明的 argv,禁止 shell 拼接、管道、重定向;
|
||||
//! 参数白名单校验。Wave 0:空骨架,随 Wave 1 落地。
|
||||
|
||||
/// 执行层能力标记(占位)
|
||||
pub const LAYER: &str = "agentdock-exec";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_identity() {
|
||||
assert_eq!(LAYER, "agentdock-exec");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "agentdock-platform"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Win/Linux 环境检测、PATH、发行版识别(架构 §5)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.52"
|
||||
@@ -0,0 +1,315 @@
|
||||
//! 环境检测实现(架构 §5)
|
||||
//!
|
||||
//! 只做「探测」不执行用户命令:调用系统 `--version` 类只读命令并解析输出;
|
||||
//! 进程执行统一走 `std::process::Command`,不做 shell 拼接。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::model::{Capabilities, PlatformEnv, RuntimeInfo, Runtimes, Shells};
|
||||
|
||||
/// 全量环境检测(Windows / Linux 双平台)
|
||||
pub fn detect_env() -> PlatformEnv {
|
||||
#[cfg(windows)]
|
||||
let (os, os_version) = (String::from("windows"), windows_os_version());
|
||||
#[cfg(not(windows))]
|
||||
let (os, os_version) = (String::from("linux"), linux_os_version());
|
||||
|
||||
PlatformEnv {
|
||||
os,
|
||||
os_version,
|
||||
arch: std::env::consts::ARCH.to_string(),
|
||||
shells: detect_shells(),
|
||||
runtimes: detect_runtimes(),
|
||||
path_entries: path_entries(),
|
||||
capabilities: detect_capabilities(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从命令输出中提取首个版本号(如 "v24.18.0" -> "24.18.0")。
|
||||
/// 规则:取第一段以数字开头、由数字与点组成的子串,尾部点剔除。
|
||||
pub fn extract_version(output: &str) -> Option<String> {
|
||||
let bytes = output.as_bytes();
|
||||
let n = bytes.len();
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
if bytes[i].is_ascii_digit() {
|
||||
let mut j = i;
|
||||
while j < n && (bytes[j].is_ascii_digit() || bytes[j] == b'.') {
|
||||
j += 1;
|
||||
}
|
||||
let s = output[i..j].trim_end_matches('.');
|
||||
if !s.is_empty() {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
i = j;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 按平台分隔符拆分 PATH
|
||||
fn path_entries() -> Vec<String> {
|
||||
std::env::var("PATH")
|
||||
.unwrap_or_default()
|
||||
.split(path_list_sep())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn path_list_sep() -> char {
|
||||
';'
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn path_list_sep() -> char {
|
||||
':'
|
||||
}
|
||||
|
||||
/// 在 PATH 中解析可执行文件。
|
||||
/// Windows 优先 .exe/.cmd/.bat(CreateProcess 无法直接执行无扩展名文件,
|
||||
/// 例如 nodejs 目录下的 npm bash 脚本),最后才回退无扩展名。
|
||||
fn resolve_program(program: &str) -> Option<PathBuf> {
|
||||
let direct = Path::new(program);
|
||||
if direct.is_absolute() && direct.is_file() {
|
||||
return Some(direct.to_path_buf());
|
||||
}
|
||||
let exts: &[&str] = if cfg!(windows) {
|
||||
&[".exe", ".cmd", ".bat", ""]
|
||||
} else {
|
||||
&[""]
|
||||
};
|
||||
for dir in path_entries() {
|
||||
for ext in exts {
|
||||
let cand = Path::new(&dir).join(format!("{program}{ext}"));
|
||||
if cand.is_file() {
|
||||
return Some(cand);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 探测一个可执行文件:返回状态 / 版本 / 路径。
|
||||
/// 结果分类:installed / not_in_path / exec_failed / version_unparseable。
|
||||
fn probe(program: &str, args: &[&str]) -> Option<RuntimeInfo> {
|
||||
let path = resolve_program(program);
|
||||
let path_str = path.as_ref().map(|p| p.to_string_lossy().to_string());
|
||||
|
||||
let Some(exe) = path else {
|
||||
return Some(RuntimeInfo {
|
||||
status: "not_in_path".into(),
|
||||
version: None,
|
||||
path: None,
|
||||
});
|
||||
};
|
||||
|
||||
let mut cmd = Command::new(&exe);
|
||||
cmd.args(args);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// CREATE_NO_WINDOW:探测命令不闪黑框
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
let output = match cmd.output() {
|
||||
Ok(o) => o,
|
||||
Err(_) => {
|
||||
return Some(RuntimeInfo {
|
||||
status: "exec_failed".into(),
|
||||
version: None,
|
||||
path: path_str,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
return Some(RuntimeInfo {
|
||||
status: "exec_failed".into(),
|
||||
version: None,
|
||||
path: path_str,
|
||||
});
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let text = if stdout.trim().is_empty() {
|
||||
String::from_utf8_lossy(&output.stderr).to_string()
|
||||
} else {
|
||||
stdout.to_string()
|
||||
};
|
||||
|
||||
match extract_version(&text) {
|
||||
Some(version) => Some(RuntimeInfo {
|
||||
status: "installed".into(),
|
||||
version: Some(version),
|
||||
path: path_str,
|
||||
}),
|
||||
None => Some(RuntimeInfo {
|
||||
status: "version_unparseable".into(),
|
||||
version: None,
|
||||
path: path_str,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_runtimes() -> Runtimes {
|
||||
Runtimes {
|
||||
node: probe("node", &["--version"]),
|
||||
npm: probe("npm", &["--version"]),
|
||||
python: probe_python(),
|
||||
uv: probe("uv", &["--version"]),
|
||||
git: probe("git", &["--version"]),
|
||||
winget: probe("winget", &["--version"]),
|
||||
apt: probe("apt", &["--version"]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Python:优先 `python --version`,失败退回 `py --version`(Windows 启动器)
|
||||
fn probe_python() -> Option<RuntimeInfo> {
|
||||
let direct = probe("python", &["--version"]);
|
||||
if matches!(direct, Some(ref i) if i.status == "installed") {
|
||||
return direct;
|
||||
}
|
||||
probe("py", &["--version"])
|
||||
}
|
||||
|
||||
fn detect_shells() -> Shells {
|
||||
Shells {
|
||||
powershell_version: probe_powershell(),
|
||||
pwsh_version: probe("pwsh", &["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"])
|
||||
.and_then(|i| i.version),
|
||||
bash_available: resolve_program("bash").is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn probe_powershell() -> Option<String> {
|
||||
probe(
|
||||
"powershell",
|
||||
&["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"],
|
||||
)
|
||||
.and_then(|i| i.version)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn probe_powershell() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_capabilities() -> Capabilities {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
Capabilities {
|
||||
// Windows Credential Manager 常驻可用(架构 §4.1)
|
||||
keyring: "ok".into(),
|
||||
// `net session` 仅在管理员上下文返回成功(提权探测,仅检测不执行用户命令)
|
||||
can_elevate: run_success("net", &["session"]),
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Capabilities {
|
||||
keyring: if resolve_program("secret-tool").is_some() {
|
||||
"ok".into()
|
||||
} else {
|
||||
"missing".into()
|
||||
},
|
||||
can_elevate: std::env::var("USER").map(|u| u == "root").unwrap_or(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_success(program: &str, args: &[&str]) -> bool {
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
cmd.output().map(|o| o.status.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_os_version() -> String {
|
||||
use winreg::enums::HKEY_LOCAL_MACHINE;
|
||||
use winreg::RegKey;
|
||||
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
match hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion") {
|
||||
Ok(key) => {
|
||||
let product: String = key.get_value("ProductName").unwrap_or_else(|_| "Windows".into());
|
||||
let display: String = key.get_value("DisplayVersion").unwrap_or_default();
|
||||
let build: String = key.get_value("CurrentBuildNumber").unwrap_or_default();
|
||||
let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(0);
|
||||
let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
|
||||
let ver = if !display.is_empty() {
|
||||
display
|
||||
} else {
|
||||
format!("{major}.{minor}")
|
||||
};
|
||||
format!("{product} {ver} (Build {build})")
|
||||
}
|
||||
Err(_) => "Windows (unknown version)".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn linux_os_version() -> String {
|
||||
let content = std::fs::read_to_string("/etc/os-release").unwrap_or_default();
|
||||
for line in content.lines() {
|
||||
if let Some(v) = line.strip_prefix("PRETTY_NAME=") {
|
||||
return v.trim().trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
"Linux (unknown distro)".into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_version_from_common_outputs() {
|
||||
assert_eq!(extract_version("v24.18.0"), Some("24.18.0".into()));
|
||||
assert_eq!(extract_version("11.16.0\n"), Some("11.16.0".into()));
|
||||
assert_eq!(extract_version("Python 3.14.6"), Some("3.14.6".into()));
|
||||
assert_eq!(extract_version("git version 2.47.1.windows.1"), Some("2.47.1".into()));
|
||||
assert_eq!(extract_version("GNU bash, version 5.2.26(1)-release"), Some("5.2.26".into()));
|
||||
assert_eq!(extract_version("v1.8.1911"), Some("1.8.1911".into()));
|
||||
assert_eq!(extract_version("5.1.26100.1"), Some("5.1.26100.1".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_none_when_no_digits() {
|
||||
assert_eq!(extract_version("command not found"), None);
|
||||
assert_eq!(extract_version(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_trailing_dots() {
|
||||
assert_eq!(extract_version("v1.2.3."), Some("1.2.3".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_has_path_on_windows() {
|
||||
// PATH 变量在任何真实 Windows/Linux 上都存在
|
||||
assert!(!path_entries().is_empty(), "PATH 不应为空");
|
||||
}
|
||||
|
||||
/// 实机自测:打印本机环境检测快照(cargo test -p agentdock-platform -- --ignored --nocapture)
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn print_detect_env_snapshot() {
|
||||
let env = detect_env();
|
||||
println!("{:#?}", env);
|
||||
assert_eq!(env.os, "windows");
|
||||
assert!(!env.os_version.is_empty());
|
||||
assert!(!env.arch.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! agentdock-platform —— 平台检测层
|
||||
//!
|
||||
//! 负责 Windows / Linux 的本机环境检测(架构 §5):
|
||||
//! OS 版本、架构、PATH、shell、运行时(Node/npm/Python/uv/Git/winget/apt)、
|
||||
//! 能力(密钥库、提权探测)。
|
||||
|
||||
pub mod detect;
|
||||
pub mod model;
|
||||
|
||||
pub use model::{Capabilities, PlatformEnv, RuntimeInfo, Runtimes, Shells};
|
||||
@@ -0,0 +1,56 @@
|
||||
//! 平台环境数据结构(对齐架构 §5 `PlatformEnv`)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 平台环境快照
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct PlatformEnv {
|
||||
/// os: windows | linux
|
||||
pub os: String,
|
||||
/// 人类可读的系统版本(如 "Windows 11 24H2 (Build 26100)" / "Ubuntu 24.04 LTS")
|
||||
pub os_version: String,
|
||||
/// 架构(std::env::consts::ARCH,如 x86_64)
|
||||
pub arch: String,
|
||||
pub shells: Shells,
|
||||
pub runtimes: Runtimes,
|
||||
/// PATH 条目(Windows 用 ';' 分割,Linux 用 ':')
|
||||
pub path_entries: Vec<String>,
|
||||
pub capabilities: Capabilities,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct Shells {
|
||||
/// Windows PowerShell(5.x)版本
|
||||
pub powershell_version: Option<String>,
|
||||
/// PowerShell 7+(pwsh)版本
|
||||
pub pwsh_version: Option<String>,
|
||||
pub bash_available: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct Runtimes {
|
||||
pub node: Option<RuntimeInfo>,
|
||||
pub npm: Option<RuntimeInfo>,
|
||||
pub python: Option<RuntimeInfo>,
|
||||
pub uv: Option<RuntimeInfo>,
|
||||
pub git: Option<RuntimeInfo>,
|
||||
pub winget: Option<RuntimeInfo>,
|
||||
pub apt: Option<RuntimeInfo>,
|
||||
}
|
||||
|
||||
/// 单个运行时探测结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct RuntimeInfo {
|
||||
/// installed | not_installed | not_in_path | exec_failed | version_unparseable
|
||||
pub status: String,
|
||||
pub version: Option<String>,
|
||||
/// 解析到的可执行文件绝对路径(PATH 搜索)
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct Capabilities {
|
||||
/// ok | missing(Linux 依赖 secret-tool / DBus Secret Service)
|
||||
pub keyring: String,
|
||||
pub can_elevate: bool,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "agentdock-secrets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,17 @@
|
||||
//! agentdock-secrets —— 系统密钥库封装与日志脱敏(架构 §4.1 / §4.3)
|
||||
//!
|
||||
//! 职责:keyring 封装(Windows Credential Manager / Linux Secret Service)、
|
||||
//! 敏感值脱敏。Wave 0:空骨架,随 Wave 1 落地。
|
||||
|
||||
/// 密钥层能力标记(占位)
|
||||
pub const LAYER: &str = "agentdock-secrets";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_identity() {
|
||||
assert_eq!(LAYER, "agentdock-secrets");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "agentdock-store"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "SQLite 本地状态存储:schema 迁移与仓库(架构 §2 状态存储层)"
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,32 @@
|
||||
//! 存储层错误类型
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StoreError {
|
||||
Io(std::io::Error),
|
||||
Sqlite(rusqlite::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for StoreError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
StoreError::Io(e) => write!(f, "IO: {e}"),
|
||||
StoreError::Sqlite(e) => write!(f, "SQLite: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StoreError {}
|
||||
|
||||
impl From<std::io::Error> for StoreError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
StoreError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for StoreError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
StoreError::Sqlite(e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! agentdock-store —— 本地状态存储(SQLite)
|
||||
//!
|
||||
//! Wave 0:SQLite 空库 + schema 迁移骨架。仓库查询接口随后续波次实现。
|
||||
|
||||
pub mod error;
|
||||
pub mod migrations;
|
||||
pub mod schema;
|
||||
pub mod store;
|
||||
|
||||
pub use error::StoreError;
|
||||
pub use store::{Store, default_data_dir};
|
||||
@@ -0,0 +1,26 @@
|
||||
//! 迁移执行器(Wave 0 骨架)
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::schema::{MIGRATIONS, SCHEMA_VERSION};
|
||||
|
||||
/// 应用全部迁移。所有语句均为幂等(IF NOT EXISTS),可安全重复执行。
|
||||
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for sql in MIGRATIONS {
|
||||
tx.execute_batch(sql)?;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO schema_version (version) VALUES (?1)",
|
||||
[SCHEMA_VERSION],
|
||||
)?;
|
||||
tx.commit()
|
||||
}
|
||||
|
||||
/// 读取当前 schema 版本(未初始化返回 0)
|
||||
pub fn current_version(conn: &Connection) -> rusqlite::Result<i64> {
|
||||
conn.query_row("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.or(Ok(0))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! schema 定义与迁移骨架(Wave 0)
|
||||
//!
|
||||
//! 迁移采用顺序数组:每个元素是一段幂等 SQL(CREATE TABLE IF NOT EXISTS)。
|
||||
//! 后续波次新增表时在 `MIGRATIONS` 尾部追加并提升 `SCHEMA_VERSION`。
|
||||
|
||||
/// 当前 schema 版本
|
||||
pub const SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
/// 迁移列表(按顺序执行,均需幂等)
|
||||
pub const MIGRATIONS: [&str; 3] = [
|
||||
// 迁移 1:schema 版本表
|
||||
"CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);",
|
||||
// 迁移 2:已安装 CLI 记录
|
||||
"CREATE TABLE IF NOT EXISTS installed_clis (
|
||||
cli_id TEXT PRIMARY KEY,
|
||||
name_zh TEXT NOT NULL,
|
||||
version TEXT,
|
||||
install_channel TEXT,
|
||||
detected_at TEXT
|
||||
);",
|
||||
// 迁移 3:诊断历史
|
||||
"CREATE TABLE IF NOT EXISTS diagnostic_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cli_id TEXT NOT NULL,
|
||||
rule_id TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
message_zh TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);",
|
||||
];
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Store:打开/创建 SQLite 空库并应用迁移(Wave 0)
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::error::StoreError;
|
||||
use crate::migrations;
|
||||
|
||||
/// 本地状态存储句柄
|
||||
pub struct Store {
|
||||
conn: Connection,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// 打开(不存在则创建)指定路径的 SQLite 库并应用迁移
|
||||
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, StoreError> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
let conn = Connection::open(&path)?;
|
||||
conn.pragma_update(None, "foreign_keys", true)?;
|
||||
migrations::migrate(&conn)?;
|
||||
Ok(Self { conn, path })
|
||||
}
|
||||
|
||||
/// 打开默认数据目录下的 agentdock.db(Windows: %APPDATA%\agentdock)
|
||||
pub fn open_default() -> Result<Self, StoreError> {
|
||||
Self::open(default_data_dir().join("agentdock.db"))
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn connection(&self) -> &Connection {
|
||||
&self.conn
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认数据目录
|
||||
pub fn default_data_dir() -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(appdata) = std::env::var_os("APPDATA") {
|
||||
return PathBuf::from(appdata).join("agentdock");
|
||||
}
|
||||
PathBuf::from(".").join("agentdock")
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
|
||||
return PathBuf::from(xdg).join("agentdock");
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return PathBuf::from(home).join(".local/share").join("agentdock");
|
||||
}
|
||||
PathBuf::from(".").join("agentdock")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::migrations::current_version;
|
||||
use crate::schema::SCHEMA_VERSION;
|
||||
|
||||
#[test]
|
||||
fn in_memory_migration_applies_and_is_idempotent() {
|
||||
let conn = Connection::open_in_memory().expect("内存库应可打开");
|
||||
migrations::migrate(&conn).expect("首次迁移应成功");
|
||||
assert_eq!(current_version(&conn).unwrap(), SCHEMA_VERSION);
|
||||
|
||||
// 幂等:重复迁移不报错、版本不变
|
||||
migrations::migrate(&conn).expect("重复迁移应成功");
|
||||
assert_eq!(current_version(&conn).unwrap(), SCHEMA_VERSION);
|
||||
|
||||
// 关键表存在
|
||||
let tables: Vec<String> = conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('schema_version','installed_clis','diagnostic_history')")
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(tables.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_creates_empty_db_file() {
|
||||
let dir = std::env::temp_dir().join(format!("agentdock-test-{}", std::process::id()));
|
||||
let db = dir.join("agentdock.db");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
let store = Store::open(&db).expect("应能创建库文件");
|
||||
assert!(db.exists(), "SQLite 空库文件应已创建");
|
||||
assert_eq!(current_version(store.connection()).unwrap(), SCHEMA_VERSION);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user