Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
/// Return `true` when the asset reference is already a remote URL.
|
||||
pub(crate) fn is_remote_asset_url(value: &str) -> bool {
|
||||
value.starts_with("http://") || value.starts_with("https://")
|
||||
}
|
||||
|
||||
/// Normalize a user-supplied relative asset path and reject traversal.
|
||||
pub(crate) fn normalize_relative_asset_path(path: &str) -> Option<PathBuf> {
|
||||
if path.contains('\\') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut normalized = PathBuf::new();
|
||||
|
||||
for component in Path::new(path).components() {
|
||||
match component {
|
||||
Component::Normal(part) => normalized.push(part),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
|
||||
}
|
||||
}
|
||||
|
||||
if normalized.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(normalized)
|
||||
}
|
||||
|
||||
/// Convert a normalized relative path into a URL path with forward slashes.
|
||||
pub(crate) fn normalized_asset_url_path(path: &str) -> Option<String> {
|
||||
let normalized = normalize_relative_asset_path(path)?;
|
||||
Some(
|
||||
normalized
|
||||
.components()
|
||||
.filter_map(|component| match component {
|
||||
Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("/"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve an extension-scoped asset reference into a backend-served URL.
|
||||
pub(crate) fn resolve_extension_asset_url(extension_name: &str, raw: &str) -> Option<String> {
|
||||
if is_remote_asset_url(raw) {
|
||||
return Some(raw.to_owned());
|
||||
}
|
||||
|
||||
let relative = normalized_asset_url_path(raw)?;
|
||||
Some(format!("/api/extensions/{extension_name}/assets/{relative}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remote_asset_url_detects_http_and_https() {
|
||||
assert!(is_remote_asset_url("http://example.com/icon.png"));
|
||||
assert!(is_remote_asset_url("https://example.com/icon.png"));
|
||||
assert!(!is_remote_asset_url("/local/icon.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_relative_asset_path_rejects_traversal_and_absolute_paths() {
|
||||
assert!(normalize_relative_asset_path("../secret.txt").is_none());
|
||||
assert!(normalize_relative_asset_path("/etc/passwd").is_none());
|
||||
assert!(normalize_relative_asset_path("C:\\Windows\\System32").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_relative_asset_path_preserves_nested_relative_paths() {
|
||||
let path = normalize_relative_asset_path("./settings/ui/index.html").unwrap();
|
||||
assert_eq!(path, PathBuf::from("settings/ui/index.html"));
|
||||
assert_eq!(
|
||||
normalized_asset_url_path("./settings/ui/index.html").as_deref(),
|
||||
Some("settings/ui/index.html")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Assistant source classification + rule/skill dispatch traits used by
|
||||
//! `skill_routes` to route rule-md / skill-md reads/writes to the correct
|
||||
//! source (built-in file, extension resolution, or user-writable directory).
|
||||
//!
|
||||
//! These traits live in `nomifun-extension` (not `nomifun-assistant`) so
|
||||
//! `skill_routes` can depend on them without pulling `nomifun-assistant` into
|
||||
//! the dependency graph; the concrete implementation ships from
|
||||
//! `nomifun-assistant::AssistantService`.
|
||||
|
||||
use nomifun_api_types::AssistantSource;
|
||||
use nomifun_common::AppError;
|
||||
|
||||
/// Classify an assistant id into its source (builtin / extension / user).
|
||||
#[async_trait::async_trait]
|
||||
pub trait AssistantClassifier: Send + Sync {
|
||||
/// Return the source of the assistant. Callers treat `User` as "not
|
||||
/// known to builtins or extensions"; confirming existence in the user
|
||||
/// table is the repository's job.
|
||||
async fn classify(&self, id: &str) -> AssistantSource;
|
||||
}
|
||||
|
||||
/// Source-dispatched read/write access for assistant rule/skill md files.
|
||||
///
|
||||
/// Implemented by `nomifun_assistant::AssistantService`; depended on by
|
||||
/// `skill_routes` so the existing `/api/skills/assistant-rule/*` and
|
||||
/// `/api/skills/assistant-skill/*` endpoints dispatch per source.
|
||||
#[async_trait::async_trait]
|
||||
pub trait AssistantRuleDispatcher: Send + Sync {
|
||||
async fn read_rule(&self, id: &str, locale: Option<&str>) -> Result<String, AppError>;
|
||||
async fn write_rule(&self, id: &str, locale: Option<&str>, content: &str) -> Result<(), AppError>;
|
||||
async fn delete_rule(&self, id: &str) -> Result<bool, AppError>;
|
||||
|
||||
async fn read_skill(&self, id: &str, locale: Option<&str>) -> Result<String, AppError>;
|
||||
async fn write_skill(&self, id: &str, locale: Option<&str>, content: &str) -> Result<(), AppError>;
|
||||
async fn delete_skill(&self, id: &str) -> Result<bool, AppError>;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/// Manifest filename that identifies an extension directory.
|
||||
pub const EXTENSION_MANIFEST_FILE: &str = "nomi-extension.json";
|
||||
|
||||
/// Default subdirectory name for extensions.
|
||||
pub const EXTENSIONS_DIR_NAME: &str = "extensions";
|
||||
|
||||
/// Current extension API version.
|
||||
pub const EXTENSION_API_VERSION: &str = "1.0.0";
|
||||
|
||||
/// Hub index schema version we support.
|
||||
pub const HUB_SUPPORTED_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// Cache TTL for agent activity snapshots (milliseconds).
|
||||
pub const ACTIVITY_SNAPSHOT_TTL_MS: u64 = 3000;
|
||||
|
||||
/// Debounce delay for hot-reload file watching (milliseconds).
|
||||
pub const DEBOUNCE_MS: u64 = 1000;
|
||||
|
||||
/// Debounce delay for state persistence writes (milliseconds).
|
||||
pub const STATE_PERSIST_DEBOUNCE_MS: u64 = 500;
|
||||
|
||||
/// Reserved extension name prefixes that third-party extensions cannot use.
|
||||
pub const RESERVED_NAME_PREFIXES: &[&str] = &["nomi-", "internal-", "builtin-", "system-"];
|
||||
|
||||
/// Preset agent type identifiers.
|
||||
pub const PRESET_AGENT_TYPES: &[&str] = &["gemini", "claude", "codex", "codebuddy", "opencode"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle hook timeouts (seconds)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Timeout for `onInstall` hook — may involve downloading dependencies.
|
||||
pub const LIFECYCLE_ON_INSTALL_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
/// Timeout for `onUninstall` hook — cleanup operations.
|
||||
pub const LIFECYCLE_ON_UNINSTALL_TIMEOUT_SECS: u64 = 60;
|
||||
|
||||
/// Timeout for `onActivate` hook — runs every activation.
|
||||
pub const LIFECYCLE_ON_ACTIVATE_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// Timeout for `onDeactivate` hook — runs every deactivation.
|
||||
pub const LIFECYCLE_ON_DEACTIVATE_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reserved WebUI route prefixes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Route prefixes reserved for internal use — extensions cannot register these.
|
||||
pub const RESERVED_ROUTE_PREFIXES: &[&str] = &["/api/", "/auth/", "/ws/"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill & rule management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Default subdirectory name for user-created skills.
|
||||
pub const SKILLS_DIR_NAME: &str = "skills";
|
||||
|
||||
/// Default subdirectory name for per-job cron skills under the data dir.
|
||||
pub const CRON_SKILLS_DIR_NAME: &str = "cron/skills";
|
||||
|
||||
/// Default subdirectory name for built-in skills.
|
||||
pub const BUILTIN_SKILLS_DIR_NAME: &str = "builtin-skills";
|
||||
|
||||
/// Default subdirectory name for built-in rules.
|
||||
pub const BUILTIN_RULES_DIR_NAME: &str = "builtin-rules";
|
||||
|
||||
/// Subdirectory inside the built-in skills corpus whose children are
|
||||
/// auto-injected into every assistant. Historical name was `_builtin`;
|
||||
/// renamed to `auto-inject` as part of the 2026-04-23 built-in skill
|
||||
/// migration (skills are now embedded in the backend binary via
|
||||
/// `include_dir!`).
|
||||
pub const BUILTIN_AUTO_SKILLS_SUBDIR: &str = "auto-inject";
|
||||
|
||||
/// Default subdirectory name for assistant-level rules.
|
||||
pub const ASSISTANT_RULES_DIR_NAME: &str = "assistant-rules";
|
||||
|
||||
/// Default subdirectory name for assistant-level skills.
|
||||
pub const ASSISTANT_SKILLS_DIR_NAME: &str = "assistant-skills";
|
||||
|
||||
/// Filename that identifies a skill directory.
|
||||
pub const SKILL_MANIFEST_FILE: &str = "SKILL.md";
|
||||
|
||||
/// Persistence file for custom external skill paths.
|
||||
pub const CUSTOM_SKILL_PATHS_FILE: &str = "custom-skill-paths.json";
|
||||
|
||||
/// Well-known skill source name for the nomifun skills market.
|
||||
pub const SKILLS_MARKET_NAME: &str = "nomifun-skills";
|
||||
|
||||
/// Well-known skill source path for the nomifun skills market.
|
||||
///
|
||||
/// NOTE: This is a URL placeholder, not a filesystem path. When used in
|
||||
/// `ExternalPathsManager`, it serves as an identifier for the skills market
|
||||
/// source. Filesystem scanning functions like `detect_and_count_external_skills`
|
||||
/// will silently skip it since the path does not exist on disk.
|
||||
pub const SKILLS_MARKET_PATH: &str = "https://github.com/nomifun/nomifun-skills";
|
||||
|
||||
/// Common skill directory names to detect on the filesystem.
|
||||
///
|
||||
/// Each tuple is `(display_name, relative_path, source_slug)`:
|
||||
/// - `display_name` — user-facing label (e.g. the tab title).
|
||||
/// - `relative_path` — path under the user's home directory.
|
||||
/// - `source_slug` — stable machine-readable identifier mirrored to
|
||||
/// the renderer as `ExternalSkillSourceResponse.source`. Used as a
|
||||
/// React key and `data-testid` suffix in `SkillsHubSettings.tsx`.
|
||||
pub const COMMON_SKILL_DIRS: &[(&str, &str, &str)] = &[
|
||||
("Claude Skills", ".claude/skills", "claude"),
|
||||
("Gemini Skills", ".gemini/skills", "gemini"),
|
||||
("Codex / Agent Skills", ".agents/skills", "agents"),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_manifest_file_name() {
|
||||
assert_eq!(EXTENSION_MANIFEST_FILE, "nomi-extension.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reserved_prefixes_contains_expected() {
|
||||
assert!(RESERVED_NAME_PREFIXES.contains(&"nomi-"));
|
||||
assert!(RESERVED_NAME_PREFIXES.contains(&"internal-"));
|
||||
assert!(RESERVED_NAME_PREFIXES.contains(&"builtin-"));
|
||||
assert!(RESERVED_NAME_PREFIXES.contains(&"system-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preset_agent_types_non_empty() {
|
||||
assert!(!PRESET_AGENT_TYPES.is_empty());
|
||||
assert!(PRESET_AGENT_TYPES.contains(&"claude"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lifecycle_timeouts_ordering() {
|
||||
// onInstall should have the longest timeout
|
||||
const {
|
||||
assert!(LIFECYCLE_ON_INSTALL_TIMEOUT_SECS >= LIFECYCLE_ON_ACTIVATE_TIMEOUT_SECS);
|
||||
assert!(LIFECYCLE_ON_INSTALL_TIMEOUT_SECS >= LIFECYCLE_ON_DEACTIVATE_TIMEOUT_SECS);
|
||||
assert!(LIFECYCLE_ON_UNINSTALL_TIMEOUT_SECS >= LIFECYCLE_ON_DEACTIVATE_TIMEOUT_SECS);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reserved_route_prefixes() {
|
||||
assert!(RESERVED_ROUTE_PREFIXES.contains(&"/api/"));
|
||||
assert!(RESERVED_ROUTE_PREFIXES.contains(&"/auth/"));
|
||||
assert!(RESERVED_ROUTE_PREFIXES.contains(&"/ws/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debounce_values_positive() {
|
||||
const {
|
||||
assert!(DEBOUNCE_MS > 0);
|
||||
assert!(STATE_PERSIST_DEBOUNCE_MS > 0);
|
||||
assert!(ACTIVITY_SNAPSHOT_TTL_MS > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_skill_dirs_include_codex_agent_skills_home() {
|
||||
let codex = COMMON_SKILL_DIRS
|
||||
.iter()
|
||||
.find(|(_, _, slug)| *slug == "agents")
|
||||
.expect("common Agent Skills source must exist");
|
||||
|
||||
assert_eq!(
|
||||
*codex,
|
||||
("Codex / Agent Skills", ".agents/skills", "agents"),
|
||||
"Codex reads user skills from ~/.agents/skills, not the broader ~/.agents folder"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::LoadedExtension;
|
||||
#[cfg(test)]
|
||||
use crate::types::{ExtensionManifest, ExtensionSource, ExtensionState};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single dependency issue found during validation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum DependencyIssue {
|
||||
/// A required dependency is not installed.
|
||||
Missing {
|
||||
#[serde(rename = "ext")]
|
||||
extension: String,
|
||||
#[serde(rename = "dep")]
|
||||
dependency: String,
|
||||
required: String,
|
||||
},
|
||||
/// A dependency exists but its version does not satisfy the requirement.
|
||||
VersionMismatch {
|
||||
#[serde(rename = "ext")]
|
||||
extension: String,
|
||||
#[serde(rename = "dep")]
|
||||
dependency: String,
|
||||
required: String,
|
||||
actual: String,
|
||||
},
|
||||
/// A cycle was detected in the dependency graph.
|
||||
Circular { cycle: Vec<String> },
|
||||
}
|
||||
|
||||
/// Outcome of dependency validation across a set of extensions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DependencyValidationResult {
|
||||
/// `true` when no issues were found.
|
||||
pub valid: bool,
|
||||
/// All detected issues (missing, version mismatch, circular).
|
||||
pub issues: Vec<DependencyIssue>,
|
||||
/// Topological load order — dependencies before dependents.
|
||||
/// Cyclic extensions are appended at the end in alphabetical order.
|
||||
pub load_order: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Validate all inter-extension dependencies: missing, version mismatch,
|
||||
/// circular. Returns a [`DependencyValidationResult`] containing the issues
|
||||
/// found and a topological load order.
|
||||
pub fn validate_dependencies(extensions: &[LoadedExtension]) -> DependencyValidationResult {
|
||||
let versions: HashMap<&str, &str> = extensions
|
||||
.iter()
|
||||
.map(|ext| (ext.manifest.name.as_str(), ext.manifest.version.as_str()))
|
||||
.collect();
|
||||
|
||||
let mut issues = Vec::new();
|
||||
|
||||
// 1. Check missing and version mismatches.
|
||||
for ext in extensions {
|
||||
for (dep_name, dep_req) in &ext.manifest.dependencies {
|
||||
match versions.get(dep_name.as_str()) {
|
||||
None => {
|
||||
issues.push(DependencyIssue::Missing {
|
||||
extension: ext.manifest.name.clone(),
|
||||
dependency: dep_name.clone(),
|
||||
required: dep_req.clone(),
|
||||
});
|
||||
}
|
||||
Some(actual_version) => {
|
||||
if !version_matches(dep_req, actual_version) {
|
||||
issues.push(DependencyIssue::VersionMismatch {
|
||||
extension: ext.manifest.name.clone(),
|
||||
dependency: dep_name.clone(),
|
||||
required: dep_req.clone(),
|
||||
actual: actual_version.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Topological sort + cycle detection.
|
||||
let (load_order, cycles) = compute_topological_order(extensions);
|
||||
for cycle in cycles {
|
||||
issues.push(DependencyIssue::Circular { cycle });
|
||||
}
|
||||
|
||||
DependencyValidationResult {
|
||||
valid: issues.is_empty(),
|
||||
issues,
|
||||
load_order,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version matching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check whether `actual` satisfies `requirement`.
|
||||
///
|
||||
/// - Bare version (`"1.2.3"`) → **exact** match.
|
||||
/// - Caret (`"^1.2.3"`) → `>=1.2.3, <2.0.0`.
|
||||
/// - Tilde (`"~1.2.3"`) → `>=1.2.3, <1.3.0`.
|
||||
fn version_matches(requirement: &str, actual: &str) -> bool {
|
||||
let Ok(version) = semver::Version::parse(actual) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let req_str = if requirement.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
// Bare version → exact match via '=' operator.
|
||||
format!("={requirement}")
|
||||
} else {
|
||||
requirement.to_string()
|
||||
};
|
||||
|
||||
let Ok(req) = semver::VersionReq::parse(&req_str) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
req.matches(&version)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topological sort internals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Kahn's algorithm with cycle detection.
|
||||
///
|
||||
/// Returns `(load_order, detected_cycles)`. Cyclic nodes are appended to
|
||||
/// `load_order` in alphabetical order so that the caller can still attempt
|
||||
/// loading them (API Spec requirement).
|
||||
fn compute_topological_order(extensions: &[LoadedExtension]) -> (Vec<String>, Vec<Vec<String>>) {
|
||||
let known: HashSet<&str> = extensions.iter().map(|e| e.manifest.name.as_str()).collect();
|
||||
|
||||
// adjacency: dependency → vec of dependents
|
||||
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||
let mut in_degree: HashMap<&str, usize> = HashMap::new();
|
||||
|
||||
for ext in extensions {
|
||||
in_degree.entry(ext.manifest.name.as_str()).or_insert(0);
|
||||
for dep_name in ext.manifest.dependencies.keys() {
|
||||
if known.contains(dep_name.as_str()) {
|
||||
adj.entry(dep_name.as_str())
|
||||
.or_default()
|
||||
.push(ext.manifest.name.as_str());
|
||||
*in_degree.entry(ext.manifest.name.as_str()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed the queue with zero-in-degree nodes (sorted for determinism).
|
||||
let mut queue: VecDeque<&str> = {
|
||||
let mut seeds: Vec<&str> = in_degree
|
||||
.iter()
|
||||
.filter(|(_, deg)| **deg == 0)
|
||||
.map(|(&name, _)| name)
|
||||
.collect();
|
||||
seeds.sort_unstable();
|
||||
seeds.into_iter().collect()
|
||||
};
|
||||
|
||||
let mut order: Vec<String> = Vec::with_capacity(extensions.len());
|
||||
|
||||
while let Some(node) = queue.pop_front() {
|
||||
order.push(node.to_string());
|
||||
if let Some(dependents) = adj.get(node) {
|
||||
let mut ready = Vec::new();
|
||||
for &dep in dependents {
|
||||
let deg = in_degree.get_mut(dep).expect("in_degree entry");
|
||||
*deg -= 1;
|
||||
if *deg == 0 {
|
||||
ready.push(dep);
|
||||
}
|
||||
}
|
||||
ready.sort_unstable();
|
||||
for n in ready {
|
||||
queue.push_back(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nodes not emitted are part of cycles.
|
||||
let emitted: HashSet<&str> = order.iter().map(|s| s.as_str()).collect();
|
||||
let remaining: HashSet<&str> = known.difference(&emitted).copied().collect();
|
||||
|
||||
let cycles = if remaining.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
find_cycles(extensions, &remaining)
|
||||
};
|
||||
|
||||
// Append cyclic nodes alphabetically (best-effort load).
|
||||
let mut remaining_sorted: Vec<String> = remaining.iter().map(|s| s.to_string()).collect();
|
||||
remaining_sorted.sort_unstable();
|
||||
order.extend(remaining_sorted);
|
||||
|
||||
(order, cycles)
|
||||
}
|
||||
|
||||
/// Find distinct cycles in the subgraph induced by `involved` nodes.
|
||||
fn find_cycles(extensions: &[LoadedExtension], involved: &HashSet<&str>) -> Vec<Vec<String>> {
|
||||
// Build adjacency: node → its dependencies (restricted to involved set).
|
||||
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||
for ext in extensions {
|
||||
let name = ext.manifest.name.as_str();
|
||||
if involved.contains(name) {
|
||||
let mut deps: Vec<&str> = ext
|
||||
.manifest
|
||||
.dependencies
|
||||
.keys()
|
||||
.map(|s| s.as_str())
|
||||
.filter(|s| involved.contains(s))
|
||||
.collect();
|
||||
deps.sort_unstable();
|
||||
adj.insert(name, deps);
|
||||
}
|
||||
}
|
||||
|
||||
let mut visited: HashSet<&str> = HashSet::new();
|
||||
let mut on_stack: HashSet<&str> = HashSet::new();
|
||||
let mut path: Vec<&str> = Vec::new();
|
||||
let mut cycles: Vec<Vec<String>> = Vec::new();
|
||||
|
||||
let mut sorted_nodes: Vec<&str> = involved.iter().copied().collect();
|
||||
sorted_nodes.sort_unstable();
|
||||
|
||||
for node in sorted_nodes {
|
||||
if !visited.contains(node) {
|
||||
dfs_find_cycles(node, &adj, &mut visited, &mut on_stack, &mut path, &mut cycles);
|
||||
}
|
||||
}
|
||||
|
||||
cycles
|
||||
}
|
||||
|
||||
fn dfs_find_cycles<'a>(
|
||||
node: &'a str,
|
||||
adj: &HashMap<&'a str, Vec<&'a str>>,
|
||||
visited: &mut HashSet<&'a str>,
|
||||
on_stack: &mut HashSet<&'a str>,
|
||||
path: &mut Vec<&'a str>,
|
||||
cycles: &mut Vec<Vec<String>>,
|
||||
) {
|
||||
visited.insert(node);
|
||||
on_stack.insert(node);
|
||||
path.push(node);
|
||||
|
||||
if let Some(neighbors) = adj.get(node) {
|
||||
for &next in neighbors {
|
||||
if !visited.contains(next) {
|
||||
dfs_find_cycles(next, adj, visited, on_stack, path, cycles);
|
||||
} else if on_stack.contains(next) {
|
||||
// Extract cycle from path.
|
||||
if let Some(start) = path.iter().position(|&n| n == next) {
|
||||
let mut cycle: Vec<String> = path[start..].iter().map(|s| s.to_string()).collect();
|
||||
cycle.push(next.to_string()); // close the cycle
|
||||
cycles.push(cycle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path.pop();
|
||||
on_stack.remove(node);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a minimal [`LoadedExtension`] for testing purposes.
|
||||
#[cfg(test)]
|
||||
fn make_extension(name: &str, version: &str, deps: &[(&str, &str)]) -> LoadedExtension {
|
||||
use std::collections::HashMap;
|
||||
|
||||
LoadedExtension {
|
||||
manifest: ExtensionManifest {
|
||||
name: name.to_string(),
|
||||
version: version.to_string(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
license: None,
|
||||
homepage: None,
|
||||
icon: None,
|
||||
engine: None,
|
||||
api_version: None,
|
||||
dependencies: deps
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect::<HashMap<_, _>>(),
|
||||
entry_point: None,
|
||||
permissions: None,
|
||||
contributes: None,
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
},
|
||||
directory: format!("/extensions/{name}"),
|
||||
source: ExtensionSource::Local,
|
||||
state: ExtensionState {
|
||||
name: name.to_string(),
|
||||
version: version.to_string(),
|
||||
enabled: true,
|
||||
installed_at: None,
|
||||
last_activated_at: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- version_matches ---------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn exact_match_same_version() {
|
||||
assert!(version_matches("1.2.3", "1.2.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_match_different_version() {
|
||||
assert!(!version_matches("2.0.0", "1.5.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_match_rejects_higher_patch() {
|
||||
assert!(!version_matches("1.2.3", "1.2.4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_match_within_range() {
|
||||
// ^1.2.3 allows >=1.2.3, <2.0.0
|
||||
assert!(version_matches("^1.2.3", "1.9.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_match_at_lower_bound() {
|
||||
assert!(version_matches("^1.2.3", "1.2.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_match_rejects_next_major() {
|
||||
assert!(!version_matches("^1.2.3", "2.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_match_rejects_below_lower() {
|
||||
assert!(!version_matches("^1.2.3", "1.2.2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_match_within_range() {
|
||||
// ~1.2.3 allows >=1.2.3, <1.3.0
|
||||
assert!(version_matches("~1.2.3", "1.2.9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_match_at_lower_bound() {
|
||||
assert!(version_matches("~1.2.3", "1.2.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_match_rejects_next_minor() {
|
||||
assert!(!version_matches("~1.2.3", "1.3.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_match_rejects_below_lower() {
|
||||
assert!(!version_matches("~1.2.3", "1.2.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_requirement_returns_false() {
|
||||
assert!(!version_matches("not-a-version", "1.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_actual_returns_false() {
|
||||
assert!(!version_matches("^1.0.0", "not-semver"));
|
||||
}
|
||||
|
||||
// -- topological_sort --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn sort_no_deps() {
|
||||
let exts = vec![
|
||||
make_extension("alpha", "1.0.0", &[]),
|
||||
make_extension("beta", "1.0.0", &[]),
|
||||
make_extension("gamma", "1.0.0", &[]),
|
||||
];
|
||||
let order = compute_topological_order(&exts).0;
|
||||
assert_eq!(order.len(), 3);
|
||||
// Alphabetical when no constraints.
|
||||
assert_eq!(order, vec!["alpha", "beta", "gamma"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_linear_chain() {
|
||||
// gamma → beta → alpha
|
||||
let exts = vec![
|
||||
make_extension("alpha", "1.0.0", &[]),
|
||||
make_extension("beta", "1.0.0", &[("alpha", "^1.0.0")]),
|
||||
make_extension("gamma", "1.0.0", &[("beta", "^1.0.0")]),
|
||||
];
|
||||
let order = compute_topological_order(&exts).0;
|
||||
assert_eq!(order, vec!["alpha", "beta", "gamma"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_diamond() {
|
||||
// d → b, d → c, b → a, c → a
|
||||
let exts = vec![
|
||||
make_extension("a", "1.0.0", &[]),
|
||||
make_extension("b", "1.0.0", &[("a", "^1.0.0")]),
|
||||
make_extension("c", "1.0.0", &[("a", "^1.0.0")]),
|
||||
make_extension("d", "1.0.0", &[("b", "^1.0.0"), ("c", "^1.0.0")]),
|
||||
];
|
||||
let order = compute_topological_order(&exts).0;
|
||||
// a must come before b and c; b and c before d.
|
||||
let pos = |name: &str| order.iter().position(|n| n == name).unwrap();
|
||||
assert!(pos("a") < pos("b"));
|
||||
assert!(pos("a") < pos("c"));
|
||||
assert!(pos("b") < pos("d"));
|
||||
assert!(pos("c") < pos("d"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_with_cycle_still_includes_all() {
|
||||
// a → b → c → a (cycle)
|
||||
let exts = vec![
|
||||
make_extension("a", "1.0.0", &[("c", "^1.0.0")]),
|
||||
make_extension("b", "1.0.0", &[("a", "^1.0.0")]),
|
||||
make_extension("c", "1.0.0", &[("b", "^1.0.0")]),
|
||||
];
|
||||
let order = compute_topological_order(&exts).0;
|
||||
assert_eq!(order.len(), 3);
|
||||
// All three should be present.
|
||||
assert!(order.contains(&"a".to_string()));
|
||||
assert!(order.contains(&"b".to_string()));
|
||||
assert!(order.contains(&"c".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_ignores_unknown_deps() {
|
||||
// ext-a depends on ext-missing (not in list).
|
||||
let exts = vec![make_extension("ext-a", "1.0.0", &[("ext-missing", "^1.0.0")])];
|
||||
let order = compute_topological_order(&exts).0;
|
||||
assert_eq!(order, vec!["ext-a"]);
|
||||
}
|
||||
|
||||
// -- validate_dependencies ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn valid_satisfied_deps() {
|
||||
let exts = vec![
|
||||
make_extension("ext-b", "1.2.0", &[]),
|
||||
make_extension("ext-a", "1.0.0", &[("ext-b", "^1.0.0")]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(result.valid);
|
||||
assert!(result.issues.is_empty());
|
||||
assert_eq!(result.load_order, vec!["ext-b", "ext-a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_missing_dependency() {
|
||||
let exts = vec![make_extension("ext-a", "1.0.0", &[("ext-missing", "^1.0.0")])];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(!result.valid);
|
||||
assert_eq!(result.issues.len(), 1);
|
||||
assert!(matches!(
|
||||
&result.issues[0],
|
||||
DependencyIssue::Missing {
|
||||
extension,
|
||||
dependency,
|
||||
..
|
||||
} if extension == "ext-a" && dependency == "ext-missing"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_version_mismatch_exact() {
|
||||
let exts = vec![
|
||||
make_extension("ext-b", "1.5.0", &[]),
|
||||
make_extension("ext-a", "1.0.0", &[("ext-b", "2.0.0")]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(!result.valid);
|
||||
assert!(result.issues.iter().any(|i| matches!(
|
||||
i,
|
||||
DependencyIssue::VersionMismatch {
|
||||
required,
|
||||
actual,
|
||||
..
|
||||
} if required == "2.0.0" && actual == "1.5.0"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_circular_dependency() {
|
||||
let exts = vec![
|
||||
make_extension("ext-a", "1.0.0", &[("ext-c", "^1.0.0")]),
|
||||
make_extension("ext-b", "1.0.0", &[("ext-a", "^1.0.0")]),
|
||||
make_extension("ext-c", "1.0.0", &[("ext-b", "^1.0.0")]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(!result.valid);
|
||||
let circular = result
|
||||
.issues
|
||||
.iter()
|
||||
.filter(|i| matches!(i, DependencyIssue::Circular { .. }))
|
||||
.count();
|
||||
assert!(circular >= 1);
|
||||
// All nodes still in load_order.
|
||||
assert_eq!(result.load_order.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_cycle_path_closes() {
|
||||
let exts = vec![
|
||||
make_extension("a", "1.0.0", &[("c", "^1.0.0")]),
|
||||
make_extension("b", "1.0.0", &[("a", "^1.0.0")]),
|
||||
make_extension("c", "1.0.0", &[("b", "^1.0.0")]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
let cycles: Vec<&Vec<String>> = result
|
||||
.issues
|
||||
.iter()
|
||||
.filter_map(|i| match i {
|
||||
DependencyIssue::Circular { cycle } => Some(cycle),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!cycles.is_empty());
|
||||
// Cycle path must close (first == last).
|
||||
for cycle in &cycles {
|
||||
assert!(cycle.len() >= 3);
|
||||
assert_eq!(cycle.first(), cycle.last());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_deps_all_valid() {
|
||||
let exts = vec![make_extension("x", "1.0.0", &[]), make_extension("y", "2.0.0", &[])];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(result.valid);
|
||||
assert!(result.issues.is_empty());
|
||||
assert_eq!(result.load_order.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_issues() {
|
||||
// ext-a → ext-missing (missing), ext-a → ext-b bad version, ext-c → ext-d → ext-c (cycle)
|
||||
let exts = vec![
|
||||
make_extension("ext-a", "1.0.0", &[("ext-missing", "^1.0.0"), ("ext-b", "^2.0.0")]),
|
||||
make_extension("ext-b", "1.0.0", &[]),
|
||||
make_extension("ext-c", "1.0.0", &[("ext-d", "^1.0.0")]),
|
||||
make_extension("ext-d", "1.0.0", &[("ext-c", "^1.0.0")]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(!result.valid);
|
||||
|
||||
let missing_count = result
|
||||
.issues
|
||||
.iter()
|
||||
.filter(|i| matches!(i, DependencyIssue::Missing { .. }))
|
||||
.count();
|
||||
let mismatch_count = result
|
||||
.issues
|
||||
.iter()
|
||||
.filter(|i| matches!(i, DependencyIssue::VersionMismatch { .. }))
|
||||
.count();
|
||||
let circular_count = result
|
||||
.issues
|
||||
.iter()
|
||||
.filter(|i| matches!(i, DependencyIssue::Circular { .. }))
|
||||
.count();
|
||||
|
||||
assert_eq!(missing_count, 1);
|
||||
assert_eq!(mismatch_count, 1);
|
||||
assert!(circular_count >= 1);
|
||||
// All 4 extensions still in load order.
|
||||
assert_eq!(result.load_order.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_extensions_list() {
|
||||
let result = validate_dependencies(&[]);
|
||||
assert!(result.valid);
|
||||
assert!(result.issues.is_empty());
|
||||
assert!(result.load_order.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_match_success_via_validate() {
|
||||
let exts = vec![
|
||||
make_extension("base", "1.9.0", &[]),
|
||||
make_extension("consumer", "1.0.0", &[("base", "^1.2.3")]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(result.valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_match_failure_via_validate() {
|
||||
let exts = vec![
|
||||
make_extension("base", "2.0.0", &[]),
|
||||
make_extension("consumer", "1.0.0", &[("base", "^1.2.3")]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(!result.valid);
|
||||
assert!(
|
||||
result
|
||||
.issues
|
||||
.iter()
|
||||
.any(|i| matches!(i, DependencyIssue::VersionMismatch { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_match_success_via_validate() {
|
||||
let exts = vec![
|
||||
make_extension("base", "1.2.9", &[]),
|
||||
make_extension("consumer", "1.0.0", &[("base", "~1.2.3")]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(result.valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_match_failure_via_validate() {
|
||||
let exts = vec![
|
||||
make_extension("base", "1.3.0", &[]),
|
||||
make_extension("consumer", "1.0.0", &[("base", "~1.2.3")]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(!result.valid);
|
||||
assert!(
|
||||
result
|
||||
.issues
|
||||
.iter()
|
||||
.any(|i| matches!(i, DependencyIssue::VersionMismatch { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_cycle_does_not_block_acyclic_nodes() {
|
||||
// a and b form a cycle; c has no deps.
|
||||
let exts = vec![
|
||||
make_extension("a", "1.0.0", &[("b", "^1.0.0")]),
|
||||
make_extension("b", "1.0.0", &[("a", "^1.0.0")]),
|
||||
make_extension("c", "1.0.0", &[]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
// c should appear before the cyclic nodes in load_order.
|
||||
let pos_c = result.load_order.iter().position(|n| n == "c").unwrap();
|
||||
let pos_a = result.load_order.iter().position(|n| n == "a").unwrap();
|
||||
let pos_b = result.load_order.iter().position(|n| n == "b").unwrap();
|
||||
assert!(pos_c < pos_a);
|
||||
assert!(pos_c < pos_b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use nomifun_common::AppError;
|
||||
|
||||
/// Extension system domain errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ExtensionError {
|
||||
#[error("Manifest validation failed: {0}")]
|
||||
ManifestValidation(String),
|
||||
|
||||
#[error("Extension name '{name}' uses reserved prefix '{prefix}'")]
|
||||
ReservedNamePrefix { name: String, prefix: String },
|
||||
|
||||
#[error("Invalid version '{version}': {reason}")]
|
||||
InvalidVersion { version: String, reason: String },
|
||||
|
||||
#[error("Undefined environment variable: {0}")]
|
||||
UndefinedEnvVariable(String),
|
||||
|
||||
#[error("File reference not found: {0}")]
|
||||
FileReferenceNotFound(String),
|
||||
|
||||
#[error("Path traversal detected: {0}")]
|
||||
PathTraversal(String),
|
||||
|
||||
#[error("Engine incompatible: extension '{name}' requires nomifun {required}, got {actual}")]
|
||||
EngineIncompatible {
|
||||
name: String,
|
||||
required: String,
|
||||
actual: String,
|
||||
},
|
||||
|
||||
#[error("API version incompatible: extension '{name}' requires API {required}, supported {supported}")]
|
||||
ApiVersionIncompatible {
|
||||
name: String,
|
||||
required: String,
|
||||
supported: String,
|
||||
},
|
||||
|
||||
#[error("WebUI route '{route}' must be under '/{extension_name}/' namespace")]
|
||||
InvalidWebuiRouteNamespace { extension_name: String, route: String },
|
||||
|
||||
#[error("WebUI route '{route}' uses reserved prefix '{prefix}'")]
|
||||
ReservedWebuiRoute { route: String, prefix: String },
|
||||
|
||||
#[error("Theme CSS file not found: {0}")]
|
||||
ThemeCssNotFound(String),
|
||||
|
||||
#[error("Contribution resolution failed for '{extension_name}': {reason}")]
|
||||
ResolutionFailed { extension_name: String, reason: String },
|
||||
|
||||
#[error("Lifecycle hook '{hook}' timed out after {timeout_secs}s for extension '{extension_name}'")]
|
||||
HookTimeout {
|
||||
extension_name: String,
|
||||
hook: String,
|
||||
timeout_secs: u64,
|
||||
},
|
||||
|
||||
#[error("Lifecycle hook '{hook}' failed for extension '{extension_name}': {reason}")]
|
||||
HookFailed {
|
||||
extension_name: String,
|
||||
hook: String,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("Lifecycle hook script not found: {0}")]
|
||||
HookNotFound(String),
|
||||
|
||||
#[error("Extension not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("State persistence failed: {0}")]
|
||||
StatePersistence(String),
|
||||
|
||||
#[error("Cannot delete built-in skill: {0}")]
|
||||
BuiltinSkillDeletion(String),
|
||||
|
||||
#[error("Skill not found: {0}")]
|
||||
SkillNotFound(String),
|
||||
|
||||
#[error("Invalid skill path: {0}")]
|
||||
InvalidSkillPath(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("{0}")]
|
||||
JsonParse(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
impl From<ExtensionError> for AppError {
|
||||
fn from(err: ExtensionError) -> Self {
|
||||
match err {
|
||||
ExtensionError::ManifestValidation(msg) => AppError::BadRequest(msg),
|
||||
ExtensionError::ReservedNamePrefix { .. } => AppError::BadRequest(err.to_string()),
|
||||
ExtensionError::InvalidVersion { .. } => AppError::BadRequest(err.to_string()),
|
||||
ExtensionError::UndefinedEnvVariable(var) => {
|
||||
AppError::BadRequest(format!("Undefined environment variable: {var}"))
|
||||
}
|
||||
ExtensionError::FileReferenceNotFound(path) => {
|
||||
AppError::NotFound(format!("File reference not found: {path}"))
|
||||
}
|
||||
ExtensionError::PathTraversal(path) => AppError::BadRequest(format!("Path traversal detected: {path}")),
|
||||
ExtensionError::EngineIncompatible { .. } => AppError::BadRequest(err.to_string()),
|
||||
ExtensionError::ApiVersionIncompatible { .. } => AppError::BadRequest(err.to_string()),
|
||||
ExtensionError::InvalidWebuiRouteNamespace { .. } => AppError::BadRequest(err.to_string()),
|
||||
ExtensionError::ReservedWebuiRoute { .. } => AppError::BadRequest(err.to_string()),
|
||||
ExtensionError::ThemeCssNotFound(path) => AppError::NotFound(format!("Theme CSS not found: {path}")),
|
||||
ExtensionError::HookTimeout { .. } => AppError::Internal(err.to_string()),
|
||||
ExtensionError::HookFailed { .. } => AppError::Internal(err.to_string()),
|
||||
ExtensionError::HookNotFound(path) => AppError::NotFound(format!("Hook script not found: {path}")),
|
||||
ExtensionError::ResolutionFailed { .. } => AppError::Internal(err.to_string()),
|
||||
ExtensionError::NotFound(name) => AppError::NotFound(format!("Extension not found: {name}")),
|
||||
ExtensionError::StatePersistence(msg) => AppError::Internal(msg),
|
||||
ExtensionError::BuiltinSkillDeletion(name) => {
|
||||
AppError::BadRequest(format!("Cannot delete built-in skill: {name}"))
|
||||
}
|
||||
ExtensionError::SkillNotFound(name) => AppError::NotFound(format!("Skill not found: {name}")),
|
||||
ExtensionError::InvalidSkillPath(path) => AppError::BadRequest(format!("Invalid skill path: {path}")),
|
||||
ExtensionError::Io(e) => AppError::Internal(e.to_string()),
|
||||
ExtensionError::JsonParse(e) => AppError::BadRequest(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_manifest_validation_error_display() {
|
||||
let err = ExtensionError::ManifestValidation("name is required".into());
|
||||
assert_eq!(err.to_string(), "Manifest validation failed: name is required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reserved_name_prefix_error_display() {
|
||||
let err = ExtensionError::ReservedNamePrefix {
|
||||
name: "nomi-test".into(),
|
||||
prefix: "nomi-".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"Extension name 'nomi-test' uses reserved prefix 'nomi-'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_version_error_display() {
|
||||
let err = ExtensionError::InvalidVersion {
|
||||
version: "not-semver".into(),
|
||||
reason: "unexpected character".into(),
|
||||
};
|
||||
assert_eq!(err.to_string(), "Invalid version 'not-semver': unexpected character");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undefined_env_variable_error_display() {
|
||||
let err = ExtensionError::UndefinedEnvVariable("MY_SECRET".into());
|
||||
assert_eq!(err.to_string(), "Undefined environment variable: MY_SECRET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_reference_not_found_error_display() {
|
||||
let err = ExtensionError::FileReferenceNotFound("prompts/system.md".into());
|
||||
assert_eq!(err.to_string(), "File reference not found: prompts/system.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_traversal_error_display() {
|
||||
let err = ExtensionError::PathTraversal("../../etc/passwd".into());
|
||||
assert_eq!(err.to_string(), "Path traversal detected: ../../etc/passwd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_into_app_error_path_traversal() {
|
||||
let err = ExtensionError::PathTraversal("../secret".into());
|
||||
let app_err: AppError = err.into();
|
||||
assert!(matches!(app_err, AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_into_app_error_bad_request() {
|
||||
let err = ExtensionError::ManifestValidation("test".into());
|
||||
let app_err: AppError = err.into();
|
||||
assert!(matches!(app_err, AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_into_app_error_not_found() {
|
||||
let err = ExtensionError::FileReferenceNotFound("missing.md".into());
|
||||
let app_err: AppError = err.into();
|
||||
assert!(matches!(app_err, AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_error_conversion() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
|
||||
let err = ExtensionError::from(io_err);
|
||||
assert!(matches!(err, ExtensionError::Io(_)));
|
||||
let app_err: AppError = err.into();
|
||||
assert!(matches!(app_err, AppError::Internal(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::constants::{CUSTOM_SKILL_PATHS_FILE, SKILLS_MARKET_NAME, SKILLS_MARKET_PATH};
|
||||
use crate::error::ExtensionError;
|
||||
use crate::skill_service::NamedPath;
|
||||
|
||||
/// Persistent storage for custom external skill paths.
|
||||
///
|
||||
/// Data is stored in `~/.nomifun/custom-skill-paths.json`.
|
||||
pub struct ExternalPathsManager {
|
||||
file_path: PathBuf,
|
||||
paths: RwLock<Vec<PersistedNamedPath>>,
|
||||
}
|
||||
|
||||
/// Serializable named path entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
struct PersistedNamedPath {
|
||||
name: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl ExternalPathsManager {
|
||||
/// Create a new manager that persists to the given data directory.
|
||||
///
|
||||
/// Loads existing paths from disk if the file exists.
|
||||
pub async fn new(data_dir: &Path) -> Self {
|
||||
let file_path = data_dir.join(CUSTOM_SKILL_PATHS_FILE);
|
||||
let paths = load_from_file(&file_path).await;
|
||||
|
||||
Self {
|
||||
file_path,
|
||||
paths: RwLock::new(paths),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a manager with an explicit persistence file path.
|
||||
///
|
||||
/// Useful for testing.
|
||||
pub async fn with_file(file_path: PathBuf) -> Self {
|
||||
let paths = load_from_file(&file_path).await;
|
||||
|
||||
Self {
|
||||
file_path,
|
||||
paths: RwLock::new(paths),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all custom external paths.
|
||||
pub async fn get_custom_external_paths(&self) -> Vec<NamedPath> {
|
||||
let paths = self.paths.read().await;
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| NamedPath {
|
||||
name: p.name.clone(),
|
||||
path: p.path.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Add a custom external path.
|
||||
///
|
||||
/// If a path with the same value already exists, it is updated with the new name.
|
||||
pub async fn add_custom_external_path(&self, name: &str, path: &str) -> Result<(), ExtensionError> {
|
||||
let mut paths = self.paths.write().await;
|
||||
|
||||
// Update existing or add new
|
||||
if let Some(existing) = paths.iter_mut().find(|p| p.path == path) {
|
||||
existing.name = name.to_string();
|
||||
} else {
|
||||
paths.push(PersistedNamedPath {
|
||||
name: name.to_string(),
|
||||
path: path.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
save_to_file(&self.file_path, &paths).await?;
|
||||
debug!(name = %name, path = %path, "added custom external path");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a custom external path by its path value.
|
||||
pub async fn remove_custom_external_path(&self, path: &str) -> Result<(), ExtensionError> {
|
||||
let mut paths = self.paths.write().await;
|
||||
let before_len = paths.len();
|
||||
paths.retain(|p| p.path != path);
|
||||
|
||||
if paths.len() < before_len {
|
||||
save_to_file(&self.file_path, &paths).await?;
|
||||
debug!(path = %path, "removed custom external path");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable the nomifun skills market by adding it to external paths.
|
||||
pub async fn enable_skills_market(&self) -> Result<(), ExtensionError> {
|
||||
self.add_custom_external_path(SKILLS_MARKET_NAME, SKILLS_MARKET_PATH)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Disable the nomifun skills market by removing it from external paths.
|
||||
pub async fn disable_skills_market(&self) -> Result<(), ExtensionError> {
|
||||
self.remove_custom_external_path(SKILLS_MARKET_PATH).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Load paths from the persistence file.
|
||||
async fn load_from_file(file_path: &Path) -> Vec<PersistedNamedPath> {
|
||||
match tokio::fs::read_to_string(file_path).await {
|
||||
Ok(content) => match serde_json::from_str::<Vec<PersistedNamedPath>>(&content) {
|
||||
Ok(paths) => paths,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
path = %file_path.display(),
|
||||
error = %e,
|
||||
"failed to parse custom skill paths file, starting fresh"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
path = %file_path.display(),
|
||||
error = %e,
|
||||
"failed to read custom skill paths file, starting fresh"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save paths to the persistence file.
|
||||
async fn save_to_file(file_path: &Path, paths: &[PersistedNamedPath]) -> Result<(), ExtensionError> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = file_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(paths)?;
|
||||
tokio::fs::write(file_path, json).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Basic CRUD
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_manager_empty_when_no_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mgr = ExternalPathsManager::new(tmp.path()).await;
|
||||
|
||||
let paths = mgr.get_custom_external_paths().await;
|
||||
assert!(paths.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_and_get_paths() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mgr = ExternalPathsManager::new(tmp.path()).await;
|
||||
|
||||
mgr.add_custom_external_path("My Skills", "/home/user/skills")
|
||||
.await
|
||||
.unwrap();
|
||||
mgr.add_custom_external_path("Work Skills", "/work/skills")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let paths = mgr.get_custom_external_paths().await;
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert_eq!(paths[0].name, "My Skills");
|
||||
assert_eq!(paths[0].path, "/home/user/skills");
|
||||
assert_eq!(paths[1].name, "Work Skills");
|
||||
assert_eq!(paths[1].path, "/work/skills");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_duplicate_path_updates_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mgr = ExternalPathsManager::new(tmp.path()).await;
|
||||
|
||||
mgr.add_custom_external_path("Original", "/my/path").await.unwrap();
|
||||
mgr.add_custom_external_path("Updated", "/my/path").await.unwrap();
|
||||
|
||||
let paths = mgr.get_custom_external_paths().await;
|
||||
assert_eq!(paths.len(), 1);
|
||||
assert_eq!(paths[0].name, "Updated");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_existing_path() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mgr = ExternalPathsManager::new(tmp.path()).await;
|
||||
|
||||
mgr.add_custom_external_path("Skills", "/path/a").await.unwrap();
|
||||
mgr.add_custom_external_path("More", "/path/b").await.unwrap();
|
||||
|
||||
mgr.remove_custom_external_path("/path/a").await.unwrap();
|
||||
|
||||
let paths = mgr.get_custom_external_paths().await;
|
||||
assert_eq!(paths.len(), 1);
|
||||
assert_eq!(paths[0].path, "/path/b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_nonexistent_path_is_noop() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mgr = ExternalPathsManager::new(tmp.path()).await;
|
||||
|
||||
mgr.remove_custom_external_path("/nonexistent").await.unwrap();
|
||||
|
||||
let paths = mgr.get_custom_external_paths().await;
|
||||
assert!(paths.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Persistence
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn persists_and_reloads() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// First session: add paths
|
||||
{
|
||||
let mgr = ExternalPathsManager::new(tmp.path()).await;
|
||||
mgr.add_custom_external_path("A", "/path/a").await.unwrap();
|
||||
mgr.add_custom_external_path("B", "/path/b").await.unwrap();
|
||||
}
|
||||
|
||||
// Second session: paths should still be there
|
||||
{
|
||||
let mgr = ExternalPathsManager::new(tmp.path()).await;
|
||||
let paths = mgr.get_custom_external_paths().await;
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert_eq!(paths[0].name, "A");
|
||||
assert_eq!(paths[1].name, "B");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handles_corrupted_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let file_path = tmp.path().join(CUSTOM_SKILL_PATHS_FILE);
|
||||
std::fs::write(&file_path, "not valid json").unwrap();
|
||||
|
||||
let mgr = ExternalPathsManager::new(tmp.path()).await;
|
||||
let paths = mgr.get_custom_external_paths().await;
|
||||
assert!(paths.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Skills market
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_and_disable_skills_market() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mgr = ExternalPathsManager::new(tmp.path()).await;
|
||||
|
||||
mgr.enable_skills_market().await.unwrap();
|
||||
|
||||
let paths = mgr.get_custom_external_paths().await;
|
||||
assert_eq!(paths.len(), 1);
|
||||
assert_eq!(paths[0].name, SKILLS_MARKET_NAME);
|
||||
assert_eq!(paths[0].path, SKILLS_MARKET_PATH);
|
||||
|
||||
mgr.disable_skills_market().await.unwrap();
|
||||
|
||||
let paths = mgr.get_custom_external_paths().await;
|
||||
assert!(paths.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_market_idempotent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mgr = ExternalPathsManager::new(tmp.path()).await;
|
||||
|
||||
mgr.enable_skills_market().await.unwrap();
|
||||
mgr.enable_skills_market().await.unwrap();
|
||||
|
||||
let paths = mgr.get_custom_external_paths().await;
|
||||
assert_eq!(paths.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::constants::HUB_SUPPORTED_SCHEMA_VERSION;
|
||||
use crate::error::ExtensionError;
|
||||
use crate::registry::ExtensionRegistry;
|
||||
use crate::types::{HubExtensionStatus, HubExtensionWithStatus};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hub index on-disk format
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Schema envelope for a Hub index file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct HubIndexFile {
|
||||
/// Schema version — we only support [`HUB_SUPPORTED_SCHEMA_VERSION`].
|
||||
#[serde(default = "default_schema_version")]
|
||||
schema_version: u32,
|
||||
/// Extension entries in the index.
|
||||
#[serde(default)]
|
||||
extensions: Vec<HubIndexEntry>,
|
||||
}
|
||||
|
||||
fn default_schema_version() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
/// A single entry in the Hub index file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct HubIndexEntry {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
/// Whether this extension is bundled with the app (no download needed).
|
||||
#[serde(default)]
|
||||
pub bundled: bool,
|
||||
/// Optional download URL for remote extensions.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub download_url: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HubIndexManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Manages the Hub extension index — loads from local file, merges
|
||||
/// install status from the live extension registry.
|
||||
#[derive(Clone)]
|
||||
pub struct HubIndexManager {
|
||||
/// Directory that contains `index.json`.
|
||||
index_dir: PathBuf,
|
||||
/// Reference to the live extension registry for status resolution.
|
||||
registry: ExtensionRegistry,
|
||||
}
|
||||
|
||||
impl HubIndexManager {
|
||||
/// Create a new index manager.
|
||||
///
|
||||
/// - `index_dir`: directory containing the Hub `index.json`.
|
||||
/// - `registry`: live extension registry used to determine install status.
|
||||
pub fn new(index_dir: PathBuf, registry: ExtensionRegistry) -> Self {
|
||||
Self { index_dir, registry }
|
||||
}
|
||||
|
||||
/// Load the Hub index and merge install status from the registry.
|
||||
///
|
||||
/// Returns a list of extensions with their current status.
|
||||
pub async fn load_index(&self) -> Vec<HubExtensionWithStatus> {
|
||||
let entries = self.load_index_entries();
|
||||
self.merge_with_registry_status(entries).await
|
||||
}
|
||||
|
||||
/// Look up a single extension by name from the index.
|
||||
pub(crate) fn get_extension(&self, name: &str) -> Option<HubIndexEntry> {
|
||||
let entries = self.load_index_entries();
|
||||
entries.into_iter().find(|e| e.name == name)
|
||||
}
|
||||
|
||||
/// Return the directory where extensions should be installed.
|
||||
pub fn install_target_dir(&self) -> PathBuf {
|
||||
self.index_dir.clone()
|
||||
}
|
||||
|
||||
/// Return the index file path.
|
||||
fn index_file_path(&self) -> PathBuf {
|
||||
self.index_dir.join("index.json")
|
||||
}
|
||||
|
||||
/// Load index entries from disk, falling back to an empty list.
|
||||
fn load_index_entries(&self) -> Vec<HubIndexEntry> {
|
||||
let path = self.index_file_path();
|
||||
match load_index_from_file(&path) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"hub index not found or invalid, returning empty list"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge index entries with live registry status.
|
||||
async fn merge_with_registry_status(&self, entries: Vec<HubIndexEntry>) -> Vec<HubExtensionWithStatus> {
|
||||
let loaded = self.registry.get_loaded_extensions().await;
|
||||
let installed: HashMap<String, String> = loaded.into_iter().map(|s| (s.name, s.version)).collect();
|
||||
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let status = resolve_status(&entry, &installed);
|
||||
HubExtensionWithStatus {
|
||||
name: entry.name,
|
||||
version: entry.version,
|
||||
display_name: entry.display_name,
|
||||
description: entry.description,
|
||||
author: entry.author,
|
||||
icon: entry.icon,
|
||||
tags: entry.tags,
|
||||
bundled: entry.bundled,
|
||||
status,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Index file I/O
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read and parse the Hub index file, returning entries.
|
||||
fn load_index_from_file(path: &Path) -> Result<Vec<HubIndexEntry>, ExtensionError> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let index: HubIndexFile = serde_json::from_slice(&bytes)?;
|
||||
|
||||
if index.schema_version != HUB_SUPPORTED_SCHEMA_VERSION {
|
||||
warn!(
|
||||
found = index.schema_version,
|
||||
expected = HUB_SUPPORTED_SCHEMA_VERSION,
|
||||
"hub index schema version mismatch — attempting best-effort parse"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(index.extensions)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Determine the runtime status of a Hub entry by checking whether
|
||||
/// it is loaded in the registry.
|
||||
fn resolve_status(entry: &HubIndexEntry, installed: &HashMap<String, String>) -> HubExtensionStatus {
|
||||
if entry.bundled {
|
||||
return HubExtensionStatus::Installed;
|
||||
}
|
||||
|
||||
match installed.get(&entry.name) {
|
||||
Some(installed_version) => {
|
||||
if is_update_available(&entry.version, installed_version) {
|
||||
HubExtensionStatus::UpdateAvailable
|
||||
} else {
|
||||
HubExtensionStatus::Installed
|
||||
}
|
||||
}
|
||||
None => HubExtensionStatus::NotInstalled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the index version is newer than the installed version.
|
||||
fn is_update_available(index_version: &str, installed_version: &str) -> bool {
|
||||
let Ok(idx) = semver::Version::parse(index_version) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(inst) = semver::Version::parse(installed_version) else {
|
||||
return false;
|
||||
};
|
||||
idx > inst
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_status_bundled_always_installed() {
|
||||
let entry = HubIndexEntry {
|
||||
name: "builtin-ext".into(),
|
||||
version: "1.0.0".into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
icon: None,
|
||||
tags: Vec::new(),
|
||||
bundled: true,
|
||||
download_url: None,
|
||||
};
|
||||
let installed = HashMap::new();
|
||||
assert_eq!(resolve_status(&entry, &installed), HubExtensionStatus::Installed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_status_not_installed() {
|
||||
let entry = HubIndexEntry {
|
||||
name: "new-ext".into(),
|
||||
version: "1.0.0".into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
icon: None,
|
||||
tags: Vec::new(),
|
||||
bundled: false,
|
||||
download_url: None,
|
||||
};
|
||||
let installed = HashMap::new();
|
||||
assert_eq!(resolve_status(&entry, &installed), HubExtensionStatus::NotInstalled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_status_installed_same_version() {
|
||||
let entry = HubIndexEntry {
|
||||
name: "my-ext".into(),
|
||||
version: "1.0.0".into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
icon: None,
|
||||
tags: Vec::new(),
|
||||
bundled: false,
|
||||
download_url: None,
|
||||
};
|
||||
let installed = HashMap::from([("my-ext".into(), "1.0.0".into())]);
|
||||
assert_eq!(resolve_status(&entry, &installed), HubExtensionStatus::Installed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_status_update_available() {
|
||||
let entry = HubIndexEntry {
|
||||
name: "my-ext".into(),
|
||||
version: "2.0.0".into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
icon: None,
|
||||
tags: Vec::new(),
|
||||
bundled: false,
|
||||
download_url: None,
|
||||
};
|
||||
let installed = HashMap::from([("my-ext".into(), "1.0.0".into())]);
|
||||
assert_eq!(resolve_status(&entry, &installed), HubExtensionStatus::UpdateAvailable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_status_installed_newer_than_index() {
|
||||
let entry = HubIndexEntry {
|
||||
name: "my-ext".into(),
|
||||
version: "1.0.0".into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
icon: None,
|
||||
tags: Vec::new(),
|
||||
bundled: false,
|
||||
download_url: None,
|
||||
};
|
||||
let installed = HashMap::from([("my-ext".into(), "2.0.0".into())]);
|
||||
// Installed version is newer — still "installed", not "update_available".
|
||||
assert_eq!(resolve_status(&entry, &installed), HubExtensionStatus::Installed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_update_available_newer() {
|
||||
assert!(is_update_available("2.0.0", "1.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_update_available_same() {
|
||||
assert!(!is_update_available("1.0.0", "1.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_update_available_older() {
|
||||
assert!(!is_update_available("1.0.0", "2.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_update_available_invalid_version() {
|
||||
assert!(!is_update_available("not-semver", "1.0.0"));
|
||||
assert!(!is_update_available("1.0.0", "not-semver"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_index_from_file_valid() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let index = HubIndexFile {
|
||||
schema_version: 1,
|
||||
extensions: vec![HubIndexEntry {
|
||||
name: "test-ext".into(),
|
||||
version: "1.0.0".into(),
|
||||
display_name: Some("Test Extension".into()),
|
||||
description: Some("A test extension".into()),
|
||||
author: Some("Test Author".into()),
|
||||
icon: None,
|
||||
tags: vec!["tools".into()],
|
||||
bundled: false,
|
||||
download_url: Some("https://example.com/test-ext-1.0.0.tar.gz".into()),
|
||||
}],
|
||||
};
|
||||
let path = tmp.path().join("index.json");
|
||||
std::fs::write(&path, serde_json::to_vec_pretty(&index).unwrap()).unwrap();
|
||||
|
||||
let entries = load_index_from_file(&path).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].name, "test-ext");
|
||||
assert_eq!(entries[0].version, "1.0.0");
|
||||
assert!(!entries[0].bundled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_index_from_file_not_found() {
|
||||
let result = load_index_from_file(Path::new("/nonexistent/index.json"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_index_from_file_invalid_json() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let path = tmp.path().join("index.json");
|
||||
std::fs::write(&path, b"not valid json").unwrap();
|
||||
|
||||
let result = load_index_from_file(&path);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_index_from_file_empty_extensions() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let index = HubIndexFile {
|
||||
schema_version: 1,
|
||||
extensions: Vec::new(),
|
||||
};
|
||||
let path = tmp.path().join("index.json");
|
||||
std::fs::write(&path, serde_json::to_vec(&index).unwrap()).unwrap();
|
||||
|
||||
let entries = load_index_from_file(&path).unwrap();
|
||||
assert!(entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hub_index_entry_deserialization() {
|
||||
let json = serde_json::json!({
|
||||
"name": "my-ext",
|
||||
"version": "2.0.0",
|
||||
"display_name": "My Extension",
|
||||
"tags": ["ai", "tools"],
|
||||
"bundled": true
|
||||
});
|
||||
let entry: HubIndexEntry = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(entry.name, "my-ext");
|
||||
assert_eq!(entry.version, "2.0.0");
|
||||
assert_eq!(entry.display_name.as_deref(), Some("My Extension"));
|
||||
assert_eq!(entry.tags, vec!["ai", "tools"]);
|
||||
assert!(entry.bundled);
|
||||
assert!(entry.download_url.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use serde_json::json;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::constants::EXTENSION_MANIFEST_FILE;
|
||||
use crate::error::ExtensionError;
|
||||
use crate::manifest::{parse_manifest, validate_manifest};
|
||||
use crate::registry::ExtensionRegistry;
|
||||
use crate::resolvers::resolve_extension_contributions;
|
||||
use crate::types::{ExtensionSource, ExtensionState, LoadedExtension};
|
||||
|
||||
use super::index_manager::HubIndexManager;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Outcome of a Hub install/update/uninstall operation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HubResult {
|
||||
pub success: bool,
|
||||
pub msg: Option<String>,
|
||||
}
|
||||
|
||||
impl HubResult {
|
||||
fn ok() -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
msg: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn err(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
msg: Some(msg.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Info about an available update.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HubUpdateInfo {
|
||||
pub name: String,
|
||||
pub current_version: String,
|
||||
pub latest_version: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HubInstaller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handles extension installation, update, uninstall, and verification.
|
||||
///
|
||||
/// For this phase, remote downloading is a stub — extensions must already
|
||||
/// be present in the Hub directory or have a bundled flag. The installer
|
||||
/// verifies the manifest and contributions, then triggers a hot reload.
|
||||
#[derive(Clone)]
|
||||
pub struct HubInstaller {
|
||||
index_manager: HubIndexManager,
|
||||
registry: ExtensionRegistry,
|
||||
broadcaster: Arc<dyn EventBroadcaster>,
|
||||
}
|
||||
|
||||
impl HubInstaller {
|
||||
pub fn new(index_manager: HubIndexManager, registry: ExtensionRegistry) -> Self {
|
||||
let broadcaster = registry.event_broadcaster();
|
||||
Self {
|
||||
index_manager,
|
||||
registry,
|
||||
broadcaster,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install an extension from the Hub by name.
|
||||
///
|
||||
/// Flow: look up in index → verify the extension directory exists →
|
||||
/// validate manifest → verify contributions → trigger hot reload.
|
||||
pub async fn install(&self, name: &str) -> HubResult {
|
||||
info!(name, "hub: installing extension");
|
||||
self.broadcast_state_changed(name, "installing", None);
|
||||
|
||||
let entry = match self.index_manager.get_extension(name) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
let error = format!("Extension '{name}' not found in hub index");
|
||||
self.broadcast_state_changed(name, "failed", Some(error.clone()));
|
||||
return HubResult::err(error);
|
||||
}
|
||||
};
|
||||
|
||||
let target_dir = self.index_manager.install_target_dir();
|
||||
let ext_dir = target_dir.join(&entry.name);
|
||||
|
||||
// For now, the extension directory must already exist (no remote download).
|
||||
// Future: download from entry.download_url and extract.
|
||||
if !ext_dir.exists() {
|
||||
let error = format!(
|
||||
"Extension directory not found: {}. Remote download not yet implemented.",
|
||||
ext_dir.display()
|
||||
);
|
||||
self.broadcast_state_changed(name, "failed", Some(error.clone()));
|
||||
return HubResult::err(error);
|
||||
}
|
||||
|
||||
if let Err(e) = self.verify_installation(&ext_dir) {
|
||||
let error = format!("Installation verification failed: {e}");
|
||||
self.broadcast_state_changed(name, "failed", Some(error.clone()));
|
||||
return HubResult::err(error);
|
||||
}
|
||||
|
||||
// Trigger hot reload to pick up the new extension.
|
||||
self.registry.hot_reload().await;
|
||||
self.broadcast_state_changed(name, "installed", None);
|
||||
|
||||
info!(name, "hub: extension installed successfully");
|
||||
HubResult::ok()
|
||||
}
|
||||
|
||||
/// Retry a previously failed installation.
|
||||
pub async fn retry_install(&self, name: &str) -> HubResult {
|
||||
debug!(name, "hub: retrying installation");
|
||||
self.install(name).await
|
||||
}
|
||||
|
||||
/// Update an installed extension to the latest version from the index.
|
||||
///
|
||||
/// For this phase, update is equivalent to re-verifying the existing
|
||||
/// directory (which may have been updated externally) and hot-reloading.
|
||||
pub async fn update(&self, name: &str) -> HubResult {
|
||||
info!(name, "hub: updating extension");
|
||||
self.broadcast_state_changed(name, "updating", None);
|
||||
|
||||
let entry = match self.index_manager.get_extension(name) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
let error = format!("Extension '{name}' not found in hub index");
|
||||
self.broadcast_state_changed(name, "failed", Some(error.clone()));
|
||||
return HubResult::err(error);
|
||||
}
|
||||
};
|
||||
|
||||
let target_dir = self.index_manager.install_target_dir();
|
||||
let ext_dir = target_dir.join(&entry.name);
|
||||
|
||||
if !ext_dir.exists() {
|
||||
let error = format!("Extension not installed: {}", ext_dir.display());
|
||||
self.broadcast_state_changed(name, "failed", Some(error.clone()));
|
||||
return HubResult::err(error);
|
||||
}
|
||||
|
||||
if let Err(e) = self.verify_installation(&ext_dir) {
|
||||
let error = format!("Update verification failed: {e}");
|
||||
self.broadcast_state_changed(name, "failed", Some(error.clone()));
|
||||
return HubResult::err(error);
|
||||
}
|
||||
|
||||
self.registry.hot_reload().await;
|
||||
self.broadcast_state_changed(name, "installed", None);
|
||||
|
||||
info!(name, "hub: extension updated successfully");
|
||||
HubResult::ok()
|
||||
}
|
||||
|
||||
/// Uninstall an extension by removing its directory and hot-reloading.
|
||||
pub async fn uninstall(&self, name: &str) -> HubResult {
|
||||
if let Err(msg) = validate_hub_name(name) {
|
||||
self.broadcast_state_changed(name, "failed", Some(msg.clone()));
|
||||
return HubResult::err(msg);
|
||||
}
|
||||
|
||||
info!(name, "hub: uninstalling extension");
|
||||
|
||||
let target_dir = self.index_manager.install_target_dir();
|
||||
let ext_dir = target_dir.join(name);
|
||||
|
||||
if !ext_dir.exists() {
|
||||
let error = format!("Extension '{name}' is not installed");
|
||||
self.broadcast_state_changed(name, "failed", Some(error.clone()));
|
||||
return HubResult::err(error);
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::remove_dir_all(&ext_dir) {
|
||||
warn!(
|
||||
name,
|
||||
error = %e,
|
||||
"hub: failed to remove extension directory"
|
||||
);
|
||||
let error = format!("Failed to remove extension directory: {e}");
|
||||
self.broadcast_state_changed(name, "failed", Some(error.clone()));
|
||||
return HubResult::err(error);
|
||||
}
|
||||
|
||||
self.registry.hot_reload().await;
|
||||
self.broadcast_state_changed(name, "uninstalled", None);
|
||||
|
||||
info!(name, "hub: extension uninstalled successfully");
|
||||
HubResult::ok()
|
||||
}
|
||||
|
||||
/// Check for available updates across all installed extensions.
|
||||
///
|
||||
/// Compares installed versions against the Hub index.
|
||||
pub async fn check_updates(&self) -> Vec<HubUpdateInfo> {
|
||||
let index_list = self.index_manager.load_index().await;
|
||||
let loaded = self.registry.get_loaded_extensions().await;
|
||||
|
||||
let mut updates = Vec::new();
|
||||
|
||||
for hub_ext in &index_list {
|
||||
if hub_ext.bundled {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(installed) = loaded.iter().find(|l| l.name == hub_ext.name)
|
||||
&& is_newer(&hub_ext.version, &installed.version)
|
||||
{
|
||||
updates.push(HubUpdateInfo {
|
||||
name: hub_ext.name.clone(),
|
||||
current_version: installed.version.clone(),
|
||||
latest_version: hub_ext.version.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updates
|
||||
}
|
||||
|
||||
/// Verify that an extension directory contains a valid manifest
|
||||
/// and that its contributions can be resolved without errors.
|
||||
pub fn verify_installation(&self, ext_dir: &Path) -> Result<(), ExtensionError> {
|
||||
let manifest_path = ext_dir.join(EXTENSION_MANIFEST_FILE);
|
||||
|
||||
if !manifest_path.exists() {
|
||||
return Err(ExtensionError::ManifestValidation(format!(
|
||||
"Manifest not found: {}",
|
||||
manifest_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let bytes = std::fs::read(&manifest_path)?;
|
||||
let manifest = parse_manifest(&bytes)?;
|
||||
validate_manifest(&manifest)?;
|
||||
|
||||
// Build a temporary LoadedExtension to test contribution resolution.
|
||||
let loaded = LoadedExtension {
|
||||
manifest,
|
||||
directory: ext_dir.to_str().unwrap_or_default().to_owned(),
|
||||
source: ExtensionSource::Local,
|
||||
state: ExtensionState {
|
||||
name: "verification-check".into(),
|
||||
version: "0.0.0".into(),
|
||||
enabled: true,
|
||||
installed_at: None,
|
||||
last_activated_at: None,
|
||||
},
|
||||
};
|
||||
|
||||
// Resolve contributions — this validates CSS files exist for themes,
|
||||
// route namespaces for webui, etc.
|
||||
let _contributions = resolve_extension_contributions(&loaded);
|
||||
|
||||
debug!(
|
||||
dir = %ext_dir.display(),
|
||||
"hub: installation verification passed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn broadcast_state_changed(&self, name: &str, status: &str, error: Option<String>) {
|
||||
self.broadcaster.broadcast(WebSocketMessage::new(
|
||||
"hub.state-changed",
|
||||
json!({
|
||||
"name": name,
|
||||
"status": status,
|
||||
"error": error,
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Validate an extension name to prevent path traversal attacks.
|
||||
fn validate_hub_name(name: &str) -> Result<(), String> {
|
||||
if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") {
|
||||
return Err(format!("Invalid extension name: '{name}'"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if `index_version` is newer than `installed_version`.
|
||||
fn is_newer(index_version: &str, installed_version: &str) -> bool {
|
||||
let Ok(idx) = semver::Version::parse(index_version) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(inst) = semver::Version::parse(installed_version) else {
|
||||
return false;
|
||||
};
|
||||
idx > inst
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nomifun_realtime::BroadcastEventBus;
|
||||
|
||||
#[test]
|
||||
fn hub_result_ok() {
|
||||
let r = HubResult::ok();
|
||||
assert!(r.success);
|
||||
assert!(r.msg.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hub_result_err() {
|
||||
let r = HubResult::err("something failed");
|
||||
assert!(!r.success);
|
||||
assert_eq!(r.msg.as_deref(), Some("something failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_newer_true() {
|
||||
assert!(is_newer("2.0.0", "1.0.0"));
|
||||
assert!(is_newer("1.1.0", "1.0.0"));
|
||||
assert!(is_newer("1.0.1", "1.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_newer_false() {
|
||||
assert!(!is_newer("1.0.0", "1.0.0"));
|
||||
assert!(!is_newer("1.0.0", "2.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_newer_invalid_versions() {
|
||||
assert!(!is_newer("not-semver", "1.0.0"));
|
||||
assert!(!is_newer("1.0.0", "not-semver"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_installation_no_manifest() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let registry = make_test_registry();
|
||||
let index_mgr = HubIndexManager::new(tmp.path().to_path_buf(), registry.clone());
|
||||
let installer = HubInstaller::new(index_mgr, registry);
|
||||
|
||||
let result = installer.verify_installation(tmp.path());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_installation_invalid_manifest() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join(EXTENSION_MANIFEST_FILE), b"not valid json").unwrap();
|
||||
|
||||
let registry = make_test_registry();
|
||||
let index_mgr = HubIndexManager::new(tmp.path().to_path_buf(), registry.clone());
|
||||
let installer = HubInstaller::new(index_mgr, registry);
|
||||
|
||||
let result = installer.verify_installation(tmp.path());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_installation_valid_manifest() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let manifest = serde_json::json!({
|
||||
"name": "test-ext",
|
||||
"version": "1.0.0"
|
||||
});
|
||||
std::fs::write(
|
||||
tmp.path().join(EXTENSION_MANIFEST_FILE),
|
||||
serde_json::to_vec_pretty(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let registry = make_test_registry();
|
||||
let index_mgr = HubIndexManager::new(tmp.path().to_path_buf(), registry.clone());
|
||||
let installer = HubInstaller::new(index_mgr, registry);
|
||||
|
||||
let result = installer.verify_installation(tmp.path());
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_installation_reserved_name_fails() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let manifest = serde_json::json!({
|
||||
"name": "nomi-internal-ext",
|
||||
"version": "1.0.0"
|
||||
});
|
||||
std::fs::write(
|
||||
tmp.path().join(EXTENSION_MANIFEST_FILE),
|
||||
serde_json::to_vec_pretty(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let registry = make_test_registry();
|
||||
let index_mgr = HubIndexManager::new(tmp.path().to_path_buf(), registry.clone());
|
||||
let installer = HubInstaller::new(index_mgr, registry);
|
||||
|
||||
let result = installer.verify_installation(tmp.path());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_hub_name_rejects_traversal() {
|
||||
assert!(validate_hub_name("../etc").is_err());
|
||||
assert!(validate_hub_name("foo/../../bar").is_err());
|
||||
assert!(validate_hub_name("foo\\bar").is_err());
|
||||
assert!(validate_hub_name("").is_err());
|
||||
assert!(validate_hub_name("..").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_hub_name_accepts_valid() {
|
||||
assert!(validate_hub_name("my-extension").is_ok());
|
||||
assert!(validate_hub_name("ext_v2").is_ok());
|
||||
assert!(validate_hub_name("a").is_ok());
|
||||
}
|
||||
|
||||
fn make_test_registry() -> ExtensionRegistry {
|
||||
use crate::state::ExtensionStateStore;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
|
||||
let bus = Arc::new(BroadcastEventBus::new(64));
|
||||
// Leak the TempDir so it lives long enough for the test.
|
||||
std::mem::forget(tmp);
|
||||
ExtensionRegistry::new(store, bus, "1.0.0".into())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_broadcasts_installing_then_failed_for_missing_index_entry() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = crate::state::ExtensionStateStore::new(tmp.path().join("states.json"));
|
||||
let bus = Arc::new(BroadcastEventBus::new(64));
|
||||
let registry = ExtensionRegistry::new(store, bus.clone(), "1.0.0".into());
|
||||
let index_mgr = HubIndexManager::new(tmp.path().to_path_buf(), registry.clone());
|
||||
let installer = HubInstaller::new(index_mgr, registry);
|
||||
let mut rx = bus.subscribe();
|
||||
|
||||
let result = installer.install("missing-ext").await;
|
||||
|
||||
assert!(!result.success);
|
||||
let first = rx.recv().await.unwrap();
|
||||
assert_eq!(first.name, "hub.state-changed");
|
||||
assert_eq!(first.data["name"], "missing-ext");
|
||||
assert_eq!(first.data["status"], "installing");
|
||||
|
||||
let second = rx.recv().await.unwrap();
|
||||
assert_eq!(second.name, "hub.state-changed");
|
||||
assert_eq!(second.data["status"], "failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod index_manager;
|
||||
pub mod installer;
|
||||
|
||||
pub use index_manager::HubIndexManager;
|
||||
pub use installer::HubInstaller;
|
||||
@@ -0,0 +1,175 @@
|
||||
use axum::Router;
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use axum::extract::{Json, State};
|
||||
use axum::routing::{get, post};
|
||||
|
||||
use nomifun_api_types::{
|
||||
ApiResponse, HubExtensionListItem, HubOperationResponse, HubUpdateInfo as ApiHubUpdateInfo, InstallExtensionRequest,
|
||||
};
|
||||
use nomifun_common::AppError;
|
||||
|
||||
use crate::hub::index_manager::HubIndexManager;
|
||||
use crate::hub::installer::HubInstaller;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shared state for Hub route handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct HubRouterState {
|
||||
pub index_manager: HubIndexManager,
|
||||
pub installer: HubInstaller,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the Hub router with all `/api/hub/*` routes.
|
||||
///
|
||||
/// All routes require authentication (applied by the caller).
|
||||
pub fn hub_routes(state: HubRouterState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/hub/extensions", get(get_hub_extensions))
|
||||
.route("/api/hub/install", post(install_extension))
|
||||
.route("/api/hub/retry-install", post(retry_install))
|
||||
.route("/api/hub/check-updates", post(check_updates))
|
||||
.route("/api/hub/update", post(update_extension))
|
||||
.route("/api/hub/uninstall", post(uninstall_extension))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/hub/extensions` — get Hub extension list with statuses.
|
||||
async fn get_hub_extensions(
|
||||
State(state): State<HubRouterState>,
|
||||
) -> Result<Json<ApiResponse<Vec<HubExtensionListItem>>>, AppError> {
|
||||
let entries = state.index_manager.load_index().await;
|
||||
let items: Vec<HubExtensionListItem> = entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let status_str = serde_json::to_value(e.status)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_else(|| "notInstalled".to_string());
|
||||
HubExtensionListItem {
|
||||
name: e.name,
|
||||
version: e.version,
|
||||
display_name: e.display_name,
|
||||
description: e.description,
|
||||
author: e.author,
|
||||
icon: e.icon,
|
||||
tags: e.tags,
|
||||
bundled: e.bundled,
|
||||
status: status_str,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ApiResponse::ok(items)))
|
||||
}
|
||||
|
||||
/// `POST /api/hub/install` — install an extension from the Hub.
|
||||
async fn install_extension(
|
||||
State(state): State<HubRouterState>,
|
||||
body: Result<Json<InstallExtensionRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<HubOperationResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let result = state.installer.install(&req.name).await;
|
||||
Ok(Json(ApiResponse::ok(HubOperationResponse {
|
||||
success: result.success,
|
||||
msg: result.msg,
|
||||
})))
|
||||
}
|
||||
|
||||
/// `POST /api/hub/retry-install` — retry a failed installation.
|
||||
async fn retry_install(
|
||||
State(state): State<HubRouterState>,
|
||||
body: Result<Json<InstallExtensionRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<HubOperationResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let result = state.installer.retry_install(&req.name).await;
|
||||
Ok(Json(ApiResponse::ok(HubOperationResponse {
|
||||
success: result.success,
|
||||
msg: result.msg,
|
||||
})))
|
||||
}
|
||||
|
||||
/// `POST /api/hub/check-updates` — check for available updates.
|
||||
async fn check_updates(
|
||||
State(state): State<HubRouterState>,
|
||||
) -> Result<Json<ApiResponse<Vec<ApiHubUpdateInfo>>>, AppError> {
|
||||
let updates = state.installer.check_updates().await;
|
||||
let resp: Vec<ApiHubUpdateInfo> = updates
|
||||
.into_iter()
|
||||
.map(|u| ApiHubUpdateInfo {
|
||||
name: u.name,
|
||||
current_version: u.current_version,
|
||||
latest_version: u.latest_version,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
/// `POST /api/hub/update` — update an installed extension.
|
||||
async fn update_extension(
|
||||
State(state): State<HubRouterState>,
|
||||
body: Result<Json<InstallExtensionRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<HubOperationResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let result = state.installer.update(&req.name).await;
|
||||
Ok(Json(ApiResponse::ok(HubOperationResponse {
|
||||
success: result.success,
|
||||
msg: result.msg,
|
||||
})))
|
||||
}
|
||||
|
||||
/// `POST /api/hub/uninstall` — uninstall an extension.
|
||||
async fn uninstall_extension(
|
||||
State(state): State<HubRouterState>,
|
||||
body: Result<Json<InstallExtensionRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<HubOperationResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let result = state.installer.uninstall(&req.name).await;
|
||||
Ok(Json(ApiResponse::ok(HubOperationResponse {
|
||||
success: result.success,
|
||||
msg: result.msg,
|
||||
})))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::registry::ExtensionRegistry;
|
||||
use crate::state::ExtensionStateStore;
|
||||
use nomifun_realtime::BroadcastEventBus;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn make_state() -> HubRouterState {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
|
||||
let bus = Arc::new(BroadcastEventBus::new(64));
|
||||
let hub_dir = tmp.path().to_path_buf();
|
||||
std::mem::forget(tmp);
|
||||
let registry = ExtensionRegistry::new(store, bus, "1.0.0".into());
|
||||
let index_manager = HubIndexManager::new(hub_dir, registry.clone());
|
||||
let installer = HubInstaller::new(index_manager.clone(), registry);
|
||||
HubRouterState {
|
||||
index_manager,
|
||||
installer,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hub_routes_builds_router() {
|
||||
let state = make_state();
|
||||
let _router = hub_routes(state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Extension registry: manifest parsing, hub installer, skill scanning, and lifecycle hooks.
|
||||
|
||||
mod asset_paths;
|
||||
pub mod classifier;
|
||||
pub mod constants;
|
||||
pub mod dependency;
|
||||
pub mod error;
|
||||
pub mod external_paths;
|
||||
pub mod hub;
|
||||
pub mod hub_routes;
|
||||
pub mod lifecycle;
|
||||
pub mod loader;
|
||||
pub mod manifest;
|
||||
pub mod permission;
|
||||
pub mod registry;
|
||||
mod registry_helpers;
|
||||
pub mod resolvers;
|
||||
pub mod routes;
|
||||
pub mod skill_routes;
|
||||
pub mod skill_service;
|
||||
pub mod startup_materialize;
|
||||
pub mod state;
|
||||
pub mod template;
|
||||
pub mod types;
|
||||
pub mod watcher;
|
||||
|
||||
pub use classifier::{AssistantClassifier, AssistantRuleDispatcher};
|
||||
pub use constants::*;
|
||||
pub use dependency::{DependencyIssue, DependencyValidationResult, validate_dependencies};
|
||||
pub use error::ExtensionError;
|
||||
pub use lifecycle::{HookKind, execute_hook, needs_install_hook, resolve_hook_path};
|
||||
pub use loader::{
|
||||
ScanPath, filter_by_engine_compatibility, load_all, resolve_install_target_dir_for_data_dir, resolve_scan_paths,
|
||||
resolve_scan_paths_for_data_dir,
|
||||
};
|
||||
pub use manifest::{parse_manifest, validate_manifest};
|
||||
pub use permission::{build_permission_summary, calculate_risk_level};
|
||||
pub use registry::{ExtensionRegistry, ExtensionSummary};
|
||||
pub use resolvers::{resolve_all_contributions, resolve_extension_contributions, resolve_i18n_for_all};
|
||||
pub use startup_materialize::materialize_if_needed;
|
||||
pub use state::{ExtensionStateStore, load_states_from_file, resolve_state_file_path, save_states_to_file};
|
||||
pub use template::{resolve_env_map, resolve_env_templates, resolve_file_reference};
|
||||
pub use types::*;
|
||||
pub use watcher::ExtensionWatcher;
|
||||
|
||||
pub use external_paths::ExternalPathsManager;
|
||||
pub use hub::{HubIndexManager, HubInstaller};
|
||||
pub use hub_routes::{HubRouterState, hub_routes};
|
||||
pub use routes::{ExtensionRouterState, extension_routes};
|
||||
pub use skill_routes::{SkillRouterState, skill_routes};
|
||||
pub use skill_service::{
|
||||
BUILTIN_SKILLS_ENV_VAR, BuiltinAutoSkillItem, ExternalSkillSource, NamedPath, ResolvedAgentSkill, ScannedSkill,
|
||||
SkillListItem, SkillPaths, SkillSource, builtin_skills_corpus, delete_skill, detect_and_count_external_skills,
|
||||
detect_common_skill_paths, export_skill_with_symlink, get_skill_paths, import_skill, import_skill_with_symlink,
|
||||
link_workspace_skills, list_available_skills, list_builtin_auto_skills, materialize_skills_for_agent,
|
||||
read_builtin_rule, read_builtin_skill, read_skill_info, resolve_skill_paths, scan_for_skills,
|
||||
};
|
||||
pub use skill_service::{
|
||||
delete_assistant_rule, delete_assistant_skill, read_assistant_rule, read_assistant_skill, write_assistant_rule,
|
||||
write_assistant_skill,
|
||||
};
|
||||
@@ -0,0 +1,521 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
|
||||
use nomifun_runtime::Builder as CmdBuilder;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::constants::{
|
||||
LIFECYCLE_ON_ACTIVATE_TIMEOUT_SECS, LIFECYCLE_ON_DEACTIVATE_TIMEOUT_SECS, LIFECYCLE_ON_INSTALL_TIMEOUT_SECS,
|
||||
LIFECYCLE_ON_UNINSTALL_TIMEOUT_SECS,
|
||||
};
|
||||
use crate::error::ExtensionError;
|
||||
use crate::types::LifecycleHooks;
|
||||
|
||||
/// Which lifecycle hook to execute.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HookKind {
|
||||
OnInstall,
|
||||
OnUninstall,
|
||||
OnActivate,
|
||||
OnDeactivate,
|
||||
}
|
||||
|
||||
impl HookKind {
|
||||
/// Default timeout in seconds for this hook kind.
|
||||
pub fn timeout_secs(self) -> u64 {
|
||||
match self {
|
||||
Self::OnInstall => LIFECYCLE_ON_INSTALL_TIMEOUT_SECS,
|
||||
Self::OnUninstall => LIFECYCLE_ON_UNINSTALL_TIMEOUT_SECS,
|
||||
Self::OnActivate => LIFECYCLE_ON_ACTIVATE_TIMEOUT_SECS,
|
||||
Self::OnDeactivate => LIFECYCLE_ON_DEACTIVATE_TIMEOUT_SECS,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable label for logging and error messages.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::OnInstall => "onInstall",
|
||||
Self::OnUninstall => "onUninstall",
|
||||
Self::OnActivate => "onActivate",
|
||||
Self::OnDeactivate => "onDeactivate",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the hook script path from the manifest for a given hook kind.
|
||||
pub fn resolve_hook_path(hooks: &LifecycleHooks, kind: HookKind) -> Option<&str> {
|
||||
let value = match kind {
|
||||
HookKind::OnInstall => hooks.on_install.as_deref(),
|
||||
HookKind::OnUninstall => hooks.on_uninstall.as_deref(),
|
||||
HookKind::OnActivate => hooks.on_activate.as_deref(),
|
||||
HookKind::OnDeactivate => hooks.on_deactivate.as_deref(),
|
||||
};
|
||||
value.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Map a hook script to the interpreter (program) and argument list used to
|
||||
/// run it, dispatching on the script's file extension. This is the single
|
||||
/// source of truth for how lifecycle hook scripts are executed across
|
||||
/// platforms.
|
||||
///
|
||||
/// `CmdBuilder` resolves a bare program name (no path separators) through
|
||||
/// `PATH` — including the Windows `.cmd`/`.ps1`/`.bat` shim fallbacks — so we
|
||||
/// intentionally pass the interpreter as a bare name (`sh`, `cmd`,
|
||||
/// `powershell` / `pwsh`) and the script path as an argument, rather than
|
||||
/// spawning the script file directly as the program. Spawning a `.sh`/shebang
|
||||
/// script directly is fatal on Windows (`CreateProcess` → `ERROR_BAD_EXE_FORMAT`).
|
||||
///
|
||||
/// Extension dispatch:
|
||||
/// - `.sh` → `sh <script>` (works on unix; on Windows via git-bash/MSYS `sh` on PATH).
|
||||
/// - `.ps1` → `pwsh -NoProfile -ExecutionPolicy Bypass -File <script>` on unix,
|
||||
/// `powershell -NoProfile -ExecutionPolicy Bypass -File <script>` on Windows.
|
||||
/// - `.cmd` / `.bat` → `cmd /C <script>` on Windows; on unix run directly (shebang) as a fallback.
|
||||
/// - none / other → unix: run directly (rely on shebang); Windows: `cmd /C <script>`.
|
||||
///
|
||||
/// `<script>` is always passed as the absolute path so the interpreter resolves
|
||||
/// it regardless of the child's working directory.
|
||||
fn hook_command(script: &Path) -> (OsString, Vec<OsString>) {
|
||||
let ext = script
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_ascii_lowercase);
|
||||
let script_arg: OsString = script.as_os_str().to_owned();
|
||||
|
||||
match ext.as_deref() {
|
||||
Some("sh") => (OsString::from("sh"), vec![script_arg]),
|
||||
Some("ps1") => {
|
||||
let program = if cfg!(windows) { "powershell" } else { "pwsh" };
|
||||
(
|
||||
OsString::from(program),
|
||||
vec![
|
||||
OsString::from("-NoProfile"),
|
||||
OsString::from("-ExecutionPolicy"),
|
||||
OsString::from("Bypass"),
|
||||
OsString::from("-File"),
|
||||
script_arg,
|
||||
],
|
||||
)
|
||||
}
|
||||
Some("cmd") | Some("bat") => {
|
||||
if cfg!(windows) {
|
||||
(OsString::from("cmd"), vec![OsString::from("/C"), script_arg])
|
||||
} else {
|
||||
// No cmd.exe on unix; best effort is to exec the script directly
|
||||
// (a `.cmd` on unix is unusual but we honour the shebang if any).
|
||||
(script_arg, Vec::new())
|
||||
}
|
||||
}
|
||||
// No / unknown extension: unix executes directly via shebang; Windows
|
||||
// cannot exec a shebang script, so route through `cmd /C`.
|
||||
_ => {
|
||||
if cfg!(windows) {
|
||||
(OsString::from("cmd"), vec![OsString::from("/C"), script_arg])
|
||||
} else {
|
||||
(script_arg, Vec::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a lifecycle hook script in a child process.
|
||||
///
|
||||
/// - `ext_dir`: absolute path to the extension root directory (used as cwd).
|
||||
/// - `hook_path`: script path relative to `ext_dir`.
|
||||
/// - `kind`: which hook is being executed (determines timeout and label).
|
||||
/// - `extension_name`: used for logging and error context.
|
||||
///
|
||||
/// Returns `Ok(())` on success. Returns an error if the script is not found,
|
||||
/// times out, or exits with a non-zero status.
|
||||
pub async fn execute_hook(
|
||||
ext_dir: &Path,
|
||||
hook_path: &str,
|
||||
kind: HookKind,
|
||||
extension_name: &str,
|
||||
) -> Result<(), ExtensionError> {
|
||||
let script = ext_dir.join(hook_path);
|
||||
|
||||
if !script.exists() {
|
||||
warn!(
|
||||
extension = extension_name,
|
||||
hook = kind.label(),
|
||||
path = %script.display(),
|
||||
"lifecycle hook script not found, skipping"
|
||||
);
|
||||
return Err(ExtensionError::HookNotFound(script.display().to_string()));
|
||||
}
|
||||
|
||||
let timeout_secs = kind.timeout_secs();
|
||||
let label = kind.label();
|
||||
|
||||
info!(
|
||||
extension = extension_name,
|
||||
hook = label,
|
||||
path = %script.display(),
|
||||
timeout_secs,
|
||||
"executing lifecycle hook"
|
||||
);
|
||||
|
||||
// Select an interpreter by the script's file extension and pass the script
|
||||
// as an argument. Spawning the script path directly as the program fails on
|
||||
// Windows (`CreateProcess` cannot exec a `.sh`/shebang file). The interpreter
|
||||
// is a bare name so `CmdBuilder` resolves it through PATH (+ Windows shims).
|
||||
let (program, args) = hook_command(&script);
|
||||
let mut builder = CmdBuilder::clean_cli(&program);
|
||||
builder.args(&args);
|
||||
builder.current_dir(ext_dir);
|
||||
let child_future = builder.output();
|
||||
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), child_future).await;
|
||||
|
||||
match result {
|
||||
Err(_elapsed) => {
|
||||
warn!(
|
||||
extension = extension_name,
|
||||
hook = label,
|
||||
timeout_secs,
|
||||
"lifecycle hook timed out"
|
||||
);
|
||||
Err(ExtensionError::HookTimeout {
|
||||
extension_name: extension_name.to_owned(),
|
||||
hook: label.to_owned(),
|
||||
timeout_secs,
|
||||
})
|
||||
}
|
||||
Ok(Err(io_err)) => {
|
||||
warn!(
|
||||
extension = extension_name,
|
||||
hook = label,
|
||||
error = %io_err,
|
||||
"lifecycle hook I/O error"
|
||||
);
|
||||
Err(ExtensionError::HookFailed {
|
||||
extension_name: extension_name.to_owned(),
|
||||
hook: label.to_owned(),
|
||||
reason: io_err.to_string(),
|
||||
})
|
||||
}
|
||||
Ok(Ok(output)) => {
|
||||
if output.status.success() {
|
||||
info!(
|
||||
extension = extension_name,
|
||||
hook = label,
|
||||
"lifecycle hook completed successfully"
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let code = output
|
||||
.status
|
||||
.code()
|
||||
.map_or_else(|| "signal".to_owned(), |c| c.to_string());
|
||||
warn!(
|
||||
extension = extension_name,
|
||||
hook = label,
|
||||
exit_code = %code,
|
||||
stderr = %stderr,
|
||||
"lifecycle hook exited with error"
|
||||
);
|
||||
Err(ExtensionError::HookFailed {
|
||||
extension_name: extension_name.to_owned(),
|
||||
hook: label.to_owned(),
|
||||
reason: format!("exit code {code}: {}", stderr.trim()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine whether the `onInstall` hook should run.
|
||||
///
|
||||
/// Returns `true` when:
|
||||
/// - There is no persisted version (first-time install).
|
||||
/// - The persisted version differs from the current manifest version.
|
||||
pub fn needs_install_hook(current_version: &str, persisted_version: Option<&str>) -> bool {
|
||||
match persisted_version {
|
||||
None => true,
|
||||
Some(prev) => prev != current_version,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Write a lifecycle hook script with platform-appropriate syntax and
|
||||
/// return the file name (relative to `dir`) to hand to `execute_hook`.
|
||||
///
|
||||
/// On Windows a `.cmd` batch file is written; elsewhere a `#!/bin/sh`
|
||||
/// script (made executable). `stem` is the file name without extension.
|
||||
/// `unix_body` / `windows_body` are the script bodies for each platform.
|
||||
fn write_hook(dir: &Path, stem: &str, unix_body: &str, windows_body: &str) -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let name = format!("{stem}.cmd");
|
||||
// `@echo off` keeps the interpreter from echoing each command into
|
||||
// stdout, and CRLF line endings keep cmd.exe happy.
|
||||
let content = format!("@echo off\r\n{}\r\n", windows_body.replace('\n', "\r\n"));
|
||||
std::fs::write(dir.join(&name), content).unwrap();
|
||||
let _ = unix_body;
|
||||
name
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let name = format!("{stem}.sh");
|
||||
let full = dir.join(&name);
|
||||
std::fs::write(&full, format!("#!/bin/sh\n{unix_body}\n")).unwrap();
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&full, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let _ = windows_body;
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// needs_install_hook
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_needs_install_first_time() {
|
||||
assert!(needs_install_hook("1.0.0", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_install_version_changed() {
|
||||
assert!(needs_install_hook("2.0.0", Some("1.0.0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_install_same_version() {
|
||||
assert!(!needs_install_hook("1.0.0", Some("1.0.0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_install_downgrade() {
|
||||
assert!(needs_install_hook("0.9.0", Some("1.0.0")));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HookKind
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_hook_kind_timeout_values() {
|
||||
assert_eq!(HookKind::OnInstall.timeout_secs(), 120);
|
||||
assert_eq!(HookKind::OnUninstall.timeout_secs(), 60);
|
||||
assert_eq!(HookKind::OnActivate.timeout_secs(), 30);
|
||||
assert_eq!(HookKind::OnDeactivate.timeout_secs(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_kind_labels() {
|
||||
assert_eq!(HookKind::OnInstall.label(), "onInstall");
|
||||
assert_eq!(HookKind::OnUninstall.label(), "onUninstall");
|
||||
assert_eq!(HookKind::OnActivate.label(), "onActivate");
|
||||
assert_eq!(HookKind::OnDeactivate.label(), "onDeactivate");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// resolve_hook_path
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_resolve_hook_path_present() {
|
||||
let hooks = LifecycleHooks {
|
||||
on_install: Some("scripts/install.sh".into()),
|
||||
on_activate: Some("scripts/activate.sh".into()),
|
||||
on_deactivate: None,
|
||||
on_uninstall: None,
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_hook_path(&hooks, HookKind::OnInstall),
|
||||
Some("scripts/install.sh")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_hook_path(&hooks, HookKind::OnActivate),
|
||||
Some("scripts/activate.sh")
|
||||
);
|
||||
assert_eq!(resolve_hook_path(&hooks, HookKind::OnDeactivate), None);
|
||||
assert_eq!(resolve_hook_path(&hooks, HookKind::OnUninstall), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_hook_path_empty_string() {
|
||||
let hooks = LifecycleHooks {
|
||||
on_install: Some(String::new()),
|
||||
on_activate: None,
|
||||
on_deactivate: None,
|
||||
on_uninstall: None,
|
||||
};
|
||||
assert_eq!(resolve_hook_path(&hooks, HookKind::OnInstall), None);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// hook_command — interpreter dispatch (single source of truth)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Collect `(program, args)` as plain `String`s for assertion convenience.
|
||||
fn dispatch(name: &str) -> (String, Vec<String>) {
|
||||
let (program, args) = hook_command(Path::new(name));
|
||||
(
|
||||
program.to_string_lossy().into_owned(),
|
||||
args.iter().map(|a| a.to_string_lossy().into_owned()).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_command_sh_runs_via_sh() {
|
||||
let (program, args) = dispatch("/ext/scripts/install.sh");
|
||||
assert_eq!(program, "sh");
|
||||
assert_eq!(args, vec!["/ext/scripts/install.sh".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_command_ps1_runs_via_powershell_with_file_flag() {
|
||||
let (program, args) = dispatch("/ext/scripts/setup.ps1");
|
||||
let expected_program = if cfg!(windows) { "powershell" } else { "pwsh" };
|
||||
assert_eq!(program, expected_program);
|
||||
// …-NoProfile -ExecutionPolicy Bypass -File <script>
|
||||
assert_eq!(args.first().map(String::as_str), Some("-NoProfile"));
|
||||
assert_eq!(args.get(1).map(String::as_str), Some("-ExecutionPolicy"));
|
||||
assert_eq!(args.get(2).map(String::as_str), Some("Bypass"));
|
||||
assert_eq!(args.get(3).map(String::as_str), Some("-File"));
|
||||
assert_eq!(args.get(4).map(String::as_str), Some("/ext/scripts/setup.ps1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_command_extension_is_case_insensitive() {
|
||||
// `.SH` must dispatch like `.sh`.
|
||||
let (program, _args) = dispatch("/ext/Install.SH");
|
||||
assert_eq!(program, "sh");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn hook_command_cmd_and_bare_run_via_cmd_on_windows() {
|
||||
for name in ["C:/ext/install.cmd", "C:/ext/install.bat", "C:/ext/install"] {
|
||||
let (program, args) = dispatch(name);
|
||||
assert_eq!(program, "cmd", "name={name}");
|
||||
assert_eq!(args.first().map(String::as_str), Some("/C"), "name={name}");
|
||||
assert_eq!(args.get(1).map(String::as_str), Some(name));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn hook_command_bare_runs_directly_on_unix() {
|
||||
// No extension → execute directly (rely on shebang), no interpreter arg.
|
||||
let (program, args) = dispatch("/ext/scripts/install");
|
||||
assert_eq!(program, "/ext/scripts/install");
|
||||
assert!(args.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// execute_hook (async unit tests)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_hook_script_not_found() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = execute_hook(dir.path(), "nonexistent.sh", HookKind::OnActivate, "test-ext").await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::HookNotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_hook_success() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let name = write_hook(dir.path(), "hook", "exit 0", "exit /b 0");
|
||||
|
||||
let result = execute_hook(dir.path(), &name, HookKind::OnActivate, "test-ext").await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_hook_nonzero_exit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let name = write_hook(
|
||||
dir.path(),
|
||||
"fail",
|
||||
"echo 'something broke' >&2\nexit 1",
|
||||
"echo something broke 1>&2 & exit /b 1",
|
||||
);
|
||||
|
||||
let result = execute_hook(dir.path(), &name, HookKind::OnInstall, "test-ext").await;
|
||||
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
ExtensionError::HookFailed {
|
||||
extension_name,
|
||||
hook,
|
||||
reason,
|
||||
} => {
|
||||
assert_eq!(extension_name, "test-ext");
|
||||
assert_eq!(hook, "onInstall");
|
||||
assert!(reason.contains("something broke"));
|
||||
}
|
||||
other => panic!("expected HookFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_hook_timeout() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// A long-enough-to-outlive-the-deadline hook on each platform. The 200ms
|
||||
// deadline below must elapse before the process completes (Err == timeout),
|
||||
// but `CmdBuilder::output()` blocks the executor (not cooperatively
|
||||
// cancellable), so `timeout` only reports `Elapsed` AFTER the child exits —
|
||||
// keep the sleep short so the test stays ~1s while still comfortably
|
||||
// exceeding 200ms (so a fast spawn-failure regression still surfaces as Ok).
|
||||
let name = write_hook(
|
||||
dir.path(),
|
||||
"slow",
|
||||
"sleep 1",
|
||||
"ping -n 2 127.0.0.1 >NUL",
|
||||
);
|
||||
let script = dir.path().join(&name);
|
||||
assert!(script.exists());
|
||||
|
||||
let (program, args) = hook_command(&script);
|
||||
let mut builder = CmdBuilder::clean_cli(&program);
|
||||
builder.args(&args);
|
||||
builder.current_dir(dir.path());
|
||||
|
||||
let result = tokio::time::timeout(std::time::Duration::from_millis(200), builder.output()).await;
|
||||
|
||||
// The deadline must elapse before the long-running process completes
|
||||
// (Err == timeout). On a fast Windows `CreateProcess` failure this
|
||||
// would instead resolve immediately and `result` would be Ok — which
|
||||
// is exactly the regression this guards against.
|
||||
assert!(result.is_err(), "should have timed out");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_hook_working_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let marker = dir.path().join("cwd_marker.txt");
|
||||
// print cwd to a file: unix `pwd`, cmd `cd` with no args prints cwd.
|
||||
let name = write_hook(
|
||||
dir.path(),
|
||||
"check_cwd",
|
||||
"pwd > cwd_marker.txt",
|
||||
"cd > cwd_marker.txt",
|
||||
);
|
||||
|
||||
let result = execute_hook(dir.path(), &name, HookKind::OnActivate, "test-ext").await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(marker.exists());
|
||||
let cwd_content = std::fs::read_to_string(&marker).unwrap();
|
||||
// The cwd written by the script should match the extension dir
|
||||
// (may have symlink resolution differences, compare canonical)
|
||||
let expected = dir.path().canonicalize().unwrap();
|
||||
let actual_trimmed = cwd_content.trim();
|
||||
let actual = Path::new(actual_trimmed).canonicalize().unwrap();
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::constants::{EXTENSION_API_VERSION, EXTENSION_MANIFEST_FILE, EXTENSIONS_DIR_NAME};
|
||||
use crate::manifest::parse_manifest_in_dir;
|
||||
use crate::types::{ExtensionSource, ExtensionState, LoadedExtension};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A scan path paired with its source classification.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScanPath {
|
||||
pub path: PathBuf,
|
||||
pub source: ExtensionSource,
|
||||
}
|
||||
|
||||
/// Resolve the default list of directories to scan for extensions.
|
||||
///
|
||||
/// Priority (highest first):
|
||||
/// 1. `$NOMIFUN_EXTENSIONS_PATH`
|
||||
/// 2. `~/.nomifun/extensions/` — legacy user data directory
|
||||
/// 3. Platform AppData directory
|
||||
///
|
||||
/// In E2E test mode (`NOMIFUN_E2E_TEST=1`), only the environment variable
|
||||
/// paths are returned to ensure test isolation.
|
||||
pub fn resolve_scan_paths() -> Vec<ScanPath> {
|
||||
let env_path = std::env::var("NOMIFUN_EXTENSIONS_PATH").ok();
|
||||
let e2e_mode = is_e2e_test_mode();
|
||||
resolve_scan_paths_inner(env_path.as_deref(), e2e_mode, None)
|
||||
}
|
||||
|
||||
/// Resolve scan paths using the historical Electron desktop rules for the
|
||||
/// provided `data_dir`.
|
||||
///
|
||||
/// Priority (highest first):
|
||||
/// 1. `$NOMIFUN_EXTENSIONS_PATH`
|
||||
/// 2. `<data_dir>/extensions`
|
||||
/// 3. Legacy appData sibling directory derived from `<data_dir>`
|
||||
///
|
||||
/// In E2E test mode (`NOMIFUN_E2E_TEST=1`), only the environment variable
|
||||
/// paths are returned to ensure test isolation.
|
||||
pub fn resolve_scan_paths_for_data_dir(data_dir: &Path) -> Vec<ScanPath> {
|
||||
let env_path = std::env::var("NOMIFUN_EXTENSIONS_PATH").ok();
|
||||
let e2e_mode = is_e2e_test_mode();
|
||||
resolve_scan_paths_inner(env_path.as_deref(), e2e_mode, Some(data_dir))
|
||||
}
|
||||
|
||||
/// Resolve the install target directory using the same priority order as
|
||||
/// `resolve_scan_paths_for_data_dir`.
|
||||
pub fn resolve_install_target_dir_for_data_dir(data_dir: &Path) -> PathBuf {
|
||||
resolve_scan_paths_for_data_dir(data_dir)
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|sp| sp.path)
|
||||
.unwrap_or_else(|| data_dir.join(EXTENSIONS_DIR_NAME))
|
||||
}
|
||||
|
||||
/// Inner implementation that accepts explicit parameters for testability.
|
||||
///
|
||||
/// Production callers should use [`resolve_scan_paths`] which reads from
|
||||
/// environment variables automatically.
|
||||
fn resolve_scan_paths_inner(
|
||||
env_extensions_path: Option<&str>,
|
||||
e2e_mode: bool,
|
||||
explicit_data_dir: Option<&Path>,
|
||||
) -> Vec<ScanPath> {
|
||||
let mut paths = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
let mut push = |path: PathBuf, source: ExtensionSource| {
|
||||
let normalized = path;
|
||||
if seen.insert(normalized.clone()) {
|
||||
paths.push(ScanPath {
|
||||
path: normalized,
|
||||
source,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Environment variable paths (highest priority).
|
||||
if let Some(env_paths) = env_extensions_path {
|
||||
for path in std::env::split_paths(env_paths) {
|
||||
if !path.as_os_str().is_empty() {
|
||||
push(path, ExtensionSource::Env);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// E2E test mode: only scan env var paths for isolation.
|
||||
if e2e_mode {
|
||||
return paths;
|
||||
}
|
||||
|
||||
// 2. User data directory (desktop data dir or historical ~/.nomifun fallback).
|
||||
if let Some(data_dir) = explicit_data_dir {
|
||||
push(data_dir.join(EXTENSIONS_DIR_NAME), ExtensionSource::Local);
|
||||
if let Some(appdata_dir) = derive_legacy_appdata_extensions_dir(data_dir) {
|
||||
push(appdata_dir, ExtensionSource::Appdata);
|
||||
}
|
||||
} else {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
push(home.join(".nomifun").join(EXTENSIONS_DIR_NAME), ExtensionSource::Local);
|
||||
}
|
||||
|
||||
// 3. AppData directory (platform-specific).
|
||||
if let Some(data_dir) = dirs::data_dir() {
|
||||
push(
|
||||
data_dir.join("nomifun").join(EXTENSIONS_DIR_NAME),
|
||||
ExtensionSource::Appdata,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn derive_legacy_appdata_extensions_dir(data_dir: &Path) -> Option<PathBuf> {
|
||||
let resolved = std::fs::canonicalize(data_dir).unwrap_or_else(|_| data_dir.to_path_buf());
|
||||
let leaf = resolved.file_name()?.to_str()?;
|
||||
if leaf != "nomifun" {
|
||||
return None;
|
||||
}
|
||||
Some(resolved.parent()?.join(EXTENSIONS_DIR_NAME))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extension loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Scan all provided directories and load valid extension manifests.
|
||||
///
|
||||
/// When the same extension name appears in multiple scan paths, the first
|
||||
/// occurrence wins (earlier entries have higher priority).
|
||||
pub fn load_all(scan_paths: &[ScanPath]) -> Vec<LoadedExtension> {
|
||||
let mut seen: HashMap<String, usize> = HashMap::new();
|
||||
let mut result: Vec<LoadedExtension> = Vec::new();
|
||||
|
||||
for sp in scan_paths {
|
||||
let loaded = scan_directory(&sp.path, sp.source);
|
||||
for ext in loaded {
|
||||
let name = ext.manifest.name.clone();
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(name.clone()) {
|
||||
e.insert(result.len());
|
||||
result.push(ext);
|
||||
} else {
|
||||
debug!(
|
||||
name = %name,
|
||||
skipped_path = %sp.path.display(),
|
||||
"skipping duplicate extension (higher-priority copy already loaded)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Scan a single directory for extension subdirectories containing a
|
||||
/// valid manifest file.
|
||||
fn scan_directory(dir: &Path, source: ExtensionSource) -> Vec<LoadedExtension> {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
warn!(dir = %dir.display(), error = %e, "failed to read extensions directory");
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let mut loaded = Vec::new();
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let entry_path = entry.path();
|
||||
if !entry_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let manifest_path = entry_path.join(EXTENSION_MANIFEST_FILE);
|
||||
match load_single_extension(&manifest_path, &entry_path, source) {
|
||||
Ok(ext) => {
|
||||
debug!(name = %ext.manifest.name, dir = %entry_path.display(), "loaded extension");
|
||||
loaded.push(ext);
|
||||
}
|
||||
Err(e) => {
|
||||
// Skip extensions with invalid manifests but continue loading others.
|
||||
warn!(
|
||||
dir = %entry_path.display(),
|
||||
error = %e,
|
||||
"skipping extension with invalid manifest"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loaded
|
||||
}
|
||||
|
||||
/// Load a single extension from its manifest file.
|
||||
fn load_single_extension(
|
||||
manifest_path: &Path,
|
||||
ext_dir: &Path,
|
||||
source: ExtensionSource,
|
||||
) -> Result<LoadedExtension, crate::error::ExtensionError> {
|
||||
let bytes = std::fs::read(manifest_path)?;
|
||||
let manifest = parse_manifest_in_dir(&bytes, ext_dir)?;
|
||||
|
||||
let state = ExtensionState {
|
||||
name: manifest.name.clone(),
|
||||
version: manifest.version.clone(),
|
||||
enabled: true,
|
||||
installed_at: None,
|
||||
last_activated_at: None,
|
||||
};
|
||||
|
||||
let directory = ext_dir.to_str().unwrap_or_default().to_owned();
|
||||
|
||||
Ok(LoadedExtension {
|
||||
manifest,
|
||||
directory,
|
||||
source,
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine compatibility filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Filter extensions by engine and API version compatibility.
|
||||
///
|
||||
/// Extensions that declare `engine.nomifun` with a version range incompatible
|
||||
/// with `app_version` are excluded. Extensions whose `apiVersion` is
|
||||
/// incompatible with the supported [`EXTENSION_API_VERSION`] are also excluded.
|
||||
///
|
||||
/// Incompatible extensions are logged as warnings but do not cause errors.
|
||||
pub fn filter_by_engine_compatibility(extensions: Vec<LoadedExtension>, app_version: &str) -> Vec<LoadedExtension> {
|
||||
let Ok(app_ver) = semver::Version::parse(app_version) else {
|
||||
warn!(
|
||||
app_version = %app_version,
|
||||
"invalid app version — skipping engine compatibility filter"
|
||||
);
|
||||
return extensions;
|
||||
};
|
||||
|
||||
extensions
|
||||
.into_iter()
|
||||
.filter(|ext| is_engine_compatible(ext, &app_ver) && is_api_version_compatible(ext))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check whether the extension's `engine.nomifun` requirement is satisfied.
|
||||
fn is_engine_compatible(ext: &LoadedExtension, app_version: &semver::Version) -> bool {
|
||||
let Some(engine) = &ext.manifest.engine else {
|
||||
return true; // no engine constraint
|
||||
};
|
||||
let Some(required) = &engine.nomifun else {
|
||||
return true; // no nomifun constraint
|
||||
};
|
||||
|
||||
match semver::VersionReq::parse(required) {
|
||||
Ok(req) if req.matches(app_version) => true,
|
||||
Ok(_) => {
|
||||
warn!(
|
||||
name = %ext.manifest.name,
|
||||
required = %required,
|
||||
actual = %app_version,
|
||||
"extension filtered out: engine.nomifun incompatible"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
name = %ext.manifest.name,
|
||||
required = %required,
|
||||
error = %e,
|
||||
"extension filtered out: invalid engine.nomifun version requirement"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the extension's `apiVersion` is compatible with the
|
||||
/// supported API version.
|
||||
fn is_api_version_compatible(ext: &LoadedExtension) -> bool {
|
||||
let Some(api_ver_str) = &ext.manifest.api_version else {
|
||||
return true; // no API version constraint
|
||||
};
|
||||
|
||||
let Ok(declared) = semver::Version::parse(api_ver_str) else {
|
||||
warn!(
|
||||
name = %ext.manifest.name,
|
||||
api_version = %api_ver_str,
|
||||
"extension filtered out: invalid apiVersion"
|
||||
);
|
||||
return false;
|
||||
};
|
||||
|
||||
let Ok(supported) = semver::Version::parse(EXTENSION_API_VERSION) else {
|
||||
return true; // defensive — should never happen with a valid constant
|
||||
};
|
||||
|
||||
// Compatible if major versions match and declared <= supported.
|
||||
if declared.major == supported.major && declared <= supported {
|
||||
true
|
||||
} else {
|
||||
warn!(
|
||||
name = %ext.manifest.name,
|
||||
declared = %declared,
|
||||
supported = %supported,
|
||||
"extension filtered out: apiVersion incompatible"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn is_e2e_test_mode() -> bool {
|
||||
std::env::var("NOMIFUN_E2E_TEST").map(|v| v == "1").unwrap_or(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{EngineConfig, ExtensionManifest};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Helper: create a minimal valid manifest JSON.
|
||||
fn write_manifest(dir: &Path, name: &str, version: &str) {
|
||||
write_manifest_full(dir, name, version, None, None);
|
||||
}
|
||||
|
||||
/// Helper: create a manifest with optional engine and apiVersion fields.
|
||||
fn write_manifest_full(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
version: &str,
|
||||
engine_nomifun: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
) {
|
||||
let mut manifest = serde_json::json!({
|
||||
"name": name,
|
||||
"version": version,
|
||||
});
|
||||
if let Some(eng) = engine_nomifun {
|
||||
manifest["engine"] = serde_json::json!({ "nomifun": eng });
|
||||
}
|
||||
if let Some(api) = api_version {
|
||||
manifest["apiVersion"] = serde_json::json!(api);
|
||||
}
|
||||
let manifest_path = dir.join(EXTENSION_MANIFEST_FILE);
|
||||
fs::write(manifest_path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
// -- scan_directory -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn scan_empty_directory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = scan_directory(tmp.path(), ExtensionSource::Local);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_nonexistent_directory() {
|
||||
let result = scan_directory(Path::new("/nonexistent/path"), ExtensionSource::Local);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_loads_valid_extension() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ext_dir = tmp.path().join("my-ext");
|
||||
fs::create_dir(&ext_dir).unwrap();
|
||||
write_manifest(&ext_dir, "my-ext", "1.0.0");
|
||||
|
||||
let result = scan_directory(tmp.path(), ExtensionSource::Local);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].manifest.name, "my-ext");
|
||||
assert_eq!(result[0].manifest.version, "1.0.0");
|
||||
assert_eq!(result[0].source, ExtensionSource::Local);
|
||||
assert!(result[0].state.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_loads_nomifun_main_contract_extension() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ext_dir = tmp.path().join("legacy-ext");
|
||||
fs::create_dir(&ext_dir).unwrap();
|
||||
fs::create_dir(ext_dir.join("contributes")).unwrap();
|
||||
|
||||
fs::write(
|
||||
ext_dir.join("contributes/settings-tabs.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!([
|
||||
{
|
||||
"id": "legacy-settings",
|
||||
"name": "Legacy Settings",
|
||||
"entryPoint": "settings/legacy.html",
|
||||
"position": { "anchor": "display", "placement": "after" }
|
||||
}
|
||||
]))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::write(
|
||||
ext_dir.join(EXTENSION_MANIFEST_FILE),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"name": "legacy-ext",
|
||||
"displayName": "Legacy Extension",
|
||||
"version": "1.0.0",
|
||||
"i18n": {
|
||||
"localesDir": "i18n",
|
||||
"defaultLocale": "en-US"
|
||||
},
|
||||
"contributes": {
|
||||
"settingsTabs": "$file:contributes/settings-tabs.json",
|
||||
"webui": {
|
||||
"apiRoutes": [
|
||||
{
|
||||
"path": "/legacy-ext/collect",
|
||||
"entryPoint": "webui/collector.js"
|
||||
}
|
||||
],
|
||||
"staticAssets": [
|
||||
{
|
||||
"urlPrefix": "/legacy-ext/assets",
|
||||
"directory": "assets"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = scan_directory(tmp.path(), ExtensionSource::Local);
|
||||
assert_eq!(result.len(), 1);
|
||||
let manifest = &result[0].manifest;
|
||||
assert_eq!(manifest.display_name.as_deref(), Some("Legacy Extension"));
|
||||
assert_eq!(manifest.i18n.as_ref().unwrap().locales, vec!["en-US".to_owned()]);
|
||||
assert_eq!(manifest.contributes.as_ref().unwrap().settings_tabs.len(), 1);
|
||||
assert_eq!(manifest.contributes.as_ref().unwrap().webui.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_skips_invalid_manifest() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// Valid extension
|
||||
let good_dir = tmp.path().join("good-ext");
|
||||
fs::create_dir(&good_dir).unwrap();
|
||||
write_manifest(&good_dir, "good-ext", "1.0.0");
|
||||
|
||||
// Invalid extension (bad JSON)
|
||||
let bad_dir = tmp.path().join("bad-ext");
|
||||
fs::create_dir(&bad_dir).unwrap();
|
||||
fs::write(bad_dir.join(EXTENSION_MANIFEST_FILE), b"not valid json").unwrap();
|
||||
|
||||
let result = scan_directory(tmp.path(), ExtensionSource::Env);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].manifest.name, "good-ext");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_skips_directories_without_manifest() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ext_dir = tmp.path().join("no-manifest");
|
||||
fs::create_dir(&ext_dir).unwrap();
|
||||
fs::write(ext_dir.join("README.md"), b"hello").unwrap();
|
||||
|
||||
let result = scan_directory(tmp.path(), ExtensionSource::Local);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_skips_files_not_directories() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("not-a-dir.txt"), b"hello").unwrap();
|
||||
|
||||
let result = scan_directory(tmp.path(), ExtensionSource::Local);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
// -- load_all -------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn load_all_deduplicates_by_name() {
|
||||
let tmp1 = TempDir::new().unwrap();
|
||||
let tmp2 = TempDir::new().unwrap();
|
||||
|
||||
// Same extension name in two directories
|
||||
let ext1 = tmp1.path().join("my-ext");
|
||||
fs::create_dir(&ext1).unwrap();
|
||||
write_manifest(&ext1, "my-ext", "1.0.0");
|
||||
|
||||
let ext2 = tmp2.path().join("my-ext");
|
||||
fs::create_dir(&ext2).unwrap();
|
||||
write_manifest(&ext2, "my-ext", "2.0.0");
|
||||
|
||||
let scan_paths = vec![
|
||||
ScanPath {
|
||||
path: tmp1.path().to_path_buf(),
|
||||
source: ExtensionSource::Env,
|
||||
},
|
||||
ScanPath {
|
||||
path: tmp2.path().to_path_buf(),
|
||||
source: ExtensionSource::Local,
|
||||
},
|
||||
];
|
||||
|
||||
let result = load_all(&scan_paths);
|
||||
assert_eq!(result.len(), 1);
|
||||
// First occurrence wins (higher priority).
|
||||
assert_eq!(result[0].manifest.version, "1.0.0");
|
||||
assert_eq!(result[0].source, ExtensionSource::Env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_all_from_multiple_directories() {
|
||||
let tmp1 = TempDir::new().unwrap();
|
||||
let tmp2 = TempDir::new().unwrap();
|
||||
|
||||
let ext1 = tmp1.path().join("ext-a");
|
||||
fs::create_dir(&ext1).unwrap();
|
||||
write_manifest(&ext1, "ext-a", "1.0.0");
|
||||
|
||||
let ext2 = tmp2.path().join("ext-b");
|
||||
fs::create_dir(&ext2).unwrap();
|
||||
write_manifest(&ext2, "ext-b", "1.0.0");
|
||||
|
||||
let scan_paths = vec![
|
||||
ScanPath {
|
||||
path: tmp1.path().to_path_buf(),
|
||||
source: ExtensionSource::Env,
|
||||
},
|
||||
ScanPath {
|
||||
path: tmp2.path().to_path_buf(),
|
||||
source: ExtensionSource::Local,
|
||||
},
|
||||
];
|
||||
|
||||
let result = load_all(&scan_paths);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_all_empty_paths() {
|
||||
let result = load_all(&[]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
// -- filter_by_engine_compatibility ----------------------------------------
|
||||
|
||||
fn make_loaded_ext(
|
||||
name: &str,
|
||||
version: &str,
|
||||
engine_nomifun: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
) -> LoadedExtension {
|
||||
LoadedExtension {
|
||||
manifest: ExtensionManifest {
|
||||
name: name.to_string(),
|
||||
version: version.to_string(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
license: None,
|
||||
homepage: None,
|
||||
icon: None,
|
||||
engine: engine_nomifun.map(|v| EngineConfig {
|
||||
nomifun: Some(v.to_string()),
|
||||
}),
|
||||
api_version: api_version.map(|v| v.to_string()),
|
||||
dependencies: HashMap::new(),
|
||||
entry_point: None,
|
||||
permissions: None,
|
||||
contributes: None,
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
},
|
||||
directory: format!("/test/{name}"),
|
||||
source: ExtensionSource::Local,
|
||||
state: ExtensionState {
|
||||
name: name.to_string(),
|
||||
version: version.to_string(),
|
||||
enabled: true,
|
||||
installed_at: None,
|
||||
last_activated_at: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_keeps_compatible_engine() {
|
||||
let exts = vec![make_loaded_ext("ext-a", "1.0.0", Some("^1.0.0"), None)];
|
||||
let filtered = filter_by_engine_compatibility(exts, "1.5.0");
|
||||
assert_eq!(filtered.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_removes_incompatible_engine() {
|
||||
let exts = vec![make_loaded_ext("ext-a", "1.0.0", Some("^2.0.0"), None)];
|
||||
let filtered = filter_by_engine_compatibility(exts, "1.5.0");
|
||||
assert!(filtered.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_keeps_no_engine_constraint() {
|
||||
let exts = vec![make_loaded_ext("ext-a", "1.0.0", None, None)];
|
||||
let filtered = filter_by_engine_compatibility(exts, "1.5.0");
|
||||
assert_eq!(filtered.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_keeps_compatible_api_version() {
|
||||
let exts = vec![make_loaded_ext("ext-a", "1.0.0", None, Some("1.0.0"))];
|
||||
let filtered = filter_by_engine_compatibility(exts, "1.0.0");
|
||||
assert_eq!(filtered.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_removes_incompatible_api_version() {
|
||||
// Extension requires API 2.0.0 but we support 1.0.0
|
||||
let exts = vec![make_loaded_ext("ext-a", "1.0.0", None, Some("2.0.0"))];
|
||||
let filtered = filter_by_engine_compatibility(exts, "1.0.0");
|
||||
assert!(filtered.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_removes_invalid_engine_requirement() {
|
||||
let exts = vec![make_loaded_ext("ext-a", "1.0.0", Some("not-valid-semver-req"), None)];
|
||||
let filtered = filter_by_engine_compatibility(exts, "1.0.0");
|
||||
assert!(filtered.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_keeps_all_with_invalid_app_version() {
|
||||
// If the app version itself is invalid, skip filtering entirely.
|
||||
let exts = vec![make_loaded_ext("ext-a", "1.0.0", Some("^2.0.0"), None)];
|
||||
let filtered = filter_by_engine_compatibility(exts, "not-semver");
|
||||
assert_eq!(filtered.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_mixed_compatible_and_incompatible() {
|
||||
let exts = vec![
|
||||
make_loaded_ext("compatible", "1.0.0", Some("^1.0.0"), Some("1.0.0")),
|
||||
make_loaded_ext("bad-engine", "1.0.0", Some("^3.0.0"), None),
|
||||
make_loaded_ext("bad-api", "1.0.0", None, Some("2.0.0")),
|
||||
make_loaded_ext("no-constraint", "1.0.0", None, None),
|
||||
];
|
||||
let filtered = filter_by_engine_compatibility(exts, "1.5.0");
|
||||
assert_eq!(filtered.len(), 2);
|
||||
assert_eq!(filtered[0].manifest.name, "compatible");
|
||||
assert_eq!(filtered[1].manifest.name, "no-constraint");
|
||||
}
|
||||
|
||||
// -- resolve_scan_paths_inner ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn resolve_scan_paths_includes_env_paths() {
|
||||
let paths = resolve_scan_paths_inner(Some("/tmp/test-exts"), false, None);
|
||||
assert!(
|
||||
paths
|
||||
.iter()
|
||||
.any(|sp| sp.path.as_path() == Path::new("/tmp/test-exts") && sp.source == ExtensionSource::Env)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_scan_paths_e2e_mode_only_env() {
|
||||
let paths = resolve_scan_paths_inner(Some("/tmp/e2e-exts"), true, None);
|
||||
assert!(paths.iter().all(|sp| sp.source == ExtensionSource::Env));
|
||||
assert!(paths.iter().any(|sp| sp.path.as_path() == Path::new("/tmp/e2e-exts")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_scan_paths_no_env_includes_platform_dirs() {
|
||||
let paths = resolve_scan_paths_inner(None, false, None);
|
||||
// Should have at least one platform dir (home or appdata).
|
||||
assert!(
|
||||
paths
|
||||
.iter()
|
||||
.any(|sp| sp.source == ExtensionSource::Local || sp.source == ExtensionSource::Appdata)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_scan_paths_e2e_no_env_returns_empty() {
|
||||
let paths = resolve_scan_paths_inner(None, true, None);
|
||||
assert!(paths.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_scan_paths_for_data_dir_prefers_env_then_data_dir_then_appdata() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let app_root = tmp.path().join("Nomi-Dev");
|
||||
let data_dir = app_root.join("nomifun");
|
||||
std::fs::create_dir_all(&data_dir).unwrap();
|
||||
let canonical_app_root = std::fs::canonicalize(&app_root).unwrap();
|
||||
|
||||
let paths = resolve_scan_paths_inner(Some("/tmp/env-exts"), false, Some(&data_dir));
|
||||
assert_eq!(paths[0].path, PathBuf::from("/tmp/env-exts"));
|
||||
assert_eq!(paths[0].source, ExtensionSource::Env);
|
||||
assert_eq!(paths[1].path, data_dir.join(EXTENSIONS_DIR_NAME));
|
||||
assert_eq!(paths[1].source, ExtensionSource::Local);
|
||||
assert_eq!(paths[2].path, canonical_app_root.join(EXTENSIONS_DIR_NAME));
|
||||
assert_eq!(paths[2].source, ExtensionSource::Appdata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_scan_paths_for_data_dir_deduplicates_local_and_appdata() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let data_dir = tmp.path().join("plain-data");
|
||||
std::fs::create_dir_all(&data_dir).unwrap();
|
||||
|
||||
let paths = resolve_scan_paths_inner(None, false, Some(&data_dir));
|
||||
assert_eq!(paths.len(), 1);
|
||||
assert_eq!(paths[0].path, data_dir.join(EXTENSIONS_DIR_NAME));
|
||||
assert_eq!(paths[0].source, ExtensionSource::Local);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
use crate::constants::RESERVED_NAME_PREFIXES;
|
||||
use crate::error::ExtensionError;
|
||||
use crate::types::ExtensionManifest;
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Validate an extension manifest for required fields, name format, and version format.
|
||||
pub fn validate_manifest(manifest: &ExtensionManifest) -> Result<(), ExtensionError> {
|
||||
validate_name(&manifest.name)?;
|
||||
validate_version(&manifest.version)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reject extension names that use reserved prefixes.
|
||||
fn validate_name(name: &str) -> Result<(), ExtensionError> {
|
||||
if name.is_empty() {
|
||||
return Err(ExtensionError::ManifestValidation(
|
||||
"extension name must not be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let lower = name.to_lowercase();
|
||||
for prefix in RESERVED_NAME_PREFIXES {
|
||||
if lower.starts_with(prefix) {
|
||||
return Err(ExtensionError::ReservedNamePrefix {
|
||||
name: name.to_owned(),
|
||||
prefix: (*prefix).to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that the version string is valid semver.
|
||||
fn validate_version(version: &str) -> Result<(), ExtensionError> {
|
||||
if version.is_empty() {
|
||||
return Err(ExtensionError::ManifestValidation(
|
||||
"extension version must not be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
semver::Version::parse(version).map_err(|e| ExtensionError::InvalidVersion {
|
||||
version: version.to_owned(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse and validate a manifest from JSON bytes.
|
||||
pub fn parse_manifest(json_bytes: &[u8]) -> Result<ExtensionManifest, ExtensionError> {
|
||||
parse_manifest_inner(json_bytes, None)
|
||||
}
|
||||
|
||||
/// Parse and validate a manifest from JSON bytes, resolving legacy `$file:`
|
||||
/// references relative to the extension directory before deserialization.
|
||||
pub fn parse_manifest_in_dir(json_bytes: &[u8], extension_dir: &Path) -> Result<ExtensionManifest, ExtensionError> {
|
||||
parse_manifest_inner(json_bytes, Some(extension_dir))
|
||||
}
|
||||
|
||||
fn parse_manifest_inner(json_bytes: &[u8], extension_dir: Option<&Path>) -> Result<ExtensionManifest, ExtensionError> {
|
||||
let mut manifest_json: Value = serde_json::from_slice(json_bytes)?;
|
||||
if let Some(dir) = extension_dir {
|
||||
let mut visited = HashSet::new();
|
||||
manifest_json = resolve_file_refs(manifest_json, dir, &mut visited)?;
|
||||
}
|
||||
normalize_manifest_json(&mut manifest_json);
|
||||
let manifest: ExtensionManifest = serde_json::from_value(manifest_json)?;
|
||||
validate_manifest(&manifest)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn resolve_file_refs(
|
||||
value: Value,
|
||||
extension_dir: &Path,
|
||||
visited: &mut HashSet<PathBuf>,
|
||||
) -> Result<Value, ExtensionError> {
|
||||
match value {
|
||||
Value::String(text) if is_file_ref(&text) => resolve_file_ref_value(&text, extension_dir, visited),
|
||||
Value::Array(values) => {
|
||||
let mut resolved = Vec::with_capacity(values.len());
|
||||
for item in values {
|
||||
resolved.push(resolve_file_refs(item, extension_dir, visited)?);
|
||||
}
|
||||
Ok(Value::Array(resolved))
|
||||
}
|
||||
Value::Object(map) => {
|
||||
let mut resolved = Map::with_capacity(map.len());
|
||||
for (key, value) in map {
|
||||
resolved.insert(key, resolve_file_refs(value, extension_dir, visited)?);
|
||||
}
|
||||
Ok(Value::Object(resolved))
|
||||
}
|
||||
other => Ok(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_file_ref(value: &str) -> bool {
|
||||
value.starts_with("$file:")
|
||||
}
|
||||
|
||||
fn resolve_file_ref_value(
|
||||
reference: &str,
|
||||
extension_dir: &Path,
|
||||
visited: &mut HashSet<PathBuf>,
|
||||
) -> Result<Value, ExtensionError> {
|
||||
let relative = reference.trim_start_matches("$file:").trim();
|
||||
let absolute = extension_dir.join(relative);
|
||||
let canonical_base = std::fs::canonicalize(extension_dir)?;
|
||||
let canonical_path =
|
||||
std::fs::canonicalize(&absolute).map_err(|_| ExtensionError::FileReferenceNotFound(relative.to_owned()))?;
|
||||
|
||||
if !canonical_path.starts_with(&canonical_base) {
|
||||
return Err(ExtensionError::PathTraversal(relative.to_owned()));
|
||||
}
|
||||
|
||||
if !visited.insert(canonical_path.clone()) {
|
||||
return Err(ExtensionError::ManifestValidation(format!(
|
||||
"circular $file reference detected: {relative}"
|
||||
)));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&canonical_path)?;
|
||||
let resolved = match canonical_path.extension().and_then(|ext| ext.to_str()) {
|
||||
Some("json") | Some("jsonc") | Some("json5") => {
|
||||
let parsed: Value = serde_json::from_str(&content)?;
|
||||
resolve_file_refs(parsed, extension_dir, visited)?
|
||||
}
|
||||
_ => Value::String(content.trim_end_matches('\n').to_owned()),
|
||||
};
|
||||
|
||||
visited.remove(&canonical_path);
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn normalize_manifest_json(value: &mut Value) {
|
||||
let Some(root) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
move_key(root, "displayName", "display_name");
|
||||
move_key(root, "apiVersion", "api_version");
|
||||
move_key(root, "entryPoint", "entry_point");
|
||||
|
||||
if let Some(i18n) = root.get_mut("i18n") {
|
||||
normalize_i18n(i18n);
|
||||
}
|
||||
|
||||
if let Some(lifecycle) = root.get_mut("lifecycle") {
|
||||
normalize_lifecycle(lifecycle);
|
||||
}
|
||||
|
||||
if let Some(contributes) = root.get_mut("contributes") {
|
||||
normalize_contributes(contributes);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_i18n(value: &mut Value) {
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
move_key(obj, "localesDir", "directory");
|
||||
if !obj.contains_key("locales") {
|
||||
if let Some(default_locale) = obj.remove("defaultLocale") {
|
||||
obj.insert("locales".into(), Value::Array(vec![default_locale]));
|
||||
}
|
||||
} else {
|
||||
obj.remove("defaultLocale");
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_lifecycle(value: &mut Value) {
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
move_key(obj, "onInstall", "on_install");
|
||||
move_key(obj, "onUninstall", "on_uninstall");
|
||||
move_key(obj, "onActivate", "on_activate");
|
||||
move_key(obj, "onDeactivate", "on_deactivate");
|
||||
}
|
||||
|
||||
fn normalize_contributes(value: &mut Value) {
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
move_key(obj, "acpAdapters", "acp_adapters");
|
||||
move_key(obj, "mcpServers", "mcp_servers");
|
||||
move_key(obj, "channelPlugins", "channel_plugins");
|
||||
move_key(obj, "settingsTabs", "settings_tabs");
|
||||
move_key(obj, "modelProviders", "model_providers");
|
||||
|
||||
normalize_array_entries(obj.get_mut("acp_adapters"), normalize_acp_adapter);
|
||||
normalize_array_entries(obj.get_mut("mcp_servers"), normalize_mcp_server);
|
||||
normalize_array_entries(obj.get_mut("assistants"), normalize_assistant);
|
||||
normalize_array_entries(obj.get_mut("agents"), normalize_agent);
|
||||
normalize_array_entries(obj.get_mut("skills"), normalize_skill);
|
||||
normalize_array_entries(obj.get_mut("channel_plugins"), normalize_channel_plugin);
|
||||
normalize_array_entries(obj.get_mut("themes"), normalize_theme);
|
||||
normalize_array_entries(obj.get_mut("settings_tabs"), normalize_settings_tab);
|
||||
normalize_array_entries(obj.get_mut("model_providers"), normalize_model_provider);
|
||||
|
||||
if let Some(webui) = obj.get_mut("webui") {
|
||||
normalize_webui(webui);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_array_entries(value: Option<&mut Value>, normalize_item: fn(&mut Value)) {
|
||||
let Some(Value::Array(items)) = value else {
|
||||
return;
|
||||
};
|
||||
|
||||
for item in items {
|
||||
normalize_item(item);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_acp_adapter(value: &mut Value) {
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
move_key(obj, "cliCommand", "cli_command");
|
||||
move_key(obj, "defaultCliPath", "default_cli_path");
|
||||
move_key(obj, "acpArgs", "acp_args");
|
||||
move_key(obj, "authRequired", "auth_required");
|
||||
move_key(obj, "supportsStreaming", "supports_streaming");
|
||||
move_key(obj, "connectionType", "connection_type");
|
||||
move_key(obj, "apiKeyFields", "api_key_fields");
|
||||
move_key(obj, "yoloMode", "yolo_mode");
|
||||
move_key(obj, "healthCheck", "health_check");
|
||||
move_key(obj, "icon", "avatar");
|
||||
}
|
||||
|
||||
fn normalize_mcp_server(value: &mut Value) {
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !obj.contains_key("id")
|
||||
&& let Some(Value::String(name)) = obj.get("name")
|
||||
{
|
||||
obj.insert("id".into(), Value::String(name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_assistant(value: &mut Value) {
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
move_key(obj, "avatar", "icon");
|
||||
move_key(obj, "systemPrompt", "system_prompt");
|
||||
if !obj.contains_key("context") {
|
||||
if let Some(Value::String(path)) = obj.remove("contextFile") {
|
||||
obj.insert("context".into(), Value::String(format!("@file:{path}")));
|
||||
}
|
||||
} else {
|
||||
obj.remove("contextFile");
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_agent(value: &mut Value) {
|
||||
normalize_assistant(value);
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
move_key(obj, "presetAgentType", "agent_type");
|
||||
}
|
||||
|
||||
fn normalize_skill(value: &mut Value) {
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
move_key(obj, "file", "path");
|
||||
}
|
||||
|
||||
fn normalize_channel_plugin(value: &mut Value) {
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !obj.contains_key("id") {
|
||||
move_key(obj, "type", "id");
|
||||
}
|
||||
move_key(obj, "entryPoint", "entry_point");
|
||||
}
|
||||
|
||||
fn normalize_theme(value: &mut Value) {
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
move_key(obj, "file", "css_file");
|
||||
move_key(obj, "cover", "cover_image");
|
||||
}
|
||||
|
||||
fn normalize_settings_tab(value: &mut Value) {
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
move_key(obj, "name", "label");
|
||||
move_key(obj, "entryPoint", "url");
|
||||
|
||||
if let Some(position) = obj.get_mut("position").and_then(Value::as_object_mut) {
|
||||
move_key(position, "anchor", "relativeTo");
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_model_provider(value: &mut Value) {
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
move_key(obj, "baseUrl", "base_url");
|
||||
move_key(obj, "platform", "protocol");
|
||||
}
|
||||
|
||||
fn normalize_webui(value: &mut Value) {
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if obj.contains_key("directory") && obj.contains_key("routes") {
|
||||
return;
|
||||
}
|
||||
|
||||
let api_routes = obj
|
||||
.remove("apiRoutes")
|
||||
.or_else(|| obj.remove("api_routes"))
|
||||
.and_then(|value| value.as_array().cloned())
|
||||
.unwrap_or_default();
|
||||
let static_assets = obj
|
||||
.remove("staticAssets")
|
||||
.or_else(|| obj.remove("static_assets"))
|
||||
.and_then(|value| value.as_array().cloned())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut webui_entries = Vec::new();
|
||||
|
||||
if !api_routes.is_empty() {
|
||||
let routes = api_routes
|
||||
.into_iter()
|
||||
.map(|mut route| {
|
||||
if let Some(route_obj) = route.as_object_mut() {
|
||||
move_key(route_obj, "entryPoint", "handler");
|
||||
route_obj.entry("method").or_insert_with(|| Value::String("GET".into()));
|
||||
}
|
||||
route
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
webui_entries.push(Value::Object(Map::from_iter([
|
||||
("id".into(), Value::String("legacy-webui-routes".into())),
|
||||
("directory".into(), Value::String(".".into())),
|
||||
("routes".into(), Value::Array(routes)),
|
||||
])));
|
||||
}
|
||||
|
||||
for (index, asset) in static_assets.into_iter().enumerate() {
|
||||
let Some(asset_obj) = asset.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let directory = asset_obj
|
||||
.get("directory")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Value::String(".".into()));
|
||||
let id = asset_obj
|
||||
.get("urlPrefix")
|
||||
.or_else(|| asset_obj.get("url_prefix"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|prefix| prefix.trim_matches('/').replace(['/', '.', '_'], "-"))
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| format!("legacy-webui-assets-{index}"));
|
||||
|
||||
webui_entries.push(Value::Object(Map::from_iter([
|
||||
("id".into(), Value::String(id)),
|
||||
("directory".into(), directory),
|
||||
("routes".into(), Value::Array(Vec::new())),
|
||||
])));
|
||||
}
|
||||
|
||||
*value = Value::Array(webui_entries);
|
||||
}
|
||||
|
||||
fn move_key(map: &mut Map<String, Value>, old_key: &str, new_key: &str) {
|
||||
if map.contains_key(new_key) {
|
||||
map.remove(old_key);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(value) = map.remove(old_key) {
|
||||
map.insert(new_key.to_owned(), value);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// -- validate_manifest --
|
||||
|
||||
#[test]
|
||||
fn test_valid_manifest() {
|
||||
let manifest = ExtensionManifest {
|
||||
name: "my-cool-ext".into(),
|
||||
version: "1.0.0".into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
license: None,
|
||||
homepage: None,
|
||||
icon: None,
|
||||
engine: None,
|
||||
api_version: None,
|
||||
dependencies: Default::default(),
|
||||
entry_point: None,
|
||||
permissions: None,
|
||||
contributes: None,
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
};
|
||||
assert!(validate_manifest(&manifest).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_name_rejected() {
|
||||
let manifest = ExtensionManifest {
|
||||
name: "".into(),
|
||||
version: "1.0.0".into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
license: None,
|
||||
homepage: None,
|
||||
icon: None,
|
||||
engine: None,
|
||||
api_version: None,
|
||||
dependencies: Default::default(),
|
||||
entry_point: None,
|
||||
permissions: None,
|
||||
contributes: None,
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
};
|
||||
let err = validate_manifest(&manifest).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::ManifestValidation(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reserved_prefix_nomi() {
|
||||
let manifest = ExtensionManifest {
|
||||
name: "nomi-my-ext".into(),
|
||||
version: "1.0.0".into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
license: None,
|
||||
homepage: None,
|
||||
icon: None,
|
||||
engine: None,
|
||||
api_version: None,
|
||||
dependencies: Default::default(),
|
||||
entry_point: None,
|
||||
permissions: None,
|
||||
contributes: None,
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
};
|
||||
let err = validate_manifest(&manifest).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ExtensionError::ReservedNamePrefix { ref prefix, .. } if prefix == "nomi-"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_reserved_prefixes_rejected() {
|
||||
for prefix in RESERVED_NAME_PREFIXES {
|
||||
let name = format!("{prefix}test");
|
||||
let manifest = ExtensionManifest {
|
||||
name,
|
||||
version: "1.0.0".into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
license: None,
|
||||
homepage: None,
|
||||
icon: None,
|
||||
engine: None,
|
||||
api_version: None,
|
||||
dependencies: Default::default(),
|
||||
entry_point: None,
|
||||
permissions: None,
|
||||
contributes: None,
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
};
|
||||
assert!(
|
||||
validate_manifest(&manifest).is_err(),
|
||||
"prefix '{prefix}' should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reserved_prefix_case_insensitive() {
|
||||
let manifest = ExtensionManifest {
|
||||
name: "NOMI-upper".into(),
|
||||
version: "1.0.0".into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
license: None,
|
||||
homepage: None,
|
||||
icon: None,
|
||||
engine: None,
|
||||
api_version: None,
|
||||
dependencies: Default::default(),
|
||||
entry_point: None,
|
||||
permissions: None,
|
||||
contributes: None,
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
};
|
||||
assert!(validate_manifest(&manifest).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_version_rejected() {
|
||||
let manifest = ExtensionManifest {
|
||||
name: "my-ext".into(),
|
||||
version: "".into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
license: None,
|
||||
homepage: None,
|
||||
icon: None,
|
||||
engine: None,
|
||||
api_version: None,
|
||||
dependencies: Default::default(),
|
||||
entry_point: None,
|
||||
permissions: None,
|
||||
contributes: None,
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
};
|
||||
let err = validate_manifest(&manifest).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::ManifestValidation(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_semver_rejected() {
|
||||
let manifest = ExtensionManifest {
|
||||
name: "my-ext".into(),
|
||||
version: "not-semver".into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
license: None,
|
||||
homepage: None,
|
||||
icon: None,
|
||||
engine: None,
|
||||
api_version: None,
|
||||
dependencies: Default::default(),
|
||||
entry_point: None,
|
||||
permissions: None,
|
||||
contributes: None,
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
};
|
||||
let err = validate_manifest(&manifest).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::InvalidVersion { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_semver_versions() {
|
||||
for version in &["0.0.1", "1.0.0", "1.2.3", "10.20.30", "1.0.0-alpha.1"] {
|
||||
let manifest = ExtensionManifest {
|
||||
name: "ext".into(),
|
||||
version: (*version).into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
license: None,
|
||||
homepage: None,
|
||||
icon: None,
|
||||
engine: None,
|
||||
api_version: None,
|
||||
dependencies: Default::default(),
|
||||
entry_point: None,
|
||||
permissions: None,
|
||||
contributes: None,
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
};
|
||||
assert!(
|
||||
validate_manifest(&manifest).is_ok(),
|
||||
"version '{version}' should be accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- parse_manifest --
|
||||
|
||||
#[test]
|
||||
fn test_parse_manifest_valid() {
|
||||
let raw = json!({"name": "my-ext", "version": "1.0.0"});
|
||||
let bytes = serde_json::to_vec(&raw).unwrap();
|
||||
let manifest = parse_manifest(&bytes).unwrap();
|
||||
assert_eq!(manifest.name, "my-ext");
|
||||
assert_eq!(manifest.version, "1.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_manifest_invalid_json() {
|
||||
let err = parse_manifest(b"not json").unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::JsonParse(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_manifest_missing_name() {
|
||||
let raw = json!({"version": "1.0.0"});
|
||||
let bytes = serde_json::to_vec(&raw).unwrap();
|
||||
let err = parse_manifest(&bytes).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::JsonParse(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_manifest_reserved_name() {
|
||||
let raw = json!({"name": "internal-test", "version": "1.0.0"});
|
||||
let bytes = serde_json::to_vec(&raw).unwrap();
|
||||
let err = parse_manifest(&bytes).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::ReservedNamePrefix { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_manifest_in_dir_supports_nomifun_main_contract() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let contributes_dir = tmp.path().join("contributes");
|
||||
std::fs::create_dir_all(&contributes_dir).unwrap();
|
||||
std::fs::write(
|
||||
contributes_dir.join("settings-tabs.json"),
|
||||
serde_json::to_vec(&json!([
|
||||
{
|
||||
"id": "legacy-settings",
|
||||
"name": "Legacy Settings",
|
||||
"entryPoint": "settings/legacy.html",
|
||||
"position": { "anchor": "display", "placement": "after" }
|
||||
}
|
||||
]))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let raw = json!({
|
||||
"name": "legacy-ext",
|
||||
"displayName": "Legacy Extension",
|
||||
"version": "1.0.0",
|
||||
"i18n": {
|
||||
"localesDir": "i18n",
|
||||
"defaultLocale": "en-US"
|
||||
},
|
||||
"contributes": {
|
||||
"settingsTabs": "$file:contributes/settings-tabs.json"
|
||||
}
|
||||
});
|
||||
|
||||
let manifest = parse_manifest_in_dir(&serde_json::to_vec(&raw).unwrap(), tmp.path()).unwrap();
|
||||
assert_eq!(manifest.display_name.as_deref(), Some("Legacy Extension"));
|
||||
assert_eq!(manifest.i18n.as_ref().unwrap().locales, vec!["en-US".to_owned()]);
|
||||
assert_eq!(manifest.i18n.as_ref().unwrap().directory, "i18n");
|
||||
let settings_tabs = &manifest.contributes.as_ref().unwrap().settings_tabs;
|
||||
assert_eq!(settings_tabs.len(), 1);
|
||||
assert_eq!(settings_tabs[0].label, "Legacy Settings");
|
||||
assert_eq!(settings_tabs[0].url, "settings/legacy.html");
|
||||
assert_eq!(settings_tabs[0].position.as_ref().unwrap().relative_to, "display");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_manifest_in_dir_supports_legacy_webui_object() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let raw = json!({
|
||||
"name": "legacy-webui-ext",
|
||||
"version": "1.0.0",
|
||||
"contributes": {
|
||||
"webui": {
|
||||
"apiRoutes": [
|
||||
{
|
||||
"path": "/legacy-webui-ext/collect",
|
||||
"entryPoint": "webui/collector.js"
|
||||
}
|
||||
],
|
||||
"staticAssets": [
|
||||
{
|
||||
"urlPrefix": "/legacy-webui-ext/assets",
|
||||
"directory": "assets"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let manifest = parse_manifest_in_dir(&serde_json::to_vec(&raw).unwrap(), tmp.path()).unwrap();
|
||||
let webui = &manifest.contributes.as_ref().unwrap().webui;
|
||||
assert_eq!(webui.len(), 2);
|
||||
assert_eq!(webui[0].routes[0].handler, "webui/collector.js");
|
||||
assert_eq!(webui[0].routes[0].method, "GET");
|
||||
assert_eq!(webui[1].directory, "assets");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
use crate::types::{
|
||||
ExtPermissions, FilesystemScope, NetworkPermission, PermissionDetail, PermissionLevel, PermissionSummary, RiskLevel,
|
||||
};
|
||||
|
||||
/// Calculate the overall risk level from permission declarations.
|
||||
///
|
||||
/// Rules (from API Spec):
|
||||
/// - **dangerous**: `shell=true`, `filesystem=full`, or `network=true` (unrestricted)
|
||||
/// - **moderate**: scoped `network` (domain-restricted) or `filesystem=extension-only|workspace`
|
||||
/// - **safe**: everything else (only storage, events, clipboard, activeUser, or nothing)
|
||||
pub fn calculate_risk_level(permissions: &ExtPermissions) -> RiskLevel {
|
||||
// Dangerous: shell access
|
||||
if permissions.shell == Some(true) {
|
||||
return RiskLevel::Dangerous;
|
||||
}
|
||||
|
||||
// Dangerous: full filesystem access
|
||||
if permissions.filesystem == Some(FilesystemScope::Full) {
|
||||
return RiskLevel::Dangerous;
|
||||
}
|
||||
|
||||
// Dangerous: unrestricted network
|
||||
if let Some(NetworkPermission::Unrestricted(true)) = &permissions.network {
|
||||
return RiskLevel::Dangerous;
|
||||
}
|
||||
|
||||
// Moderate: scoped network (with allowed domains)
|
||||
if matches!(&permissions.network, Some(NetworkPermission::Scoped { .. })) {
|
||||
return RiskLevel::Moderate;
|
||||
}
|
||||
|
||||
// Moderate: workspace or extension-only filesystem
|
||||
if matches!(
|
||||
permissions.filesystem,
|
||||
Some(FilesystemScope::Workspace) | Some(FilesystemScope::ExtensionOnly)
|
||||
) {
|
||||
return RiskLevel::Moderate;
|
||||
}
|
||||
|
||||
RiskLevel::Safe
|
||||
}
|
||||
|
||||
/// Build a complete permission summary with risk analysis details.
|
||||
pub fn build_permission_summary(permissions: &ExtPermissions) -> PermissionSummary {
|
||||
let risk_level = calculate_risk_level(permissions);
|
||||
let details = build_details(permissions);
|
||||
PermissionSummary {
|
||||
permissions: permissions.clone(),
|
||||
risk_level,
|
||||
details,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_details(permissions: &ExtPermissions) -> Vec<PermissionDetail> {
|
||||
vec![
|
||||
build_storage_detail(permissions.storage),
|
||||
build_network_detail(&permissions.network),
|
||||
build_shell_detail(permissions.shell),
|
||||
build_filesystem_detail(permissions.filesystem),
|
||||
build_bool_detail("clipboard", permissions.clipboard, "Clipboard read/write access"),
|
||||
build_bool_detail(
|
||||
"activeUser",
|
||||
permissions.active_user,
|
||||
"Access to current user information",
|
||||
),
|
||||
build_bool_detail("events", permissions.events, "Extension event bus communication"),
|
||||
]
|
||||
}
|
||||
|
||||
fn build_storage_detail(storage: Option<bool>) -> PermissionDetail {
|
||||
match storage {
|
||||
Some(true) => PermissionDetail {
|
||||
permission: "storage".into(),
|
||||
level: PermissionLevel::Full,
|
||||
description: "Persistent key-value storage access".into(),
|
||||
},
|
||||
_ => PermissionDetail {
|
||||
permission: "storage".into(),
|
||||
level: PermissionLevel::None,
|
||||
description: "No storage access".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_network_detail(network: &Option<NetworkPermission>) -> PermissionDetail {
|
||||
match network {
|
||||
Some(NetworkPermission::Unrestricted(true)) => PermissionDetail {
|
||||
permission: "network".into(),
|
||||
level: PermissionLevel::Full,
|
||||
description: "Unrestricted network access".into(),
|
||||
},
|
||||
Some(NetworkPermission::Scoped { allowed_domains, .. }) => PermissionDetail {
|
||||
permission: "network".into(),
|
||||
level: PermissionLevel::Limited,
|
||||
description: format!("Network access limited to: {}", allowed_domains.join(", ")),
|
||||
},
|
||||
_ => PermissionDetail {
|
||||
permission: "network".into(),
|
||||
level: PermissionLevel::None,
|
||||
description: "No network access".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_shell_detail(shell: Option<bool>) -> PermissionDetail {
|
||||
match shell {
|
||||
Some(true) => PermissionDetail {
|
||||
permission: "shell".into(),
|
||||
level: PermissionLevel::Full,
|
||||
description: "System command execution".into(),
|
||||
},
|
||||
_ => PermissionDetail {
|
||||
permission: "shell".into(),
|
||||
level: PermissionLevel::None,
|
||||
description: "No shell access".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_filesystem_detail(filesystem: Option<FilesystemScope>) -> PermissionDetail {
|
||||
match filesystem {
|
||||
Some(FilesystemScope::Full) => PermissionDetail {
|
||||
permission: "filesystem".into(),
|
||||
level: PermissionLevel::Full,
|
||||
description: "Full filesystem access".into(),
|
||||
},
|
||||
Some(FilesystemScope::Workspace) => PermissionDetail {
|
||||
permission: "filesystem".into(),
|
||||
level: PermissionLevel::Limited,
|
||||
description: "Workspace directory access".into(),
|
||||
},
|
||||
Some(FilesystemScope::ExtensionOnly) => PermissionDetail {
|
||||
permission: "filesystem".into(),
|
||||
level: PermissionLevel::Limited,
|
||||
description: "Extension directory access only".into(),
|
||||
},
|
||||
None => PermissionDetail {
|
||||
permission: "filesystem".into(),
|
||||
level: PermissionLevel::None,
|
||||
description: "No filesystem access".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_bool_detail(name: &str, value: Option<bool>, granted_desc: &str) -> PermissionDetail {
|
||||
match value {
|
||||
Some(true) => PermissionDetail {
|
||||
permission: name.into(),
|
||||
level: PermissionLevel::Full,
|
||||
description: granted_desc.into(),
|
||||
},
|
||||
_ => PermissionDetail {
|
||||
permission: name.into(),
|
||||
level: PermissionLevel::None,
|
||||
description: format!("No {name} access"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- calculate_risk_level --
|
||||
|
||||
#[test]
|
||||
fn test_no_permissions_is_safe() {
|
||||
let perms = ExtPermissions::default();
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Safe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_and_events_only_is_safe() {
|
||||
let perms = ExtPermissions {
|
||||
storage: Some(true),
|
||||
events: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Safe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_is_safe() {
|
||||
let perms = ExtPermissions {
|
||||
clipboard: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Safe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_active_user_is_safe() {
|
||||
let perms = ExtPermissions {
|
||||
active_user: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Safe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scoped_network_is_moderate() {
|
||||
let perms = ExtPermissions {
|
||||
network: Some(NetworkPermission::Scoped {
|
||||
allowed_domains: vec!["api.example.com".into()],
|
||||
reasoning: "API calls".into(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Moderate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_filesystem_is_moderate() {
|
||||
let perms = ExtPermissions {
|
||||
filesystem: Some(FilesystemScope::Workspace),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Moderate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extension_only_filesystem_is_moderate() {
|
||||
let perms = ExtPermissions {
|
||||
filesystem: Some(FilesystemScope::ExtensionOnly),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Moderate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_is_dangerous() {
|
||||
let perms = ExtPermissions {
|
||||
shell: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Dangerous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_filesystem_is_dangerous() {
|
||||
let perms = ExtPermissions {
|
||||
filesystem: Some(FilesystemScope::Full),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Dangerous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unrestricted_network_is_dangerous() {
|
||||
let perms = ExtPermissions {
|
||||
network: Some(NetworkPermission::Unrestricted(true)),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Dangerous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dangerous_overrides_moderate() {
|
||||
let perms = ExtPermissions {
|
||||
shell: Some(true),
|
||||
network: Some(NetworkPermission::Scoped {
|
||||
allowed_domains: vec!["example.com".into()],
|
||||
reasoning: "test".into(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Dangerous);
|
||||
}
|
||||
|
||||
// -- build_permission_summary --
|
||||
|
||||
#[test]
|
||||
fn test_summary_includes_all_permissions() {
|
||||
let perms = ExtPermissions {
|
||||
storage: Some(true),
|
||||
events: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let summary = build_permission_summary(&perms);
|
||||
assert_eq!(summary.risk_level, RiskLevel::Safe);
|
||||
assert_eq!(summary.permissions, perms);
|
||||
assert_eq!(summary.details.len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary_storage_detail() {
|
||||
let perms = ExtPermissions {
|
||||
storage: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let summary = build_permission_summary(&perms);
|
||||
let storage = summary.details.iter().find(|d| d.permission == "storage").unwrap();
|
||||
assert_eq!(storage.level, PermissionLevel::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary_network_scoped_detail() {
|
||||
let perms = ExtPermissions {
|
||||
network: Some(NetworkPermission::Scoped {
|
||||
allowed_domains: vec!["a.com".into(), "b.com".into()],
|
||||
reasoning: "test".into(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let summary = build_permission_summary(&perms);
|
||||
let network = summary.details.iter().find(|d| d.permission == "network").unwrap();
|
||||
assert_eq!(network.level, PermissionLevel::Limited);
|
||||
assert!(network.description.contains("a.com"));
|
||||
assert!(network.description.contains("b.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary_filesystem_full_detail() {
|
||||
let perms = ExtPermissions {
|
||||
filesystem: Some(FilesystemScope::Full),
|
||||
..Default::default()
|
||||
};
|
||||
let summary = build_permission_summary(&perms);
|
||||
let fs = summary.details.iter().find(|d| d.permission == "filesystem").unwrap();
|
||||
assert_eq!(fs.level, PermissionLevel::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary_no_permissions_all_none() {
|
||||
let perms = ExtPermissions::default();
|
||||
let summary = build_permission_summary(&perms);
|
||||
for detail in &summary.details {
|
||||
assert_eq!(
|
||||
detail.level,
|
||||
PermissionLevel::None,
|
||||
"{} should be None",
|
||||
detail.permission
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_common::{TimestampMs, now_ms};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use serde_json::json;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::error::ExtensionError;
|
||||
use crate::lifecycle::{HookKind, execute_hook, needs_install_hook, resolve_hook_path};
|
||||
use crate::loader::{ScanPath, resolve_scan_paths};
|
||||
use crate::registry_helpers::{
|
||||
build_state_map, load_and_validate, merge_persisted_states, run_deactivation_hooks, to_summary,
|
||||
};
|
||||
use crate::resolvers::{resolve_all_contributions, resolve_i18n_for_all};
|
||||
use crate::state::ExtensionStateStore;
|
||||
use crate::types::{
|
||||
ExtensionLifecyclePayload, ExtensionState, ExtensionSystemEvent, LoadedExtension, ResolvedAcpAdapter,
|
||||
ResolvedAgent, ResolvedAssistant, ResolvedChannelPlugin, ResolvedContributions, ResolvedModelProvider,
|
||||
ResolvedSettingsTab, ResolvedSkill, ResolvedTheme, WebuiContribution,
|
||||
};
|
||||
|
||||
// Re-export ExtensionSummary from registry_helpers so that
|
||||
// `registry::{ExtensionRegistry, ExtensionSummary}` continues to work.
|
||||
pub use crate::registry_helpers::ExtensionSummary;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Central registry orchestrating extension loading, activation, contribution
|
||||
/// resolution, and event broadcasting.
|
||||
///
|
||||
/// Thread-safe: can be shared across HTTP handlers, the file watcher, and
|
||||
/// other async tasks via `Arc`.
|
||||
#[derive(Clone)]
|
||||
pub struct ExtensionRegistry {
|
||||
inner: Arc<RwLock<RegistryInner>>,
|
||||
state_store: ExtensionStateStore,
|
||||
broadcaster: Arc<dyn EventBroadcaster>,
|
||||
app_version: String,
|
||||
}
|
||||
|
||||
struct RegistryInner {
|
||||
extensions: Vec<LoadedExtension>,
|
||||
contributions: ResolvedContributions,
|
||||
scan_paths: Vec<ScanPath>,
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl ExtensionRegistry {
|
||||
/// Create a new registry.
|
||||
///
|
||||
/// - `state_store`: persists enabled/disabled states across restarts.
|
||||
/// - `broadcaster`: pushes WebSocket events to connected clients.
|
||||
/// - `app_version`: current application version for engine compatibility.
|
||||
pub fn new(state_store: ExtensionStateStore, broadcaster: Arc<dyn EventBroadcaster>, app_version: String) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(RegistryInner {
|
||||
extensions: Vec::new(),
|
||||
contributions: ResolvedContributions::default(),
|
||||
scan_paths: Vec::new(),
|
||||
initialized: false,
|
||||
})),
|
||||
state_store,
|
||||
broadcaster,
|
||||
app_version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl ExtensionRegistry {
|
||||
/// Run the full initialization pipeline using auto-detected scan paths.
|
||||
///
|
||||
/// Resolves scan paths from environment variables and platform defaults,
|
||||
/// then delegates to [`Self::initialize_with_scan_paths`].
|
||||
pub async fn initialize(&self) -> Result<(), ExtensionError> {
|
||||
let scan_paths = resolve_scan_paths();
|
||||
self.initialize_with_scan_paths(scan_paths).await
|
||||
}
|
||||
|
||||
/// Run the full initialization pipeline with explicit scan paths.
|
||||
///
|
||||
/// Prefer this over [`Self::initialize`] when the caller already knows
|
||||
/// the extension directories (e.g., in tests or embedded deployments).
|
||||
///
|
||||
/// Pipeline:
|
||||
/// 1. Load manifests from all directories
|
||||
/// 2. Filter by engine compatibility
|
||||
/// 3. Validate dependencies + topological sort
|
||||
/// 4. Merge persisted states (enabled/disabled)
|
||||
/// 5. Run lifecycle hooks (onInstall if needed, then onActivate)
|
||||
/// 6. Resolve all contributions
|
||||
/// 7. Persist updated states
|
||||
pub async fn initialize_with_scan_paths(&self, scan_paths: Vec<ScanPath>) -> Result<(), ExtensionError> {
|
||||
info!("initializing extension registry");
|
||||
debug!(count = scan_paths.len(), "resolved scan paths");
|
||||
|
||||
// 1-3. Load, filter, validate (all sync/blocking).
|
||||
let (extensions, dep_result) = load_and_validate(&scan_paths, &self.app_version);
|
||||
|
||||
// 4. Merge persisted states.
|
||||
let persisted = self.state_store.load().await?;
|
||||
let extensions = merge_persisted_states(extensions, &persisted);
|
||||
|
||||
// 5. Run lifecycle hooks.
|
||||
let extensions = self.run_activation_hooks(extensions, &persisted).await;
|
||||
|
||||
// 6. Resolve contributions.
|
||||
let contributions = resolve_all_contributions(&extensions);
|
||||
|
||||
// 7. Persist updated states.
|
||||
let states = build_state_map(&extensions);
|
||||
self.state_store.set_all(states).await;
|
||||
|
||||
// Commit to inner state.
|
||||
{
|
||||
let mut guard = self.inner.write().await;
|
||||
guard.extensions = extensions;
|
||||
guard.contributions = contributions;
|
||||
guard.scan_paths = scan_paths;
|
||||
guard.initialized = true;
|
||||
}
|
||||
|
||||
if !dep_result.issues.is_empty() {
|
||||
warn!(issues = dep_result.issues.len(), "dependency validation found issues");
|
||||
}
|
||||
|
||||
info!("extension registry initialized");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hot reload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl ExtensionRegistry {
|
||||
/// Hot-reload the registry: deactivate all -> clear -> reload -> re-resolve.
|
||||
///
|
||||
/// Emits `REGISTRY_RELOADED` event when complete.
|
||||
pub async fn hot_reload(&self) {
|
||||
info!("hot-reloading extension registry");
|
||||
|
||||
// 1. Snapshot current extensions and scan paths for deactivation.
|
||||
let (current_exts, scan_paths) = {
|
||||
let guard = self.inner.read().await;
|
||||
(guard.extensions.clone(), guard.scan_paths.clone())
|
||||
};
|
||||
|
||||
// 2. Run onDeactivate hooks for each currently active extension.
|
||||
run_deactivation_hooks(¤t_exts).await;
|
||||
|
||||
// 3. Reload pipeline (same as initialize but reuses existing scan paths).
|
||||
let (extensions, _dep_result) = load_and_validate(&scan_paths, &self.app_version);
|
||||
|
||||
// Use in-memory state (not file) to preserve pending writes that
|
||||
// haven't been flushed yet by the debounce timer.
|
||||
let persisted = self.state_store.get_all().await;
|
||||
|
||||
let extensions = merge_persisted_states(extensions, &persisted);
|
||||
let extensions = self.run_activation_hooks(extensions, &persisted).await;
|
||||
let contributions = resolve_all_contributions(&extensions);
|
||||
|
||||
let states = build_state_map(&extensions);
|
||||
self.state_store.set_all(states).await;
|
||||
|
||||
// 4. Commit new state.
|
||||
{
|
||||
let mut guard = self.inner.write().await;
|
||||
guard.extensions = extensions;
|
||||
guard.contributions = contributions;
|
||||
// scan_paths stay the same
|
||||
}
|
||||
|
||||
// 5. Broadcast REGISTRY_RELOADED event.
|
||||
self.broadcast_lifecycle_event("registry", ExtensionSystemEvent::RegistryReloaded, None);
|
||||
|
||||
info!("extension registry hot-reloaded");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enable / Disable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl ExtensionRegistry {
|
||||
/// Enable an extension by name.
|
||||
///
|
||||
/// Updates the in-memory state, re-resolves contributions, persists the
|
||||
/// change, and broadcasts `extensions.state-changed`.
|
||||
pub async fn enable_extension(&self, name: &str) -> Result<(), ExtensionError> {
|
||||
let state = {
|
||||
let mut guard = self.inner.write().await;
|
||||
|
||||
let idx = guard
|
||||
.extensions
|
||||
.iter()
|
||||
.position(|e| e.manifest.name == name)
|
||||
.ok_or_else(|| ExtensionError::NotFound(name.to_owned()))?;
|
||||
|
||||
if guard.extensions[idx].state.enabled {
|
||||
debug!(name, "extension already enabled");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
guard.extensions[idx].state.enabled = true;
|
||||
guard.extensions[idx].state.last_activated_at = Some(now_ms());
|
||||
|
||||
// Re-resolve contributions with updated enabled set.
|
||||
guard.contributions = resolve_all_contributions(&guard.extensions);
|
||||
|
||||
guard.extensions[idx].state.clone()
|
||||
};
|
||||
|
||||
// Persist + broadcast outside the write lock.
|
||||
self.state_store.set(state).await;
|
||||
self.broadcast_state_changed(name, true);
|
||||
|
||||
info!(name, "extension enabled");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disable an extension by name.
|
||||
///
|
||||
/// Optionally records a reason (logged for auditing). Updates state,
|
||||
/// re-resolves contributions, persists, and broadcasts
|
||||
/// `extensions.state-changed`.
|
||||
pub async fn disable_extension(&self, name: &str, reason: Option<&str>) -> Result<(), ExtensionError> {
|
||||
let state = {
|
||||
let mut guard = self.inner.write().await;
|
||||
|
||||
let idx = guard
|
||||
.extensions
|
||||
.iter()
|
||||
.position(|e| e.manifest.name == name)
|
||||
.ok_or_else(|| ExtensionError::NotFound(name.to_owned()))?;
|
||||
|
||||
if !guard.extensions[idx].state.enabled {
|
||||
debug!(name, "extension already disabled");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
guard.extensions[idx].state.enabled = false;
|
||||
|
||||
// Re-resolve contributions with updated enabled set.
|
||||
guard.contributions = resolve_all_contributions(&guard.extensions);
|
||||
|
||||
guard.extensions[idx].state.clone()
|
||||
};
|
||||
|
||||
// Persist + broadcast outside the write lock.
|
||||
self.state_store.set(state).await;
|
||||
self.broadcast_state_changed(name, false);
|
||||
|
||||
if let Some(r) = reason {
|
||||
info!(name, reason = r, "extension disabled");
|
||||
} else {
|
||||
info!(name, "extension disabled");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl ExtensionRegistry {
|
||||
/// Return summaries of all loaded extensions.
|
||||
pub async fn get_loaded_extensions(&self) -> Vec<ExtensionSummary> {
|
||||
let guard = self.inner.read().await;
|
||||
guard.extensions.iter().map(to_summary).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn event_broadcaster(&self) -> Arc<dyn EventBroadcaster> {
|
||||
self.broadcaster.clone()
|
||||
}
|
||||
|
||||
/// Look up a single loaded extension by name.
|
||||
pub async fn get_extension_by_name(&self, name: &str) -> Option<LoadedExtension> {
|
||||
let guard = self.inner.read().await;
|
||||
guard.extensions.iter().find(|e| e.manifest.name == name).cloned()
|
||||
}
|
||||
|
||||
/// Snapshot of all resolved contributions.
|
||||
pub async fn get_contributions(&self) -> ResolvedContributions {
|
||||
let guard = self.inner.read().await;
|
||||
guard.contributions.clone()
|
||||
}
|
||||
|
||||
pub async fn get_themes(&self) -> Vec<ResolvedTheme> {
|
||||
let guard = self.inner.read().await;
|
||||
guard.contributions.themes.clone()
|
||||
}
|
||||
|
||||
pub async fn get_assistants(&self) -> Vec<ResolvedAssistant> {
|
||||
let guard = self.inner.read().await;
|
||||
guard.contributions.assistants.clone()
|
||||
}
|
||||
|
||||
/// Return `true` if any extension contributes an assistant with this id.
|
||||
pub async fn has_assistant(&self, id: &str) -> bool {
|
||||
let guard = self.inner.read().await;
|
||||
guard.contributions.assistants.iter().any(|a| a.id == id)
|
||||
}
|
||||
|
||||
/// Lookup a single extension-contributed assistant by id.
|
||||
pub async fn get_assistant_by_id(&self, id: &str) -> Option<ResolvedAssistant> {
|
||||
let guard = self.inner.read().await;
|
||||
guard.contributions.assistants.iter().find(|a| a.id == id).cloned()
|
||||
}
|
||||
|
||||
pub async fn get_acp_adapters(&self) -> Vec<ResolvedAcpAdapter> {
|
||||
let guard = self.inner.read().await;
|
||||
guard.contributions.acp_adapters.clone()
|
||||
}
|
||||
|
||||
pub async fn get_agents(&self) -> Vec<ResolvedAgent> {
|
||||
let guard = self.inner.read().await;
|
||||
guard.contributions.agents.clone()
|
||||
}
|
||||
|
||||
pub async fn get_mcp_servers(&self) -> Vec<crate::types::ResolvedMcpServer> {
|
||||
let guard = self.inner.read().await;
|
||||
guard.contributions.mcp_servers.clone()
|
||||
}
|
||||
|
||||
pub async fn get_skills(&self) -> Vec<ResolvedSkill> {
|
||||
let guard = self.inner.read().await;
|
||||
guard.contributions.skills.clone()
|
||||
}
|
||||
|
||||
pub async fn get_settings_tabs(&self) -> Vec<ResolvedSettingsTab> {
|
||||
let guard = self.inner.read().await;
|
||||
guard.contributions.settings_tabs.clone()
|
||||
}
|
||||
|
||||
pub async fn get_webui_contributions(&self) -> Vec<WebuiContribution> {
|
||||
let guard = self.inner.read().await;
|
||||
guard.contributions.webui.clone()
|
||||
}
|
||||
|
||||
pub async fn get_channel_plugins(&self) -> Vec<ResolvedChannelPlugin> {
|
||||
let guard = self.inner.read().await;
|
||||
guard.contributions.channel_plugins.clone()
|
||||
}
|
||||
|
||||
pub async fn get_model_providers(&self) -> Vec<ResolvedModelProvider> {
|
||||
let guard = self.inner.read().await;
|
||||
guard.contributions.model_providers.clone()
|
||||
}
|
||||
|
||||
/// Resolve i18n data for a given locale across all enabled extensions.
|
||||
pub async fn get_i18n_for_locale(&self, locale: &str) -> HashMap<String, HashMap<String, String>> {
|
||||
let guard = self.inner.read().await;
|
||||
resolve_i18n_for_all(&guard.extensions, locale)
|
||||
}
|
||||
|
||||
/// Whether the registry has been initialized.
|
||||
pub async fn is_initialized(&self) -> bool {
|
||||
let guard = self.inner.read().await;
|
||||
guard.initialized
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event broadcasting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl ExtensionRegistry {
|
||||
fn broadcast_state_changed(&self, name: &str, enabled: bool) {
|
||||
let event = WebSocketMessage::new("extensions.state-changed", json!({ "name": name, "enabled": enabled }));
|
||||
self.broadcaster.broadcast(event);
|
||||
}
|
||||
|
||||
fn broadcast_lifecycle_event(
|
||||
&self,
|
||||
extension_name: &str,
|
||||
event: ExtensionSystemEvent,
|
||||
data: Option<serde_json::Value>,
|
||||
) {
|
||||
let payload = ExtensionLifecyclePayload {
|
||||
extension_name: extension_name.to_owned(),
|
||||
event,
|
||||
timestamp: now_ms(),
|
||||
data,
|
||||
};
|
||||
let msg = WebSocketMessage::new(
|
||||
"extensions.lifecycle",
|
||||
serde_json::to_value(&payload).unwrap_or_default(),
|
||||
);
|
||||
self.broadcaster.broadcast(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Activation hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl ExtensionRegistry {
|
||||
/// Run lifecycle hooks for each extension in order:
|
||||
/// - `onInstall` if first time or version changed
|
||||
/// - `onActivate` for each enabled extension
|
||||
///
|
||||
/// Hook failures are logged but do not prevent other extensions from
|
||||
/// activating.
|
||||
async fn run_activation_hooks(
|
||||
&self,
|
||||
mut extensions: Vec<LoadedExtension>,
|
||||
persisted: &HashMap<String, ExtensionState>,
|
||||
) -> Vec<LoadedExtension> {
|
||||
let now: TimestampMs = now_ms();
|
||||
|
||||
for ext in &mut extensions {
|
||||
if !ext.state.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ext_name = ext.manifest.name.clone();
|
||||
let ext_dir = Path::new(&ext.directory);
|
||||
|
||||
// Check onInstall + onActivate hooks.
|
||||
if let Some(hooks) = &ext.manifest.lifecycle {
|
||||
let persisted_version = persisted.get(&ext_name).map(|s| s.version.as_str());
|
||||
|
||||
if needs_install_hook(&ext.manifest.version, persisted_version)
|
||||
&& let Some(hook_path) = resolve_hook_path(hooks, HookKind::OnInstall)
|
||||
&& let Err(e) = execute_hook(ext_dir, hook_path, HookKind::OnInstall, &ext_name).await
|
||||
{
|
||||
warn!(
|
||||
extension = %ext_name,
|
||||
error = %e,
|
||||
"onInstall hook failed, continuing"
|
||||
);
|
||||
}
|
||||
|
||||
// Run onActivate
|
||||
if let Some(hook_path) = resolve_hook_path(hooks, HookKind::OnActivate)
|
||||
&& let Err(e) = execute_hook(ext_dir, hook_path, HookKind::OnActivate, &ext_name).await
|
||||
{
|
||||
warn!(
|
||||
extension = %ext_name,
|
||||
error = %e,
|
||||
"onActivate hook failed, continuing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update activation timestamp and install time.
|
||||
ext.state.last_activated_at = Some(now);
|
||||
if ext.state.installed_at.is_none() {
|
||||
ext.state.installed_at = Some(now);
|
||||
}
|
||||
|
||||
self.broadcast_lifecycle_event(&ext_name, ExtensionSystemEvent::ExtensionActivated, None);
|
||||
}
|
||||
|
||||
extensions
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{ExtensionManifest, ExtensionSource, ExtensionState};
|
||||
use nomifun_realtime::BroadcastEventBus;
|
||||
|
||||
fn make_test_ext(name: &str, enabled: bool) -> LoadedExtension {
|
||||
LoadedExtension {
|
||||
manifest: ExtensionManifest {
|
||||
name: name.to_owned(),
|
||||
version: "1.0.0".to_owned(),
|
||||
display_name: Some(format!("{name} Display")),
|
||||
description: Some(format!("{name} description")),
|
||||
author: None,
|
||||
license: None,
|
||||
homepage: None,
|
||||
icon: None,
|
||||
engine: None,
|
||||
api_version: None,
|
||||
dependencies: HashMap::new(),
|
||||
entry_point: None,
|
||||
permissions: None,
|
||||
contributes: None,
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
},
|
||||
directory: format!("/tmp/ext/{name}"),
|
||||
source: ExtensionSource::Local,
|
||||
state: ExtensionState {
|
||||
name: name.to_owned(),
|
||||
version: "1.0.0".to_owned(),
|
||||
enabled,
|
||||
installed_at: Some(1000),
|
||||
last_activated_at: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn make_registry() -> (ExtensionRegistry, ExtensionStateStore, Arc<BroadcastEventBus>) {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
|
||||
let bus = Arc::new(BroadcastEventBus::new(64));
|
||||
let registry = ExtensionRegistry::new(store.clone(), bus.clone(), "1.0.0".to_owned());
|
||||
(registry, store, bus)
|
||||
}
|
||||
|
||||
// -- enable_extension / disable_extension -----------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_nonexistent_returns_not_found() {
|
||||
let (registry, _, _) = make_registry();
|
||||
let result = registry.enable_extension("no-such-ext").await;
|
||||
assert!(matches!(result, Err(ExtensionError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disable_nonexistent_returns_not_found() {
|
||||
let (registry, _, _) = make_registry();
|
||||
let result = registry.disable_extension("no-such-ext", None).await;
|
||||
assert!(matches!(result, Err(ExtensionError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_disable_roundtrip() {
|
||||
let (registry, _, bus) = make_registry();
|
||||
|
||||
// Seed the registry with a disabled extension.
|
||||
{
|
||||
let mut guard = registry.inner.write().await;
|
||||
guard.extensions = vec![make_test_ext("test-ext", false)];
|
||||
guard.initialized = true;
|
||||
}
|
||||
|
||||
let mut rx = bus.subscribe();
|
||||
|
||||
// Enable
|
||||
registry.enable_extension("test-ext").await.unwrap();
|
||||
{
|
||||
let guard = registry.inner.read().await;
|
||||
assert!(guard.extensions[0].state.enabled);
|
||||
}
|
||||
|
||||
let msg = rx.recv().await.unwrap();
|
||||
assert_eq!(msg.name, "extensions.state-changed");
|
||||
assert_eq!(msg.data["enabled"], true);
|
||||
|
||||
// Disable
|
||||
registry
|
||||
.disable_extension("test-ext", Some("test reason"))
|
||||
.await
|
||||
.unwrap();
|
||||
{
|
||||
let guard = registry.inner.read().await;
|
||||
assert!(!guard.extensions[0].state.enabled);
|
||||
}
|
||||
|
||||
let msg = rx.recv().await.unwrap();
|
||||
assert_eq!(msg.name, "extensions.state-changed");
|
||||
assert_eq!(msg.data["enabled"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_already_enabled_is_noop() {
|
||||
let (registry, _, _) = make_registry();
|
||||
{
|
||||
let mut guard = registry.inner.write().await;
|
||||
guard.extensions = vec![make_test_ext("ext", true)];
|
||||
}
|
||||
// Should succeed without error.
|
||||
registry.enable_extension("ext").await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disable_already_disabled_is_noop() {
|
||||
let (registry, _, _) = make_registry();
|
||||
{
|
||||
let mut guard = registry.inner.write().await;
|
||||
guard.extensions = vec![make_test_ext("ext", false)];
|
||||
}
|
||||
registry.disable_extension("ext", None).await.unwrap();
|
||||
}
|
||||
|
||||
// -- query methods --------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_loaded_extensions_returns_summaries() {
|
||||
let (registry, _, _) = make_registry();
|
||||
{
|
||||
let mut guard = registry.inner.write().await;
|
||||
guard.extensions = vec![make_test_ext("ext-a", true), make_test_ext("ext-b", false)];
|
||||
}
|
||||
|
||||
let summaries = registry.get_loaded_extensions().await;
|
||||
assert_eq!(summaries.len(), 2);
|
||||
assert_eq!(summaries[0].name, "ext-a");
|
||||
assert!(summaries[0].enabled);
|
||||
assert_eq!(summaries[1].name, "ext-b");
|
||||
assert!(!summaries[1].enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_extension_by_name_found_and_not_found() {
|
||||
let (registry, _, _) = make_registry();
|
||||
{
|
||||
let mut guard = registry.inner.write().await;
|
||||
guard.extensions = vec![make_test_ext("my-ext", true)];
|
||||
}
|
||||
|
||||
assert!(registry.get_extension_by_name("my-ext").await.is_some());
|
||||
assert!(registry.get_extension_by_name("nope").await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_initialized_before_and_after() {
|
||||
let (registry, _, _) = make_registry();
|
||||
assert!(!registry.is_initialized().await);
|
||||
|
||||
{
|
||||
let mut guard = registry.inner.write().await;
|
||||
guard.initialized = true;
|
||||
}
|
||||
assert!(registry.is_initialized().await);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::dependency::{DependencyValidationResult, validate_dependencies};
|
||||
use crate::lifecycle::{HookKind, execute_hook, resolve_hook_path};
|
||||
use crate::loader::{ScanPath, filter_by_engine_compatibility, load_all};
|
||||
use crate::types::{ExtensionSource, ExtensionState, LoadedExtension};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ExtensionSummary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Lightweight summary of a loaded extension.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
||||
pub struct ExtensionSummary {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub source: ExtensionSource,
|
||||
}
|
||||
|
||||
pub(crate) fn to_summary(ext: &LoadedExtension) -> ExtensionSummary {
|
||||
ExtensionSummary {
|
||||
name: ext.manifest.name.clone(),
|
||||
version: ext.manifest.version.clone(),
|
||||
display_name: ext.manifest.display_name.clone(),
|
||||
description: ext.manifest.description.clone(),
|
||||
enabled: ext.state.enabled,
|
||||
source: ext.source,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load + validate pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Load extensions, filter by engine compatibility, validate dependencies, and
|
||||
/// sort by topological order. Returns the sorted extensions and the validation
|
||||
/// result.
|
||||
pub(crate) fn load_and_validate(
|
||||
scan_paths: &[ScanPath],
|
||||
app_version: &str,
|
||||
) -> (Vec<LoadedExtension>, DependencyValidationResult) {
|
||||
let loaded = load_all(scan_paths);
|
||||
debug!(count = loaded.len(), "loaded extension manifests");
|
||||
|
||||
let filtered = filter_by_engine_compatibility(loaded, app_version);
|
||||
debug!(count = filtered.len(), "after engine compatibility filter");
|
||||
|
||||
let dep_result = validate_dependencies(&filtered);
|
||||
let sorted = sort_by_load_order(filtered, &dep_result.load_order);
|
||||
debug!(count = sorted.len(), "after dependency sort");
|
||||
|
||||
(sorted, dep_result)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sorting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Reorder extensions according to the given load order.
|
||||
///
|
||||
/// Extensions not in `load_order` are appended at the end in alphabetical
|
||||
/// order.
|
||||
pub(crate) fn sort_by_load_order(extensions: Vec<LoadedExtension>, load_order: &[String]) -> Vec<LoadedExtension> {
|
||||
let mut by_name: HashMap<String, LoadedExtension> =
|
||||
extensions.into_iter().map(|e| (e.manifest.name.clone(), e)).collect();
|
||||
|
||||
let mut sorted = Vec::with_capacity(by_name.len());
|
||||
|
||||
// First, add extensions in load_order.
|
||||
for name in load_order {
|
||||
if let Some(ext) = by_name.remove(name) {
|
||||
sorted.push(ext);
|
||||
}
|
||||
}
|
||||
|
||||
// Append any remaining (not in load_order) in alphabetical order.
|
||||
let mut remaining: Vec<LoadedExtension> = by_name.into_values().collect();
|
||||
remaining.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
|
||||
sorted.extend(remaining);
|
||||
|
||||
sorted
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State merging + building
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Merge persisted enabled/disabled states into freshly loaded extensions.
|
||||
///
|
||||
/// If no persisted state exists for an extension, it defaults to enabled.
|
||||
pub(crate) fn merge_persisted_states(
|
||||
mut extensions: Vec<LoadedExtension>,
|
||||
persisted: &HashMap<String, ExtensionState>,
|
||||
) -> Vec<LoadedExtension> {
|
||||
for ext in &mut extensions {
|
||||
if let Some(saved) = persisted.get(&ext.manifest.name) {
|
||||
ext.state.enabled = saved.enabled;
|
||||
ext.state.installed_at = saved.installed_at;
|
||||
ext.state.last_activated_at = saved.last_activated_at;
|
||||
}
|
||||
}
|
||||
extensions
|
||||
}
|
||||
|
||||
/// Build a state map from the current extensions for persistence.
|
||||
pub(crate) fn build_state_map(extensions: &[LoadedExtension]) -> HashMap<String, ExtensionState> {
|
||||
extensions
|
||||
.iter()
|
||||
.map(|e| (e.state.name.clone(), e.state.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deactivation hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run `onDeactivate` hooks for all enabled extensions.
|
||||
///
|
||||
/// Errors are logged but do not propagate.
|
||||
pub(crate) async fn run_deactivation_hooks(extensions: &[LoadedExtension]) {
|
||||
for ext in extensions {
|
||||
if !ext.state.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(hooks) = &ext.manifest.lifecycle else {
|
||||
continue;
|
||||
};
|
||||
let Some(hook_path) = resolve_hook_path(hooks, HookKind::OnDeactivate) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let ext_dir = Path::new(&ext.directory);
|
||||
if let Err(e) = execute_hook(ext_dir, hook_path, HookKind::OnDeactivate, &ext.manifest.name).await {
|
||||
warn!(
|
||||
extension = %ext.manifest.name,
|
||||
error = %e,
|
||||
"onDeactivate hook failed during hot reload"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{ExtensionManifest, ExtensionSource, ExtensionState};
|
||||
|
||||
fn make_test_ext(name: &str, enabled: bool) -> LoadedExtension {
|
||||
LoadedExtension {
|
||||
manifest: ExtensionManifest {
|
||||
name: name.to_owned(),
|
||||
version: "1.0.0".to_owned(),
|
||||
display_name: Some(format!("{name} Display")),
|
||||
description: Some(format!("{name} description")),
|
||||
author: None,
|
||||
license: None,
|
||||
homepage: None,
|
||||
icon: None,
|
||||
engine: None,
|
||||
api_version: None,
|
||||
dependencies: HashMap::new(),
|
||||
entry_point: None,
|
||||
permissions: None,
|
||||
contributes: None,
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
},
|
||||
directory: format!("/tmp/ext/{name}"),
|
||||
source: ExtensionSource::Local,
|
||||
state: ExtensionState {
|
||||
name: name.to_owned(),
|
||||
version: "1.0.0".to_owned(),
|
||||
enabled,
|
||||
installed_at: Some(1000),
|
||||
last_activated_at: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// -- sort_by_load_order ---------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn sort_respects_load_order() {
|
||||
let exts = vec![
|
||||
make_test_ext("ext-c", true),
|
||||
make_test_ext("ext-a", true),
|
||||
make_test_ext("ext-b", true),
|
||||
];
|
||||
let order = vec!["ext-a".to_owned(), "ext-b".to_owned(), "ext-c".to_owned()];
|
||||
let sorted = sort_by_load_order(exts, &order);
|
||||
let names: Vec<&str> = sorted.iter().map(|e| e.manifest.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["ext-a", "ext-b", "ext-c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_appends_unordered_extensions() {
|
||||
let exts = vec![
|
||||
make_test_ext("ext-z", true),
|
||||
make_test_ext("ext-a", true),
|
||||
make_test_ext("ext-m", true),
|
||||
];
|
||||
// Only ext-a is in load order
|
||||
let order = vec!["ext-a".to_owned()];
|
||||
let sorted = sort_by_load_order(exts, &order);
|
||||
let names: Vec<&str> = sorted.iter().map(|e| e.manifest.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["ext-a", "ext-m", "ext-z"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_empty_load_order() {
|
||||
let exts = vec![make_test_ext("ext-b", true), make_test_ext("ext-a", true)];
|
||||
let sorted = sort_by_load_order(exts, &[]);
|
||||
let names: Vec<&str> = sorted.iter().map(|e| e.manifest.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["ext-a", "ext-b"]);
|
||||
}
|
||||
|
||||
// -- merge_persisted_states ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn merge_applies_persisted_enabled() {
|
||||
let exts = vec![make_test_ext("ext-a", true)];
|
||||
let mut persisted = HashMap::new();
|
||||
persisted.insert(
|
||||
"ext-a".to_owned(),
|
||||
ExtensionState {
|
||||
name: "ext-a".to_owned(),
|
||||
version: "1.0.0".to_owned(),
|
||||
enabled: false,
|
||||
installed_at: Some(500),
|
||||
last_activated_at: Some(600),
|
||||
},
|
||||
);
|
||||
|
||||
let merged = merge_persisted_states(exts, &persisted);
|
||||
assert!(!merged[0].state.enabled);
|
||||
assert_eq!(merged[0].state.installed_at, Some(500));
|
||||
assert_eq!(merged[0].state.last_activated_at, Some(600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_defaults_to_enabled_when_no_persisted() {
|
||||
let exts = vec![make_test_ext("ext-a", true)];
|
||||
let merged = merge_persisted_states(exts, &HashMap::new());
|
||||
assert!(merged[0].state.enabled);
|
||||
}
|
||||
|
||||
// -- build_state_map ------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn build_state_map_includes_all_extensions() {
|
||||
let exts = vec![make_test_ext("ext-a", true), make_test_ext("ext-b", false)];
|
||||
let map = build_state_map(&exts);
|
||||
assert_eq!(map.len(), 2);
|
||||
assert!(map["ext-a"].enabled);
|
||||
assert!(!map["ext-b"].enabled);
|
||||
}
|
||||
|
||||
// -- to_summary -----------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn summary_maps_fields_correctly() {
|
||||
let ext = make_test_ext("my-ext", true);
|
||||
let summary = to_summary(&ext);
|
||||
assert_eq!(summary.name, "my-ext");
|
||||
assert_eq!(summary.version, "1.0.0");
|
||||
assert_eq!(summary.display_name.as_deref(), Some("my-ext Display"));
|
||||
assert!(summary.enabled);
|
||||
assert_eq!(summary.source, ExtensionSource::Local);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use std::path::Path;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::asset_paths::resolve_extension_asset_url;
|
||||
use crate::error::ExtensionError;
|
||||
use crate::template::resolve_env_map;
|
||||
use crate::types::{ExtAcpAdapter, ResolvedAcpAdapter};
|
||||
|
||||
/// Resolve a single ACP adapter contribution.
|
||||
///
|
||||
/// Env template placeholders (`${VAR}`) in the `env` map are expanded.
|
||||
/// Avatar paths are resolved relative to the extension directory.
|
||||
pub fn resolve_acp_adapter(
|
||||
adapter: &ExtAcpAdapter,
|
||||
extension_name: &str,
|
||||
_ext_dir: &Path,
|
||||
) -> Result<ResolvedAcpAdapter, ExtensionError> {
|
||||
let resolved_env = resolve_env_map(&adapter.env, false)?;
|
||||
|
||||
let avatar = adapter
|
||||
.avatar
|
||||
.as_ref()
|
||||
.and_then(|a| resolve_extension_asset_url(extension_name, a));
|
||||
|
||||
Ok(ResolvedAcpAdapter {
|
||||
extension_name: extension_name.to_owned(),
|
||||
id: adapter.id.clone(),
|
||||
name: adapter.name.clone(),
|
||||
description: adapter.description.clone(),
|
||||
cli_command: adapter.cli_command.clone(),
|
||||
default_cli_path: adapter.default_cli_path.clone(),
|
||||
acp_args: adapter.acp_args.clone(),
|
||||
env: resolved_env,
|
||||
avatar,
|
||||
auth_required: adapter.auth_required,
|
||||
supports_streaming: adapter.supports_streaming,
|
||||
connection_type: adapter.connection_type.clone(),
|
||||
endpoint: adapter.endpoint.clone(),
|
||||
models: adapter.models.clone(),
|
||||
yolo_mode: adapter.yolo_mode.clone(),
|
||||
health_check: adapter.health_check.clone(),
|
||||
api_key_fields: adapter.api_key_fields.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve all ACP adapter contributions from an extension.
|
||||
pub fn resolve_acp_adapters(
|
||||
adapters: &[ExtAcpAdapter],
|
||||
extension_name: &str,
|
||||
ext_dir: &Path,
|
||||
) -> Vec<ResolvedAcpAdapter> {
|
||||
adapters
|
||||
.iter()
|
||||
.filter_map(|a| {
|
||||
resolve_acp_adapter(a, extension_name, ext_dir)
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
extension = extension_name,
|
||||
adapter_id = a.id,
|
||||
"Failed to resolve ACP adapter: {e}"
|
||||
);
|
||||
e
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_adapter(env: HashMap<String, String>) -> ExtAcpAdapter {
|
||||
ExtAcpAdapter {
|
||||
id: "test-adapter".into(),
|
||||
name: "Test Adapter".into(),
|
||||
description: Some("A test adapter".into()),
|
||||
cli_command: Some("test-cli".into()),
|
||||
default_cli_path: None,
|
||||
acp_args: vec!["--verbose".into()],
|
||||
env,
|
||||
avatar: Some("icons/avatar.png".into()),
|
||||
auth_required: Some(true),
|
||||
supports_streaming: Some(true),
|
||||
connection_type: Some("stdio".into()),
|
||||
endpoint: None,
|
||||
models: vec!["model-a".into()],
|
||||
yolo_mode: None,
|
||||
health_check: None,
|
||||
api_key_fields: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_basic_adapter() {
|
||||
let adapter = make_adapter(HashMap::new());
|
||||
let result = resolve_acp_adapter(&adapter, "my-ext", Path::new("/ext/my-ext")).unwrap();
|
||||
|
||||
assert_eq!(result.extension_name, "my-ext");
|
||||
assert_eq!(result.id, "test-adapter");
|
||||
assert_eq!(result.name, "Test Adapter");
|
||||
assert_eq!(result.cli_command.as_deref(), Some("test-cli"));
|
||||
assert_eq!(result.acp_args, vec!["--verbose"]);
|
||||
assert!(result.avatar.as_ref().unwrap().contains("icons/avatar.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_adapter_env_templates() {
|
||||
unsafe { std::env::set_var("_TEST_ACP_KEY", "secret123") };
|
||||
let mut env = HashMap::new();
|
||||
env.insert("API_KEY".into(), "${_TEST_ACP_KEY}".into());
|
||||
env.insert("STATIC".into(), "fixed".into());
|
||||
|
||||
let adapter = make_adapter(env);
|
||||
let result = resolve_acp_adapter(&adapter, "my-ext", Path::new("/ext/my-ext")).unwrap();
|
||||
|
||||
assert_eq!(result.env["API_KEY"], "secret123");
|
||||
assert_eq!(result.env["STATIC"], "fixed");
|
||||
unsafe { std::env::remove_var("_TEST_ACP_KEY") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_adapter_undefined_env_lenient() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("KEY".into(), "${_NONEXISTENT_ACP_VAR}".into());
|
||||
|
||||
let adapter = make_adapter(env);
|
||||
let result = resolve_acp_adapter(&adapter, "my-ext", Path::new("/ext/my-ext")).unwrap();
|
||||
|
||||
assert_eq!(result.env["KEY"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_adapters_skips_failures() {
|
||||
// With an empty list, we get an empty result.
|
||||
let result = resolve_acp_adapters(&[], "my-ext", Path::new("/ext/my-ext"));
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_adapter_no_avatar() {
|
||||
let mut adapter = make_adapter(HashMap::new());
|
||||
adapter.avatar = None;
|
||||
let result = resolve_acp_adapter(&adapter, "my-ext", Path::new("/ext/my-ext")).unwrap();
|
||||
assert!(result.avatar.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
use std::path::Path;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::asset_paths::resolve_extension_asset_url;
|
||||
use crate::error::ExtensionError;
|
||||
use crate::template::resolve_file_reference;
|
||||
use crate::types::{ExtAgent, ResolvedAgent};
|
||||
|
||||
/// Resolve a single agent contribution.
|
||||
///
|
||||
/// The `context` field supports `@file:` references.
|
||||
pub fn resolve_agent(agent: &ExtAgent, extension_name: &str, ext_dir: &Path) -> Result<ResolvedAgent, ExtensionError> {
|
||||
let context = agent
|
||||
.context
|
||||
.as_deref()
|
||||
.map(|v| resolve_file_reference(v, ext_dir))
|
||||
.transpose()?;
|
||||
|
||||
let icon = agent
|
||||
.icon
|
||||
.as_deref()
|
||||
.and_then(|value| resolve_extension_asset_url(extension_name, value));
|
||||
|
||||
Ok(ResolvedAgent {
|
||||
extension_name: extension_name.to_owned(),
|
||||
id: agent.id.clone(),
|
||||
name: agent.name.clone(),
|
||||
description: agent.description.clone(),
|
||||
agent_type: agent.agent_type.clone(),
|
||||
context,
|
||||
icon,
|
||||
enabled_skills: agent.enabled_skills.clone(),
|
||||
prompts: agent.prompts.clone(),
|
||||
models: agent.models.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve all agent contributions from an extension.
|
||||
pub fn resolve_agents(agents: &[ExtAgent], extension_name: &str, ext_dir: &Path) -> Vec<ResolvedAgent> {
|
||||
agents
|
||||
.iter()
|
||||
.filter_map(|a| {
|
||||
resolve_agent(a, extension_name, ext_dir)
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
extension = extension_name,
|
||||
agent_id = a.id,
|
||||
"Failed to resolve agent: {e}"
|
||||
);
|
||||
e
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_agent_plain_text() {
|
||||
let agent = ExtAgent {
|
||||
id: "agent-1".into(),
|
||||
name: "My Agent".into(),
|
||||
description: Some("Autonomous agent".into()),
|
||||
agent_type: Some("claude".into()),
|
||||
context: Some("You are an agent.".into()),
|
||||
icon: None,
|
||||
enabled_skills: vec![],
|
||||
prompts: vec![],
|
||||
models: vec![],
|
||||
};
|
||||
|
||||
let result = resolve_agent(&agent, "my-ext", Path::new("/ext/my-ext")).unwrap();
|
||||
|
||||
assert_eq!(result.extension_name, "my-ext");
|
||||
assert_eq!(result.id, "agent-1");
|
||||
assert_eq!(result.agent_type.as_deref(), Some("claude"));
|
||||
assert_eq!(result.context.as_deref(), Some("You are an agent."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_agent_file_reference() {
|
||||
let dir = std::env::temp_dir().join("ext_test_resolve_agent");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("agent_ctx.md"), "Agent context from file").unwrap();
|
||||
|
||||
let agent = ExtAgent {
|
||||
id: "agent-2".into(),
|
||||
name: "File Agent".into(),
|
||||
description: None,
|
||||
agent_type: None,
|
||||
context: Some("@file:agent_ctx.md".into()),
|
||||
icon: None,
|
||||
enabled_skills: vec![],
|
||||
prompts: vec![],
|
||||
models: vec![],
|
||||
};
|
||||
|
||||
let result = resolve_agent(&agent, "my-ext", &dir).unwrap();
|
||||
assert_eq!(result.context.as_deref(), Some("Agent context from file"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_agent_missing_file_error() {
|
||||
let agent = ExtAgent {
|
||||
id: "agent-3".into(),
|
||||
name: "Bad Agent".into(),
|
||||
description: None,
|
||||
agent_type: None,
|
||||
context: Some("@file:missing.md".into()),
|
||||
icon: None,
|
||||
enabled_skills: vec![],
|
||||
prompts: vec![],
|
||||
models: vec![],
|
||||
};
|
||||
|
||||
let err = resolve_agent(&agent, "my-ext", Path::new("/tmp/no_such_ext_dir")).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::FileReferenceNotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_agent_no_context() {
|
||||
let agent = ExtAgent {
|
||||
id: "agent-4".into(),
|
||||
name: "No Context".into(),
|
||||
description: None,
|
||||
agent_type: None,
|
||||
context: None,
|
||||
icon: None,
|
||||
enabled_skills: vec![],
|
||||
prompts: vec![],
|
||||
models: vec![],
|
||||
};
|
||||
|
||||
let result = resolve_agent(&agent, "my-ext", Path::new("/ext/my-ext")).unwrap();
|
||||
assert!(result.context.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
use std::path::Path;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::asset_paths::resolve_extension_asset_url;
|
||||
use crate::error::ExtensionError;
|
||||
use crate::template::resolve_file_reference;
|
||||
use crate::types::{ExtAssistant, ResolvedAssistant};
|
||||
|
||||
/// Resolve a single assistant contribution.
|
||||
///
|
||||
/// Long-text fields (`system_prompt`, `context`) support `@file:` references
|
||||
/// that are replaced with the referenced file's content.
|
||||
pub fn resolve_assistant(
|
||||
assistant: &ExtAssistant,
|
||||
extension_name: &str,
|
||||
ext_dir: &Path,
|
||||
) -> Result<ResolvedAssistant, ExtensionError> {
|
||||
let system_prompt = assistant
|
||||
.system_prompt
|
||||
.as_deref()
|
||||
.map(|v| resolve_file_reference(v, ext_dir))
|
||||
.transpose()?;
|
||||
|
||||
let context = assistant
|
||||
.context
|
||||
.as_deref()
|
||||
.map(|v| resolve_file_reference(v, ext_dir))
|
||||
.transpose()?;
|
||||
|
||||
let icon = assistant
|
||||
.icon
|
||||
.as_deref()
|
||||
.and_then(|value| resolve_extension_asset_url(extension_name, value));
|
||||
|
||||
Ok(ResolvedAssistant {
|
||||
extension_name: extension_name.to_owned(),
|
||||
id: assistant.id.clone(),
|
||||
name: assistant.name.clone(),
|
||||
description: assistant.description.clone(),
|
||||
system_prompt,
|
||||
icon,
|
||||
context,
|
||||
preset_agent_type: assistant.preset_agent_type.clone(),
|
||||
enabled_skills: assistant.enabled_skills.clone(),
|
||||
prompts: assistant.prompts.clone(),
|
||||
models: assistant.models.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve all assistant contributions from an extension.
|
||||
pub fn resolve_assistants(assistants: &[ExtAssistant], extension_name: &str, ext_dir: &Path) -> Vec<ResolvedAssistant> {
|
||||
assistants
|
||||
.iter()
|
||||
.filter_map(|a| {
|
||||
resolve_assistant(a, extension_name, ext_dir)
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
extension = extension_name,
|
||||
assistant_id = a.id,
|
||||
"Failed to resolve assistant: {e}"
|
||||
);
|
||||
e
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_assistant_plain_text() {
|
||||
let assistant = ExtAssistant {
|
||||
id: "asst-1".into(),
|
||||
name: "Helper".into(),
|
||||
description: Some("A helpful assistant".into()),
|
||||
system_prompt: Some("You are helpful.".into()),
|
||||
icon: None,
|
||||
context: None,
|
||||
preset_agent_type: None,
|
||||
enabled_skills: vec![],
|
||||
prompts: vec![],
|
||||
models: vec![],
|
||||
};
|
||||
|
||||
let result = resolve_assistant(&assistant, "my-ext", Path::new("/ext/my-ext")).unwrap();
|
||||
|
||||
assert_eq!(result.extension_name, "my-ext");
|
||||
assert_eq!(result.id, "asst-1");
|
||||
assert_eq!(result.system_prompt.as_deref(), Some("You are helpful."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_assistant_file_reference() {
|
||||
let dir = std::env::temp_dir().join("ext_test_resolve_assistant");
|
||||
let prompts = dir.join("prompts");
|
||||
std::fs::create_dir_all(&prompts).unwrap();
|
||||
std::fs::write(prompts.join("system.md"), "Loaded from file").unwrap();
|
||||
|
||||
let assistant = ExtAssistant {
|
||||
id: "asst-2".into(),
|
||||
name: "File Ref".into(),
|
||||
description: None,
|
||||
system_prompt: Some("@file:prompts/system.md".into()),
|
||||
icon: None,
|
||||
context: None,
|
||||
preset_agent_type: None,
|
||||
enabled_skills: vec![],
|
||||
prompts: vec![],
|
||||
models: vec![],
|
||||
};
|
||||
|
||||
let result = resolve_assistant(&assistant, "my-ext", &dir).unwrap();
|
||||
assert_eq!(result.system_prompt.as_deref(), Some("Loaded from file"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_assistant_file_not_found_error() {
|
||||
let assistant = ExtAssistant {
|
||||
id: "asst-3".into(),
|
||||
name: "Bad Ref".into(),
|
||||
description: None,
|
||||
system_prompt: Some("@file:missing.md".into()),
|
||||
icon: None,
|
||||
context: None,
|
||||
preset_agent_type: None,
|
||||
enabled_skills: vec![],
|
||||
prompts: vec![],
|
||||
models: vec![],
|
||||
};
|
||||
|
||||
let err = resolve_assistant(&assistant, "my-ext", Path::new("/tmp/no_such_ext_dir")).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::FileReferenceNotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_assistant_context_file_reference() {
|
||||
let dir = std::env::temp_dir().join("ext_test_resolve_assistant_ctx");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("context.md"), "Context content").unwrap();
|
||||
|
||||
let assistant = ExtAssistant {
|
||||
id: "asst-4".into(),
|
||||
name: "Ctx Ref".into(),
|
||||
description: None,
|
||||
system_prompt: None,
|
||||
icon: None,
|
||||
context: Some("@file:context.md".into()),
|
||||
preset_agent_type: None,
|
||||
enabled_skills: vec![],
|
||||
prompts: vec![],
|
||||
models: vec![],
|
||||
};
|
||||
|
||||
let result = resolve_assistant(&assistant, "my-ext", &dir).unwrap();
|
||||
assert_eq!(result.context.as_deref(), Some("Context content"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_assistants_skips_bad_refs() {
|
||||
let assistants = vec![
|
||||
ExtAssistant {
|
||||
id: "good".into(),
|
||||
name: "Good".into(),
|
||||
description: None,
|
||||
system_prompt: Some("plain text".into()),
|
||||
icon: None,
|
||||
context: None,
|
||||
preset_agent_type: None,
|
||||
enabled_skills: vec![],
|
||||
prompts: vec![],
|
||||
models: vec![],
|
||||
},
|
||||
ExtAssistant {
|
||||
id: "bad".into(),
|
||||
name: "Bad".into(),
|
||||
description: None,
|
||||
system_prompt: Some("@file:missing.md".into()),
|
||||
icon: None,
|
||||
context: None,
|
||||
preset_agent_type: None,
|
||||
enabled_skills: vec![],
|
||||
prompts: vec![],
|
||||
models: vec![],
|
||||
},
|
||||
];
|
||||
|
||||
let result = resolve_assistants(&assistants, "my-ext", Path::new("/tmp/no_such_ext"));
|
||||
// Only the good one should be resolved
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].id, "good");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::asset_paths::resolve_extension_asset_url;
|
||||
use crate::types::{ExtChannelPlugin, ResolvedChannelPlugin};
|
||||
|
||||
/// Resolve a single channel plugin contribution.
|
||||
///
|
||||
/// Entry point paths are resolved relative to the extension directory.
|
||||
/// Note: actual plugin code execution must go through the sandbox (not direct eval).
|
||||
pub fn resolve_channel_plugin(
|
||||
plugin: &ExtChannelPlugin,
|
||||
extension_name: &str,
|
||||
ext_dir: &Path,
|
||||
) -> ResolvedChannelPlugin {
|
||||
let entry_point = plugin
|
||||
.entry_point
|
||||
.as_ref()
|
||||
.map(|ep| ext_dir.join(ep).to_string_lossy().into_owned());
|
||||
let icon = plugin
|
||||
.icon
|
||||
.as_deref()
|
||||
.and_then(|value| resolve_extension_asset_url(extension_name, value));
|
||||
|
||||
ResolvedChannelPlugin {
|
||||
extension_name: extension_name.to_owned(),
|
||||
id: plugin.id.clone(),
|
||||
name: plugin.name.clone(),
|
||||
description: plugin.description.clone(),
|
||||
platform: plugin.platform.clone(),
|
||||
entry_point,
|
||||
icon,
|
||||
credential_fields: plugin.credential_fields.clone(),
|
||||
config_fields: plugin.config_fields.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve all channel plugin contributions from an extension.
|
||||
pub fn resolve_channel_plugins(
|
||||
plugins: &[ExtChannelPlugin],
|
||||
extension_name: &str,
|
||||
ext_dir: &Path,
|
||||
) -> Vec<ResolvedChannelPlugin> {
|
||||
plugins
|
||||
.iter()
|
||||
.map(|p| resolve_channel_plugin(p, extension_name, ext_dir))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_channel_plugin_with_entry() {
|
||||
let plugin = ExtChannelPlugin {
|
||||
id: "slack-plugin".into(),
|
||||
name: "Slack".into(),
|
||||
description: Some("Slack integration".into()),
|
||||
platform: Some("slack".into()),
|
||||
entry_point: Some("plugins/slack.js".into()),
|
||||
icon: None,
|
||||
credential_fields: vec![],
|
||||
config_fields: vec![],
|
||||
};
|
||||
|
||||
let result = resolve_channel_plugin(&plugin, "my-ext", Path::new("/ext/my-ext"));
|
||||
|
||||
assert_eq!(result.extension_name, "my-ext");
|
||||
assert_eq!(result.id, "slack-plugin");
|
||||
assert_eq!(result.platform.as_deref(), Some("slack"));
|
||||
assert!(result.entry_point.as_ref().unwrap().contains("plugins/slack.js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_channel_plugin_no_entry() {
|
||||
let plugin = ExtChannelPlugin {
|
||||
id: "simple".into(),
|
||||
name: "Simple".into(),
|
||||
description: None,
|
||||
platform: None,
|
||||
entry_point: None,
|
||||
icon: None,
|
||||
credential_fields: vec![],
|
||||
config_fields: vec![],
|
||||
};
|
||||
|
||||
let result = resolve_channel_plugin(&plugin, "my-ext", Path::new("/ext/my-ext"));
|
||||
assert!(result.entry_point.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_channel_plugins_empty() {
|
||||
let result = resolve_channel_plugins(&[], "my-ext", Path::new("/ext/my-ext"));
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::error::ExtensionError;
|
||||
use crate::types::I18nConfig;
|
||||
|
||||
/// Load i18n messages for a specific locale from an extension.
|
||||
///
|
||||
/// Looks for `{ext_dir}/{directory}/{locale}.json` where `directory` defaults to "i18n".
|
||||
/// Returns a flat key-value map of message strings.
|
||||
pub fn load_extension_i18n(
|
||||
i18n_config: &I18nConfig,
|
||||
locale: &str,
|
||||
extension_name: &str,
|
||||
ext_dir: &Path,
|
||||
) -> Result<HashMap<String, String>, ExtensionError> {
|
||||
if !i18n_config.locales.contains(&locale.to_owned()) {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let i18n_dir = ext_dir.join(&i18n_config.directory);
|
||||
let file_path = i18n_dir.join(format!("{locale}.json"));
|
||||
|
||||
if !file_path.exists() {
|
||||
tracing::debug!(
|
||||
extension = extension_name,
|
||||
locale = locale,
|
||||
path = %file_path.display(),
|
||||
"i18n file not found, returning empty map"
|
||||
);
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&file_path)?;
|
||||
let messages: HashMap<String, String> =
|
||||
serde_json::from_str(&content).map_err(|e| ExtensionError::ResolutionFailed {
|
||||
extension_name: extension_name.to_owned(),
|
||||
reason: format!("Invalid i18n JSON for locale '{locale}': {e}"),
|
||||
})?;
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Load i18n data for a given locale across multiple extensions.
|
||||
///
|
||||
/// Returns `HashMap<extension_name, HashMap<key, value>>`.
|
||||
pub fn resolve_i18n_for_locale(
|
||||
extensions: &[(String, Option<I18nConfig>, String)], // (name, i18n_config, ext_dir)
|
||||
locale: &str,
|
||||
) -> HashMap<String, HashMap<String, String>> {
|
||||
let mut result = HashMap::new();
|
||||
|
||||
for (name, i18n_config, ext_dir) in extensions {
|
||||
let Some(config) = i18n_config else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match load_extension_i18n(config, locale, name, Path::new(ext_dir)) {
|
||||
Ok(messages) if !messages.is_empty() => {
|
||||
result.insert(name.clone(), messages);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
extension = name.as_str(),
|
||||
locale = locale,
|
||||
"Failed to load i18n data: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_i18n_config(locales: Vec<&str>) -> I18nConfig {
|
||||
I18nConfig {
|
||||
locales: locales.into_iter().map(String::from).collect(),
|
||||
directory: "i18n".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_i18n_supported_locale() {
|
||||
let dir = std::env::temp_dir().join("ext_test_i18n_load");
|
||||
let i18n_dir = dir.join("i18n");
|
||||
std::fs::create_dir_all(&i18n_dir).unwrap();
|
||||
std::fs::write(
|
||||
i18n_dir.join("en.json"),
|
||||
r#"{"greeting": "Hello", "farewell": "Goodbye"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = make_i18n_config(vec!["en", "zh-CN"]);
|
||||
let result = load_extension_i18n(&config, "en", "my-ext", &dir).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result["greeting"], "Hello");
|
||||
assert_eq!(result["farewell"], "Goodbye");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_i18n_unsupported_locale_returns_empty() {
|
||||
let config = make_i18n_config(vec!["en"]);
|
||||
let result = load_extension_i18n(&config, "fr", "my-ext", Path::new("/tmp")).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_i18n_file_not_found_returns_empty() {
|
||||
let dir = std::env::temp_dir().join("ext_test_i18n_missing");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let config = make_i18n_config(vec!["en"]);
|
||||
let result = load_extension_i18n(&config, "en", "my-ext", &dir).unwrap();
|
||||
assert!(result.is_empty());
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_i18n_invalid_json_returns_error() {
|
||||
let dir = std::env::temp_dir().join("ext_test_i18n_bad_json");
|
||||
let i18n_dir = dir.join("i18n");
|
||||
std::fs::create_dir_all(&i18n_dir).unwrap();
|
||||
std::fs::write(i18n_dir.join("en.json"), "not valid json").unwrap();
|
||||
|
||||
let config = make_i18n_config(vec!["en"]);
|
||||
let err = load_extension_i18n(&config, "en", "my-ext", &dir).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::ResolutionFailed { .. }));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_i18n_for_locale_multiple_extensions() {
|
||||
let dir1 = std::env::temp_dir().join("ext_test_i18n_multi_1");
|
||||
let dir2 = std::env::temp_dir().join("ext_test_i18n_multi_2");
|
||||
let i18n1 = dir1.join("i18n");
|
||||
let i18n2 = dir2.join("i18n");
|
||||
std::fs::create_dir_all(&i18n1).unwrap();
|
||||
std::fs::create_dir_all(&i18n2).unwrap();
|
||||
std::fs::write(i18n1.join("en.json"), r#"{"key1": "val1"}"#).unwrap();
|
||||
std::fs::write(i18n2.join("en.json"), r#"{"key2": "val2"}"#).unwrap();
|
||||
|
||||
let extensions = vec![
|
||||
(
|
||||
"ext-a".to_owned(),
|
||||
Some(make_i18n_config(vec!["en"])),
|
||||
dir1.to_string_lossy().into_owned(),
|
||||
),
|
||||
(
|
||||
"ext-b".to_owned(),
|
||||
Some(make_i18n_config(vec!["en"])),
|
||||
dir2.to_string_lossy().into_owned(),
|
||||
),
|
||||
(
|
||||
"ext-c".to_owned(),
|
||||
None, // no i18n config
|
||||
"/tmp".to_owned(),
|
||||
),
|
||||
];
|
||||
|
||||
let result = resolve_i18n_for_locale(&extensions, "en");
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result["ext-a"]["key1"], "val1");
|
||||
assert_eq!(result["ext-b"]["key2"], "val2");
|
||||
|
||||
std::fs::remove_dir_all(&dir1).unwrap();
|
||||
std::fs::remove_dir_all(&dir2).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use tracing::warn;
|
||||
|
||||
use crate::types::{ExtMcpServer, ResolvedMcpServer};
|
||||
|
||||
/// Resolve a single MCP server contribution.
|
||||
///
|
||||
/// MCP server config is passed through as-is (opaque JSON).
|
||||
pub fn resolve_mcp_server(server: &ExtMcpServer, extension_name: &str) -> ResolvedMcpServer {
|
||||
ResolvedMcpServer {
|
||||
extension_name: extension_name.to_owned(),
|
||||
id: server.id.clone(),
|
||||
name: server.name.clone(),
|
||||
description: server.description.clone(),
|
||||
config: server.config.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve all MCP server contributions from an extension.
|
||||
pub fn resolve_mcp_servers(servers: &[ExtMcpServer], extension_name: &str) -> Vec<ResolvedMcpServer> {
|
||||
if servers.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
tracing::debug!(
|
||||
extension = extension_name,
|
||||
count = servers.len(),
|
||||
"Resolving MCP servers"
|
||||
);
|
||||
servers
|
||||
.iter()
|
||||
.inspect(|s| {
|
||||
if s.id.is_empty() || s.name.is_empty() {
|
||||
warn!(
|
||||
extension = extension_name,
|
||||
server_id = s.id,
|
||||
"MCP server has empty id or name"
|
||||
);
|
||||
}
|
||||
})
|
||||
.map(|s| resolve_mcp_server(s, extension_name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_server() -> ExtMcpServer {
|
||||
ExtMcpServer {
|
||||
id: "test-mcp".into(),
|
||||
name: "Test MCP".into(),
|
||||
description: Some("A test MCP server".into()),
|
||||
config: serde_json::json!({
|
||||
"command": "npx",
|
||||
"args": ["-y", "test-server"]
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_basic_mcp_server() {
|
||||
let server = make_server();
|
||||
let result = resolve_mcp_server(&server, "my-ext");
|
||||
|
||||
assert_eq!(result.extension_name, "my-ext");
|
||||
assert_eq!(result.id, "test-mcp");
|
||||
assert_eq!(result.name, "Test MCP");
|
||||
assert_eq!(result.config["command"], "npx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_mcp_servers_empty() {
|
||||
let result = resolve_mcp_servers(&[], "my-ext");
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_mcp_servers_multiple() {
|
||||
let servers = vec![make_server(), make_server()];
|
||||
let result = resolve_mcp_servers(&servers, "my-ext");
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Contribution resolvers — transform raw manifest declarations into
|
||||
//! runtime-ready structures.
|
||||
//!
|
||||
//! Each sub-module handles one contribution type. The top-level
|
||||
//! [`resolve_all_contributions`] orchestrates resolution across all
|
||||
//! enabled extensions.
|
||||
|
||||
pub mod acp_adapter;
|
||||
pub mod agent;
|
||||
pub mod assistant;
|
||||
pub mod channel_plugin;
|
||||
pub mod i18n;
|
||||
pub mod mcp_server;
|
||||
pub mod model_provider;
|
||||
pub mod settings_tab;
|
||||
pub mod skill;
|
||||
pub mod theme;
|
||||
pub mod webui;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::types::{LoadedExtension, ResolvedContributions};
|
||||
|
||||
/// Resolve all contributions from a single extension.
|
||||
///
|
||||
/// Failures in individual contribution types are logged and skipped —
|
||||
/// one broken theme does not block ACP adapter resolution.
|
||||
pub fn resolve_extension_contributions(ext: &LoadedExtension) -> ResolvedContributions {
|
||||
let ext_name = &ext.manifest.name;
|
||||
let ext_dir = Path::new(&ext.directory);
|
||||
|
||||
let contributes = match &ext.manifest.contributes {
|
||||
Some(c) => c,
|
||||
None => return ResolvedContributions::default(),
|
||||
};
|
||||
|
||||
ResolvedContributions {
|
||||
acp_adapters: acp_adapter::resolve_acp_adapters(&contributes.acp_adapters, ext_name, ext_dir),
|
||||
mcp_servers: mcp_server::resolve_mcp_servers(&contributes.mcp_servers, ext_name),
|
||||
assistants: assistant::resolve_assistants(&contributes.assistants, ext_name, ext_dir),
|
||||
agents: agent::resolve_agents(&contributes.agents, ext_name, ext_dir),
|
||||
skills: skill::resolve_skills(&contributes.skills, ext_name, ext_dir),
|
||||
themes: theme::resolve_themes(&contributes.themes, ext_name, ext_dir),
|
||||
channel_plugins: channel_plugin::resolve_channel_plugins(&contributes.channel_plugins, ext_name, ext_dir),
|
||||
webui: webui::resolve_webui_contributions(&contributes.webui, ext_name, ext_dir),
|
||||
settings_tabs: settings_tab::resolve_settings_tabs(&contributes.settings_tabs, ext_name, ext_dir),
|
||||
model_providers: model_provider::resolve_model_providers(&contributes.model_providers, ext_name),
|
||||
// i18n is resolved separately via resolve_i18n_for_locale()
|
||||
// because it requires a locale parameter at query time.
|
||||
i18n: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve contributions from all enabled extensions.
|
||||
///
|
||||
/// Extensions that are disabled (`state.enabled == false`) are skipped.
|
||||
pub fn resolve_all_contributions(extensions: &[LoadedExtension]) -> ResolvedContributions {
|
||||
let mut merged = ResolvedContributions::default();
|
||||
|
||||
for ext in extensions {
|
||||
if !ext.state.enabled {
|
||||
tracing::debug!(extension = ext.manifest.name, "Skipping disabled extension");
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolved = resolve_extension_contributions(ext);
|
||||
merge_contributions(&mut merged, resolved, &ext.manifest.name);
|
||||
}
|
||||
|
||||
merged
|
||||
.settings_tabs
|
||||
.sort_by(|left, right| left.order.cmp(&right.order).then_with(|| left.label.cmp(&right.label)));
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
/// Merge `source` contributions into `target`.
|
||||
fn merge_contributions(target: &mut ResolvedContributions, source: ResolvedContributions, extension_name: &str) {
|
||||
if !source.acp_adapters.is_empty() {
|
||||
tracing::debug!(
|
||||
extension = extension_name,
|
||||
count = source.acp_adapters.len(),
|
||||
"Merged ACP adapters"
|
||||
);
|
||||
}
|
||||
|
||||
target.acp_adapters.extend(source.acp_adapters);
|
||||
target.mcp_servers.extend(source.mcp_servers);
|
||||
target.assistants.extend(source.assistants);
|
||||
target.agents.extend(source.agents);
|
||||
target.skills.extend(source.skills);
|
||||
target.themes.extend(source.themes);
|
||||
target.channel_plugins.extend(source.channel_plugins);
|
||||
target.webui.extend(source.webui);
|
||||
target.settings_tabs.extend(source.settings_tabs);
|
||||
target.model_providers.extend(source.model_providers);
|
||||
target.i18n.extend(source.i18n);
|
||||
}
|
||||
|
||||
/// Convenience: resolve i18n data for a given locale across all enabled extensions.
|
||||
pub fn resolve_i18n_for_all(
|
||||
extensions: &[LoadedExtension],
|
||||
locale: &str,
|
||||
) -> std::collections::HashMap<String, std::collections::HashMap<String, String>> {
|
||||
let ext_data: Vec<(String, Option<crate::types::I18nConfig>, String)> = extensions
|
||||
.iter()
|
||||
.filter(|ext| ext.state.enabled)
|
||||
.map(|ext| {
|
||||
(
|
||||
ext.manifest.name.clone(),
|
||||
ext.manifest.i18n.clone(),
|
||||
ext.directory.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
i18n::resolve_i18n_for_locale(&ext_data, locale)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_extension(name: &str, enabled: bool, contributes: Option<ExtContributes>) -> LoadedExtension {
|
||||
LoadedExtension {
|
||||
manifest: ExtensionManifest {
|
||||
name: name.to_owned(),
|
||||
version: "1.0.0".to_owned(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
license: None,
|
||||
homepage: None,
|
||||
icon: None,
|
||||
engine: None,
|
||||
api_version: None,
|
||||
dependencies: HashMap::new(),
|
||||
entry_point: None,
|
||||
permissions: None,
|
||||
contributes,
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
},
|
||||
directory: "/tmp/ext".to_owned(),
|
||||
source: ExtensionSource::Local,
|
||||
state: ExtensionState {
|
||||
name: name.to_owned(),
|
||||
version: "1.0.0".to_owned(),
|
||||
enabled,
|
||||
installed_at: None,
|
||||
last_activated_at: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_extension_no_contributes() {
|
||||
let ext = make_extension("empty-ext", true, None);
|
||||
let result = resolve_extension_contributions(&ext);
|
||||
assert!(result.acp_adapters.is_empty());
|
||||
assert!(result.mcp_servers.is_empty());
|
||||
assert!(result.assistants.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_extension_with_model_providers() {
|
||||
let contributes = ExtContributes {
|
||||
model_providers: vec![ExtModelProvider {
|
||||
id: "mp-1".into(),
|
||||
name: "Test Provider".into(),
|
||||
description: None,
|
||||
protocol: None,
|
||||
base_url: None,
|
||||
models: vec![],
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ext = make_extension("provider-ext", true, Some(contributes));
|
||||
let result = resolve_extension_contributions(&ext);
|
||||
assert_eq!(result.model_providers.len(), 1);
|
||||
assert_eq!(result.model_providers[0].extension_name, "provider-ext");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_all_skips_disabled() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("skills")).unwrap();
|
||||
std::fs::write(dir.path().join("skills/my-skill.md"), "# skill").unwrap();
|
||||
|
||||
let enabled = make_extension(
|
||||
"enabled-ext",
|
||||
true,
|
||||
Some(ExtContributes {
|
||||
skills: vec![ExtSkill {
|
||||
name: "my-skill".into(),
|
||||
description: None,
|
||||
path: Some("skills/my-skill.md".into()),
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let enabled = LoadedExtension {
|
||||
directory: dir.path().to_string_lossy().into_owned(),
|
||||
..enabled
|
||||
};
|
||||
let disabled = make_extension(
|
||||
"disabled-ext",
|
||||
false,
|
||||
Some(ExtContributes {
|
||||
skills: vec![ExtSkill {
|
||||
name: "hidden-skill".into(),
|
||||
description: None,
|
||||
path: Some("skills/hidden-skill.md".into()),
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let disabled = LoadedExtension {
|
||||
directory: dir.path().to_string_lossy().into_owned(),
|
||||
..disabled
|
||||
};
|
||||
|
||||
let result = resolve_all_contributions(&[enabled, disabled]);
|
||||
assert_eq!(result.skills.len(), 1);
|
||||
assert_eq!(result.skills[0].name, "my-skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_all_merges_multiple_extensions() {
|
||||
let ext_a = make_extension(
|
||||
"ext-a",
|
||||
true,
|
||||
Some(ExtContributes {
|
||||
mcp_servers: vec![ExtMcpServer {
|
||||
id: "mcp-a".into(),
|
||||
name: "MCP A".into(),
|
||||
description: None,
|
||||
config: serde_json::json!({}),
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let ext_b = make_extension(
|
||||
"ext-b",
|
||||
true,
|
||||
Some(ExtContributes {
|
||||
mcp_servers: vec![ExtMcpServer {
|
||||
id: "mcp-b".into(),
|
||||
name: "MCP B".into(),
|
||||
description: None,
|
||||
config: serde_json::json!({}),
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
|
||||
let result = resolve_all_contributions(&[ext_a, ext_b]);
|
||||
assert_eq!(result.mcp_servers.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_all_empty_extensions() {
|
||||
let result = resolve_all_contributions(&[]);
|
||||
assert!(result.acp_adapters.is_empty());
|
||||
assert!(result.i18n.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_all_sorts_settings_tabs_globally() {
|
||||
let ext_a = make_extension(
|
||||
"ext-a",
|
||||
true,
|
||||
Some(ExtContributes {
|
||||
settings_tabs: vec![ExtSettingsTab {
|
||||
id: "zeta".into(),
|
||||
label: "Zeta".into(),
|
||||
icon: None,
|
||||
url: "settings/zeta.html".into(),
|
||||
position: None,
|
||||
order: 100,
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let ext_b = make_extension(
|
||||
"ext-b",
|
||||
true,
|
||||
Some(ExtContributes {
|
||||
settings_tabs: vec![
|
||||
ExtSettingsTab {
|
||||
id: "alpha".into(),
|
||||
label: "Alpha".into(),
|
||||
icon: None,
|
||||
url: "settings/alpha.html".into(),
|
||||
position: None,
|
||||
order: 50,
|
||||
},
|
||||
ExtSettingsTab {
|
||||
id: "beta".into(),
|
||||
label: "Beta".into(),
|
||||
icon: None,
|
||||
url: "settings/beta.html".into(),
|
||||
position: None,
|
||||
order: 100,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
|
||||
let result = resolve_all_contributions(&[ext_a, ext_b]);
|
||||
let ids: Vec<&str> = result.settings_tabs.iter().map(|tab| tab.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["ext-ext-b-alpha", "ext-ext-b-beta", "ext-ext-a-zeta"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use crate::types::{ExtModelProvider, ResolvedModelProvider};
|
||||
|
||||
/// Resolve a single model provider contribution.
|
||||
pub fn resolve_model_provider(provider: &ExtModelProvider, extension_name: &str) -> ResolvedModelProvider {
|
||||
ResolvedModelProvider {
|
||||
extension_name: extension_name.to_owned(),
|
||||
id: provider.id.clone(),
|
||||
name: provider.name.clone(),
|
||||
description: provider.description.clone(),
|
||||
protocol: provider.protocol.clone(),
|
||||
base_url: provider.base_url.clone(),
|
||||
models: provider.models.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve all model provider contributions from an extension.
|
||||
pub fn resolve_model_providers(providers: &[ExtModelProvider], extension_name: &str) -> Vec<ResolvedModelProvider> {
|
||||
providers
|
||||
.iter()
|
||||
.map(|p| resolve_model_provider(p, extension_name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_model_provider() {
|
||||
let provider = ExtModelProvider {
|
||||
id: "openai-compat".into(),
|
||||
name: "OpenAI Compatible".into(),
|
||||
description: Some("An OpenAI-compatible provider".into()),
|
||||
protocol: Some("openai".into()),
|
||||
base_url: Some("https://api.example.com/v1".into()),
|
||||
models: vec!["gpt-4".into(), "gpt-3.5-turbo".into()],
|
||||
};
|
||||
|
||||
let result = resolve_model_provider(&provider, "my-ext");
|
||||
|
||||
assert_eq!(result.extension_name, "my-ext");
|
||||
assert_eq!(result.id, "openai-compat");
|
||||
assert_eq!(result.protocol.as_deref(), Some("openai"));
|
||||
assert_eq!(result.models.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_model_provider_minimal() {
|
||||
let provider = ExtModelProvider {
|
||||
id: "minimal".into(),
|
||||
name: "Minimal".into(),
|
||||
description: None,
|
||||
protocol: None,
|
||||
base_url: None,
|
||||
models: vec![],
|
||||
};
|
||||
|
||||
let result = resolve_model_provider(&provider, "my-ext");
|
||||
assert!(result.description.is_none());
|
||||
assert!(result.protocol.is_none());
|
||||
assert!(result.models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_model_providers_empty() {
|
||||
let result = resolve_model_providers(&[], "my-ext");
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use std::path::Path;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::asset_paths::{is_remote_asset_url, normalized_asset_url_path};
|
||||
use crate::types::{ExtSettingsTab, ResolvedSettingsTab};
|
||||
|
||||
fn resolve_asset_url(extension_name: &str, raw: &str) -> Option<String> {
|
||||
if is_remote_asset_url(raw) {
|
||||
return Some(raw.to_owned());
|
||||
}
|
||||
|
||||
let relative = normalized_asset_url_path(raw)?;
|
||||
Some(format!("/api/extensions/{extension_name}/assets/{relative}"))
|
||||
}
|
||||
|
||||
/// Resolve a single settings tab contribution.
|
||||
///
|
||||
/// Position information (`relativeTo`, `placement`) is preserved for the
|
||||
/// frontend to handle insertion ordering.
|
||||
pub fn resolve_settings_tab(
|
||||
tab: &ExtSettingsTab,
|
||||
extension_name: &str,
|
||||
_ext_dir: &Path,
|
||||
) -> Option<ResolvedSettingsTab> {
|
||||
let url = resolve_asset_url(extension_name, &tab.url).or_else(|| {
|
||||
warn!(
|
||||
extension = extension_name,
|
||||
tab_id = tab.id,
|
||||
url = tab.url,
|
||||
"Skipping settings tab with invalid asset path"
|
||||
);
|
||||
None
|
||||
})?;
|
||||
|
||||
let icon = tab.icon.as_ref().and_then(|icon| {
|
||||
resolve_asset_url(extension_name, icon).or_else(|| {
|
||||
warn!(
|
||||
extension = extension_name,
|
||||
tab_id = tab.id,
|
||||
icon,
|
||||
"Dropping settings tab icon with invalid asset path"
|
||||
);
|
||||
None
|
||||
})
|
||||
});
|
||||
|
||||
Some(ResolvedSettingsTab {
|
||||
extension_name: extension_name.to_owned(),
|
||||
id: format!("ext-{extension_name}-{}", tab.id),
|
||||
label: tab.label.clone(),
|
||||
icon,
|
||||
url,
|
||||
position: tab.position.clone(),
|
||||
order: tab.order,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve all settings tab contributions from an extension.
|
||||
pub fn resolve_settings_tabs(
|
||||
tabs: &[ExtSettingsTab],
|
||||
extension_name: &str,
|
||||
ext_dir: &Path,
|
||||
) -> Vec<ResolvedSettingsTab> {
|
||||
let mut resolved: Vec<_> = tabs
|
||||
.iter()
|
||||
.filter_map(|tab| resolve_settings_tab(tab, extension_name, ext_dir))
|
||||
.collect();
|
||||
|
||||
resolved.sort_by(|left, right| left.order.cmp(&right.order).then_with(|| left.label.cmp(&right.label)));
|
||||
|
||||
resolved
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::SettingsTabPosition;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_settings_tab_with_local_assets_and_position() {
|
||||
let tab = ExtSettingsTab {
|
||||
id: "my-settings".into(),
|
||||
label: "My Settings".into(),
|
||||
icon: Some("icons/gear.svg".into()),
|
||||
url: "settings/index.html".into(),
|
||||
position: Some(SettingsTabPosition {
|
||||
relative_to: "general".into(),
|
||||
placement: "after".into(),
|
||||
}),
|
||||
order: 80,
|
||||
};
|
||||
|
||||
let result = resolve_settings_tab(&tab, "my-ext", Path::new("/tmp/my-ext")).unwrap();
|
||||
|
||||
assert_eq!(result.extension_name, "my-ext");
|
||||
assert_eq!(result.id, "ext-my-ext-my-settings");
|
||||
assert_eq!(result.url, "/api/extensions/my-ext/assets/settings/index.html");
|
||||
assert_eq!(
|
||||
result.icon.as_deref(),
|
||||
Some("/api/extensions/my-ext/assets/icons/gear.svg")
|
||||
);
|
||||
assert_eq!(result.order, 80);
|
||||
let pos = result.position.unwrap();
|
||||
assert_eq!(pos.relative_to, "general");
|
||||
assert_eq!(pos.placement, "after");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_settings_tab_keeps_remote_urls() {
|
||||
let tab = ExtSettingsTab {
|
||||
id: "plain-tab".into(),
|
||||
label: "Plain".into(),
|
||||
icon: Some("https://example.com/icon.svg".into()),
|
||||
url: "https://example.com/settings".into(),
|
||||
position: None,
|
||||
order: 100,
|
||||
};
|
||||
|
||||
let result = resolve_settings_tab(&tab, "my-ext", Path::new("/tmp/my-ext")).unwrap();
|
||||
assert!(result.position.is_none());
|
||||
assert_eq!(result.url, "https://example.com/settings");
|
||||
assert_eq!(result.icon.as_deref(), Some("https://example.com/icon.svg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_settings_tab_rejects_traversal_url() {
|
||||
let tab = ExtSettingsTab {
|
||||
id: "bad".into(),
|
||||
label: "Bad".into(),
|
||||
icon: None,
|
||||
url: "../settings.html".into(),
|
||||
position: None,
|
||||
order: 100,
|
||||
};
|
||||
|
||||
assert!(resolve_settings_tab(&tab, "my-ext", Path::new("/tmp/my-ext")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_settings_tabs_sorts_by_order_then_label() {
|
||||
let tabs = vec![
|
||||
ExtSettingsTab {
|
||||
id: "z".into(),
|
||||
label: "Zulu".into(),
|
||||
icon: None,
|
||||
url: "z.html".into(),
|
||||
position: None,
|
||||
order: 100,
|
||||
},
|
||||
ExtSettingsTab {
|
||||
id: "a".into(),
|
||||
label: "Alpha".into(),
|
||||
icon: None,
|
||||
url: "a.html".into(),
|
||||
position: Some(SettingsTabPosition {
|
||||
relative_to: "general".into(),
|
||||
placement: "before".into(),
|
||||
}),
|
||||
order: 50,
|
||||
},
|
||||
ExtSettingsTab {
|
||||
id: "b".into(),
|
||||
label: "Beta".into(),
|
||||
icon: None,
|
||||
url: "b.html".into(),
|
||||
position: None,
|
||||
order: 100,
|
||||
},
|
||||
];
|
||||
|
||||
let result = resolve_settings_tabs(&tabs, "my-ext", Path::new("/tmp/my-ext"));
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0].id, "ext-my-ext-a");
|
||||
assert_eq!(result[1].id, "ext-my-ext-b");
|
||||
assert_eq!(result[2].id, "ext-my-ext-z");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use std::path::Path;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::types::{ExtSkill, ResolvedSkill};
|
||||
|
||||
/// Resolve a single skill contribution.
|
||||
///
|
||||
/// Skill file paths are resolved relative to the extension directory.
|
||||
pub fn resolve_skill(skill: &ExtSkill, extension_name: &str, ext_dir: &Path) -> Option<ResolvedSkill> {
|
||||
let path = skill.path.as_ref()?;
|
||||
let location = ext_dir.join(path);
|
||||
if !location.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ResolvedSkill {
|
||||
extension_name: extension_name.to_owned(),
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
path: Some(location.to_string_lossy().into_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve all skill contributions from an extension.
|
||||
pub fn resolve_skills(skills: &[ExtSkill], extension_name: &str, ext_dir: &Path) -> Vec<ResolvedSkill> {
|
||||
skills
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
resolve_skill(s, extension_name, ext_dir).or_else(|| {
|
||||
warn!(
|
||||
extension = extension_name,
|
||||
skill_name = s.name,
|
||||
"Failed to resolve skill path"
|
||||
);
|
||||
None
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_skill_with_path() {
|
||||
let dir = std::env::temp_dir().join("ext_test_resolve_skill_with_path");
|
||||
std::fs::create_dir_all(dir.join("skills")).unwrap();
|
||||
std::fs::write(dir.join("skills/code-review.md"), "# review").unwrap();
|
||||
|
||||
let skill = ExtSkill {
|
||||
name: "code-review".into(),
|
||||
description: Some("Code review skill".into()),
|
||||
path: Some("skills/code-review.md".into()),
|
||||
};
|
||||
|
||||
let result = resolve_skill(&skill, "my-ext", &dir).unwrap();
|
||||
|
||||
assert_eq!(result.extension_name, "my-ext");
|
||||
assert_eq!(result.name, "code-review");
|
||||
assert!(result.path.as_ref().unwrap().contains("skills/code-review"));
|
||||
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_skill_no_path() {
|
||||
let skill = ExtSkill {
|
||||
name: "inline-skill".into(),
|
||||
description: None,
|
||||
path: None,
|
||||
};
|
||||
|
||||
let result = resolve_skill(&skill, "my-ext", Path::new("/ext/my-ext"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_skill_missing_path() {
|
||||
let skill = ExtSkill {
|
||||
name: "missing-skill".into(),
|
||||
description: None,
|
||||
path: Some("skills/missing.md".into()),
|
||||
};
|
||||
|
||||
let result = resolve_skill(&skill, "my-ext", Path::new("/ext/my-ext"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_skills_multiple() {
|
||||
let dir = std::env::temp_dir().join("ext_test_resolve_skills_multiple");
|
||||
std::fs::create_dir_all(dir.join("skills")).unwrap();
|
||||
std::fs::write(dir.join("skills/b.md"), "# b").unwrap();
|
||||
|
||||
let skills = vec![
|
||||
ExtSkill {
|
||||
name: "a".into(),
|
||||
description: None,
|
||||
path: None,
|
||||
},
|
||||
ExtSkill {
|
||||
name: "b".into(),
|
||||
description: None,
|
||||
path: Some("skills/b.md".into()),
|
||||
},
|
||||
];
|
||||
|
||||
let result = resolve_skills(&skills, "my-ext", &dir);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].name, "b");
|
||||
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use std::path::Path;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::asset_paths::resolve_extension_asset_url;
|
||||
use crate::error::ExtensionError;
|
||||
use crate::types::{ExtTheme, ResolvedTheme};
|
||||
|
||||
/// Resolve a single theme contribution by reading CSS file content.
|
||||
///
|
||||
/// The CSS file path is relative to the extension directory.
|
||||
/// Cover image path is resolved to an absolute path.
|
||||
pub fn resolve_theme(theme: &ExtTheme, extension_name: &str, ext_dir: &Path) -> Result<ResolvedTheme, ExtensionError> {
|
||||
let css_path = ext_dir.join(&theme.css_file);
|
||||
if !css_path.exists() {
|
||||
return Err(ExtensionError::ThemeCssNotFound(css_path.display().to_string()));
|
||||
}
|
||||
|
||||
let css_content = std::fs::read_to_string(&css_path)?;
|
||||
|
||||
let cover_image = theme
|
||||
.cover_image
|
||||
.as_deref()
|
||||
.and_then(|img| resolve_extension_asset_url(extension_name, img));
|
||||
|
||||
Ok(ResolvedTheme {
|
||||
extension_name: extension_name.to_owned(),
|
||||
id: theme.id.clone(),
|
||||
name: theme.name.clone(),
|
||||
description: theme.description.clone(),
|
||||
css_content,
|
||||
cover_image,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve all theme contributions from an extension.
|
||||
pub fn resolve_themes(themes: &[ExtTheme], extension_name: &str, ext_dir: &Path) -> Vec<ResolvedTheme> {
|
||||
themes
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
resolve_theme(t, extension_name, ext_dir)
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
extension = extension_name,
|
||||
theme_id = t.id,
|
||||
"Failed to resolve theme: {e}"
|
||||
);
|
||||
e
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_theme_reads_css() {
|
||||
let dir = std::env::temp_dir().join("ext_test_resolve_theme");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("dark.css"), ":root { --bg: #000; }").unwrap();
|
||||
|
||||
let theme = ExtTheme {
|
||||
id: "dark-theme".into(),
|
||||
name: "Dark Theme".into(),
|
||||
description: Some("A dark theme".into()),
|
||||
css_file: "dark.css".into(),
|
||||
cover_image: Some("images/dark.png".into()),
|
||||
};
|
||||
|
||||
let result = resolve_theme(&theme, "my-ext", &dir).unwrap();
|
||||
|
||||
assert_eq!(result.extension_name, "my-ext");
|
||||
assert_eq!(result.id, "dark-theme");
|
||||
assert_eq!(result.css_content, ":root { --bg: #000; }");
|
||||
assert!(result.cover_image.as_ref().unwrap().contains("images/dark.png"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_theme_css_not_found() {
|
||||
let theme = ExtTheme {
|
||||
id: "missing-theme".into(),
|
||||
name: "Missing".into(),
|
||||
description: None,
|
||||
css_file: "nonexistent.css".into(),
|
||||
cover_image: None,
|
||||
};
|
||||
|
||||
let err = resolve_theme(&theme, "my-ext", Path::new("/tmp/no_such_ext")).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::ThemeCssNotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_theme_no_cover_image() {
|
||||
let dir = std::env::temp_dir().join("ext_test_resolve_theme_no_cover");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("light.css"), "body { color: #333; }").unwrap();
|
||||
|
||||
let theme = ExtTheme {
|
||||
id: "light-theme".into(),
|
||||
name: "Light".into(),
|
||||
description: None,
|
||||
css_file: "light.css".into(),
|
||||
cover_image: None,
|
||||
};
|
||||
|
||||
let result = resolve_theme(&theme, "my-ext", &dir).unwrap();
|
||||
assert!(result.cover_image.is_none());
|
||||
assert_eq!(result.css_content, "body { color: #333; }");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_themes_skips_missing_css() {
|
||||
let dir = std::env::temp_dir().join("ext_test_resolve_themes_skip");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("ok.css"), "ok").unwrap();
|
||||
|
||||
let themes = vec![
|
||||
ExtTheme {
|
||||
id: "good".into(),
|
||||
name: "Good".into(),
|
||||
description: None,
|
||||
css_file: "ok.css".into(),
|
||||
cover_image: None,
|
||||
},
|
||||
ExtTheme {
|
||||
id: "bad".into(),
|
||||
name: "Bad".into(),
|
||||
description: None,
|
||||
css_file: "missing.css".into(),
|
||||
cover_image: None,
|
||||
},
|
||||
];
|
||||
|
||||
let result = resolve_themes(&themes, "my-ext", &dir);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].id, "good");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use std::path::Path;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::constants::RESERVED_ROUTE_PREFIXES;
|
||||
use crate::error::ExtensionError;
|
||||
use crate::types::{ExtWebui, WebuiContribution};
|
||||
|
||||
/// Validate that a WebUI route path is within the extension's namespace
|
||||
/// and does not use reserved prefixes.
|
||||
fn validate_route(route_path: &str, extension_name: &str) -> Result<(), ExtensionError> {
|
||||
let expected_prefix = format!("/{extension_name}/");
|
||||
|
||||
if !route_path.starts_with(&expected_prefix) {
|
||||
return Err(ExtensionError::InvalidWebuiRouteNamespace {
|
||||
extension_name: extension_name.to_owned(),
|
||||
route: route_path.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
for prefix in RESERVED_ROUTE_PREFIXES {
|
||||
if route_path.starts_with(prefix) {
|
||||
return Err(ExtensionError::ReservedWebuiRoute {
|
||||
route: route_path.to_owned(),
|
||||
prefix: (*prefix).to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a single WebUI contribution.
|
||||
///
|
||||
/// All routes are validated to be within the `/{extensionName}/` namespace
|
||||
/// and not using reserved prefixes.
|
||||
pub fn resolve_webui(
|
||||
webui: &ExtWebui,
|
||||
extension_name: &str,
|
||||
ext_dir: &Path,
|
||||
) -> Result<WebuiContribution, ExtensionError> {
|
||||
for route in &webui.routes {
|
||||
validate_route(&route.path, extension_name)?;
|
||||
}
|
||||
|
||||
let directory = ext_dir.join(&webui.directory).to_string_lossy().into_owned();
|
||||
|
||||
Ok(WebuiContribution {
|
||||
extension_name: extension_name.to_owned(),
|
||||
id: webui.id.clone(),
|
||||
directory,
|
||||
routes: webui.routes.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve all WebUI contributions from an extension.
|
||||
pub fn resolve_webui_contributions(
|
||||
webuis: &[ExtWebui],
|
||||
extension_name: &str,
|
||||
ext_dir: &Path,
|
||||
) -> Vec<WebuiContribution> {
|
||||
webuis
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
resolve_webui(w, extension_name, ext_dir)
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
extension = extension_name,
|
||||
webui_id = w.id,
|
||||
"Failed to resolve WebUI: {e}"
|
||||
);
|
||||
e
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::ExtWebuiRoute;
|
||||
|
||||
fn make_route(path: &str) -> ExtWebuiRoute {
|
||||
ExtWebuiRoute {
|
||||
path: path.into(),
|
||||
method: "GET".into(),
|
||||
handler: "handler.js".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_route_valid_namespace() {
|
||||
assert!(validate_route("/my-ext/api/data", "my-ext").is_ok());
|
||||
assert!(validate_route("/my-ext/page", "my-ext").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_route_wrong_namespace() {
|
||||
let err = validate_route("/other-ext/api", "my-ext").unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::InvalidWebuiRouteNamespace { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_route_reserved_prefix() {
|
||||
let err = validate_route("/api/extensions", "api").unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::ReservedWebuiRoute { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_webui_valid() {
|
||||
let webui = ExtWebui {
|
||||
id: "web-1".into(),
|
||||
directory: "dist".into(),
|
||||
routes: vec![make_route("/my-ext/dashboard")],
|
||||
};
|
||||
|
||||
let result = resolve_webui(&webui, "my-ext", Path::new("/ext/my-ext")).unwrap();
|
||||
|
||||
assert_eq!(result.extension_name, "my-ext");
|
||||
assert_eq!(result.id, "web-1");
|
||||
assert!(result.directory.contains("dist"));
|
||||
assert_eq!(result.routes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_webui_invalid_route_rejected() {
|
||||
let webui = ExtWebui {
|
||||
id: "web-bad".into(),
|
||||
directory: "dist".into(),
|
||||
routes: vec![make_route("/other-ext/api")],
|
||||
};
|
||||
|
||||
let err = resolve_webui(&webui, "my-ext", Path::new("/ext/my-ext")).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::InvalidWebuiRouteNamespace { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_webui_no_routes() {
|
||||
let webui = ExtWebui {
|
||||
id: "static-only".into(),
|
||||
directory: "public".into(),
|
||||
routes: vec![],
|
||||
};
|
||||
|
||||
let result = resolve_webui(&webui, "my-ext", Path::new("/ext/my-ext")).unwrap();
|
||||
assert!(result.routes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_webui_contributions_filters_invalid() {
|
||||
let webuis = vec![
|
||||
ExtWebui {
|
||||
id: "good".into(),
|
||||
directory: "dist".into(),
|
||||
routes: vec![make_route("/my-ext/page")],
|
||||
},
|
||||
ExtWebui {
|
||||
id: "bad".into(),
|
||||
directory: "dist".into(),
|
||||
routes: vec![make_route("/other/page")],
|
||||
},
|
||||
];
|
||||
|
||||
let result = resolve_webui_contributions(&webuis, "my-ext", Path::new("/ext/my-ext"));
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].id, "good");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_route_ws_reserved() {
|
||||
let err = validate_route("/ws/stream", "ws").unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::ReservedWebuiRoute { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_route_auth_reserved() {
|
||||
let err = validate_route("/auth/login", "auth").unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::ReservedWebuiRoute { .. }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path as FsPath;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use axum::extract::{Json, Path, State};
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::Response;
|
||||
use axum::routing::{get, post};
|
||||
|
||||
use nomifun_api_types::{
|
||||
ApiResponse, DisableExtensionRequest, EnableExtensionRequest, ExtensionSummaryResponse, GetI18nRequest,
|
||||
GetPermissionsRequest, GetRiskLevelRequest, PermissionDetailResponse, PermissionSummaryResponse,
|
||||
};
|
||||
use nomifun_common::{AppError, now_ms};
|
||||
|
||||
use crate::asset_paths::normalize_relative_asset_path;
|
||||
use crate::permission::{build_permission_summary, calculate_risk_level};
|
||||
use crate::registry::ExtensionRegistry;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shared state for extension route handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct ExtensionRouterState {
|
||||
pub registry: ExtensionRegistry,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the extension router with all `/api/extensions/*` routes.
|
||||
///
|
||||
/// Includes query routes and management routes.
|
||||
/// All routes require authentication (applied by the caller).
|
||||
pub fn extension_routes(state: ExtensionRouterState) -> Router {
|
||||
Router::new()
|
||||
// Query routes
|
||||
.route("/api/extensions", get(get_loaded_extensions))
|
||||
.route("/api/extensions/themes", get(get_themes))
|
||||
.route("/api/extensions/assistants", get(get_assistants))
|
||||
.route("/api/extensions/acp-adapters", get(get_acp_adapters))
|
||||
.route("/api/extensions/agents", get(get_agents))
|
||||
.route("/api/extensions/mcp-servers", get(get_mcp_servers))
|
||||
.route("/api/extensions/skills", get(get_skills))
|
||||
.route("/api/extensions/channel-plugins", get(get_channel_plugins))
|
||||
.route("/api/extensions/settings-tabs", get(get_settings_tabs))
|
||||
.route(
|
||||
"/api/extensions/{extension_name}/assets/{*asset_path}",
|
||||
get(get_extension_asset),
|
||||
)
|
||||
.route("/api/extensions/webui", get(get_webui))
|
||||
.route("/api/extensions/agent-activity", get(get_agent_activity))
|
||||
// Query routes with body
|
||||
.route("/api/extensions/i18n", post(get_i18n))
|
||||
.route("/api/extensions/permissions", post(get_permissions))
|
||||
.route("/api/extensions/risk-level", post(get_risk_level))
|
||||
// Management routes
|
||||
.route("/api/extensions/enable", post(enable_extension))
|
||||
.route("/api/extensions/disable", post(disable_extension))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/extensions` — list all loaded extensions.
|
||||
async fn get_loaded_extensions(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
) -> Result<Json<ApiResponse<Vec<ExtensionSummaryResponse>>>, AppError> {
|
||||
let summaries = state.registry.get_loaded_extensions().await;
|
||||
let resp: Vec<ExtensionSummaryResponse> = summaries
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let source_str = serde_json::to_value(s.source)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_else(|| "local".to_string());
|
||||
ExtensionSummaryResponse {
|
||||
name: s.name,
|
||||
version: s.version,
|
||||
display_name: s.display_name,
|
||||
description: s.description,
|
||||
enabled: s.enabled,
|
||||
source: source_str,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
/// `GET /api/extensions/themes` — get all resolved themes.
|
||||
async fn get_themes(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
|
||||
let themes = state.registry.get_themes().await;
|
||||
let timestamp = now_ms();
|
||||
let value = serde_json::Value::Array(
|
||||
themes
|
||||
.into_iter()
|
||||
.map(|theme| {
|
||||
serde_json::json!({
|
||||
"id": format!("ext-{}-{}", theme.extension_name, theme.id),
|
||||
"name": format!("{} ({})", theme.name, theme.extension_name),
|
||||
"cover": theme.cover_image,
|
||||
"css": theme.css_content,
|
||||
"is_preset": true,
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
Ok(Json(ApiResponse::ok(value)))
|
||||
}
|
||||
|
||||
/// `GET /api/extensions/assistants` — get all resolved assistants.
|
||||
async fn get_assistants(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
|
||||
let assistants = state.registry.get_assistants().await;
|
||||
let value = serde_json::Value::Array(
|
||||
assistants
|
||||
.into_iter()
|
||||
.map(|assistant| {
|
||||
serde_json::json!({
|
||||
"id": format!("ext-{}", assistant.id),
|
||||
"name": assistant.name,
|
||||
"description": assistant.description,
|
||||
"avatar": assistant.icon,
|
||||
"presetAgentType": assistant.preset_agent_type,
|
||||
"context": assistant.context.unwrap_or_default(),
|
||||
"models": assistant.models,
|
||||
"enabledSkills": assistant.enabled_skills,
|
||||
"prompts": assistant.prompts,
|
||||
"isPreset": true,
|
||||
"isBuiltin": false,
|
||||
"enabled": true,
|
||||
"_source": "extension",
|
||||
"_extensionName": assistant.extension_name,
|
||||
"_kind": "assistant",
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
Ok(Json(ApiResponse::ok(value)))
|
||||
}
|
||||
|
||||
/// `GET /api/extensions/acp-adapters` — get all resolved ACP adapters.
|
||||
async fn get_acp_adapters(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
|
||||
let adapters = state.registry.get_acp_adapters().await;
|
||||
let value = serde_json::Value::Array(
|
||||
adapters
|
||||
.into_iter()
|
||||
.map(|adapter| {
|
||||
let cli_command = adapter.cli_command.clone();
|
||||
let default_cli_path = adapter.default_cli_path.clone().or_else(|| cli_command.clone());
|
||||
serde_json::json!({
|
||||
"id": adapter.id,
|
||||
"name": adapter.name,
|
||||
"description": adapter.description,
|
||||
"cliCommand": cli_command,
|
||||
"defaultCliPath": default_cli_path,
|
||||
"acpArgs": adapter.acp_args,
|
||||
"env": adapter.env,
|
||||
"avatar": adapter.avatar,
|
||||
"authRequired": adapter.auth_required,
|
||||
"supportsStreaming": adapter.supports_streaming.unwrap_or(false),
|
||||
"connectionType": adapter.connection_type.unwrap_or_else(|| "cli".to_string()),
|
||||
"endpoint": adapter.endpoint,
|
||||
"models": adapter.models,
|
||||
"yoloMode": adapter.yolo_mode,
|
||||
"healthCheck": adapter.health_check,
|
||||
"apiKeyFields": adapter.api_key_fields,
|
||||
"isPreset": false,
|
||||
"isBuiltin": false,
|
||||
"enabled": true,
|
||||
"_source": "extension",
|
||||
"_extensionName": adapter.extension_name,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
Ok(Json(ApiResponse::ok(value)))
|
||||
}
|
||||
|
||||
/// `GET /api/extensions/agents` — get all resolved agents.
|
||||
async fn get_agents(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
|
||||
let agents = state.registry.get_agents().await;
|
||||
let value = serde_json::Value::Array(
|
||||
agents
|
||||
.into_iter()
|
||||
.map(|agent| {
|
||||
serde_json::json!({
|
||||
"id": format!("ext-{}", agent.id),
|
||||
"name": agent.name,
|
||||
"description": agent.description,
|
||||
"avatar": agent.icon,
|
||||
"presetAgentType": agent.agent_type,
|
||||
"context": agent.context.unwrap_or_default(),
|
||||
"models": agent.models,
|
||||
"enabledSkills": agent.enabled_skills,
|
||||
"prompts": agent.prompts,
|
||||
"isPreset": true,
|
||||
"isBuiltin": false,
|
||||
"enabled": true,
|
||||
"_source": "extension",
|
||||
"_extensionName": agent.extension_name,
|
||||
"_kind": "agent",
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
Ok(Json(ApiResponse::ok(value)))
|
||||
}
|
||||
|
||||
/// `GET /api/extensions/mcp-servers` — get all resolved MCP servers.
|
||||
async fn get_mcp_servers(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
|
||||
let servers = state.registry.get_mcp_servers().await;
|
||||
let timestamp = now_ms();
|
||||
let value = serde_json::Value::Array(
|
||||
servers
|
||||
.into_iter()
|
||||
.map(|server| {
|
||||
let enabled = server
|
||||
.config
|
||||
.get("enabled")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let transport = server
|
||||
.config
|
||||
.get("transport")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let original_transport = transport.clone();
|
||||
let original_json = serde_json::json!({
|
||||
"name": server.name,
|
||||
"description": server.description,
|
||||
"enabled": enabled,
|
||||
"transport": original_transport,
|
||||
});
|
||||
serde_json::json!({
|
||||
"id": format!("ext-{}-{}", server.extension_name, server.name),
|
||||
"name": server.name,
|
||||
"description": server.description,
|
||||
"enabled": enabled,
|
||||
"transport": transport,
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
"original_json": serde_json::to_string_pretty(&original_json).unwrap_or_default(),
|
||||
"_source": "extension",
|
||||
"_extensionName": server.extension_name,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
Ok(Json(ApiResponse::ok(value)))
|
||||
}
|
||||
|
||||
/// `GET /api/extensions/skills` — get all resolved skills.
|
||||
async fn get_skills(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
|
||||
let skills = state.registry.get_skills().await;
|
||||
let value = serde_json::Value::Array(
|
||||
skills
|
||||
.into_iter()
|
||||
.map(|skill| {
|
||||
serde_json::json!({
|
||||
"name": skill.name,
|
||||
"description": skill.description.unwrap_or_else(|| format!("Skill from extension: {}", skill.extension_name)),
|
||||
"location": skill.path,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
Ok(Json(ApiResponse::ok(value)))
|
||||
}
|
||||
|
||||
/// `GET /api/extensions/channel-plugins` — get all resolved channel plugins.
|
||||
async fn get_channel_plugins(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
|
||||
let plugins = state.registry.get_channel_plugins().await;
|
||||
let value = serde_json::Value::Array(
|
||||
plugins
|
||||
.into_iter()
|
||||
.map(|plugin| {
|
||||
serde_json::json!({
|
||||
"id": plugin.id,
|
||||
"type": plugin.id,
|
||||
"name": plugin.name,
|
||||
"platform": plugin.platform,
|
||||
"entryPoint": plugin.entry_point,
|
||||
"enabled": true,
|
||||
"connected": false,
|
||||
"active_users": 0,
|
||||
"has_token": false,
|
||||
"is_extension": true,
|
||||
"extension_meta": {
|
||||
"credentialFields": plugin.credential_fields,
|
||||
"configFields": plugin.config_fields,
|
||||
"description": plugin.description,
|
||||
"extensionName": plugin.extension_name,
|
||||
"icon": plugin.icon,
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
Ok(Json(ApiResponse::ok(value)))
|
||||
}
|
||||
|
||||
/// `GET /api/extensions/settings-tabs` — get all resolved settings tabs.
|
||||
async fn get_settings_tabs(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
|
||||
let tabs = state.registry.get_settings_tabs().await;
|
||||
let value = serde_json::to_value(&tabs).unwrap_or_default();
|
||||
Ok(Json(ApiResponse::ok(value)))
|
||||
}
|
||||
|
||||
/// `GET /api/extensions/{extension_name}/assets/{*asset_path}` — serve an
|
||||
/// extension asset under the trusted extension root.
|
||||
async fn get_extension_asset(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
Path((extension_name, asset_path)): Path<(String, String)>,
|
||||
) -> Result<Response, AppError> {
|
||||
let ext = state
|
||||
.registry
|
||||
.get_extension_by_name(&extension_name)
|
||||
.await
|
||||
.ok_or_else(|| AppError::NotFound(format!("Extension not found: {extension_name}")))?;
|
||||
|
||||
let canonical_root = tokio::fs::canonicalize(&ext.directory)
|
||||
.await
|
||||
.map_err(map_asset_lookup_error)?;
|
||||
|
||||
let relative_path = normalize_relative_asset_path(&asset_path).ok_or_else(|| {
|
||||
AppError::Forbidden(format!(
|
||||
"Asset path escapes extension root: {extension_name}/{asset_path}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let requested_path = canonical_root.join(&relative_path);
|
||||
let canonical_asset = tokio::fs::canonicalize(&requested_path)
|
||||
.await
|
||||
.map_err(map_asset_lookup_error)?;
|
||||
|
||||
if !canonical_asset.starts_with(&canonical_root) {
|
||||
return Err(AppError::Forbidden(format!(
|
||||
"Asset path escapes extension root: {}",
|
||||
canonical_asset.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let bytes = tokio::fs::read(&canonical_asset)
|
||||
.await
|
||||
.map_err(map_asset_lookup_error)?;
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type_for_path(&canonical_asset))
|
||||
.header(header::CACHE_CONTROL, "public, max-age=3600")
|
||||
.body(Body::from(bytes))
|
||||
.map_err(|err| AppError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
/// `GET /api/extensions/webui` — get all WebUI contributions.
|
||||
async fn get_webui(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
|
||||
let webui = state.registry.get_webui_contributions().await;
|
||||
let value = serde_json::to_value(&webui).unwrap_or_default();
|
||||
Ok(Json(ApiResponse::ok(value)))
|
||||
}
|
||||
|
||||
/// `GET /api/extensions/agent-activity` — get agent activity snapshot.
|
||||
///
|
||||
/// Returns an empty object as a placeholder; real implementation will
|
||||
/// integrate with the agent subsystem's activity tracking.
|
||||
async fn get_agent_activity(
|
||||
State(_state): State<ExtensionRouterState>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
|
||||
// Agent activity snapshot is a cross-module concern;
|
||||
// return an empty object for now.
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({}))))
|
||||
}
|
||||
|
||||
/// `POST /api/extensions/i18n` — get i18n data for a locale.
|
||||
async fn get_i18n(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
body: Result<Json<GetI18nRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<HashMap<String, HashMap<String, String>>>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let data = state.registry.get_i18n_for_locale(&req.locale).await;
|
||||
Ok(Json(ApiResponse::ok(data)))
|
||||
}
|
||||
|
||||
/// `POST /api/extensions/permissions` — get permission summary for an extension.
|
||||
async fn get_permissions(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
body: Result<Json<GetPermissionsRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<PermissionSummaryResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
let ext = state
|
||||
.registry
|
||||
.get_extension_by_name(&req.name)
|
||||
.await
|
||||
.ok_or_else(|| AppError::NotFound(format!("Extension not found: {}", req.name)))?;
|
||||
|
||||
let permissions = ext.manifest.permissions.clone().unwrap_or_default();
|
||||
let summary = build_permission_summary(&permissions);
|
||||
let risk_level = calculate_risk_level(&permissions);
|
||||
|
||||
let details: Vec<PermissionDetailResponse> = summary
|
||||
.details
|
||||
.into_iter()
|
||||
.map(|d| PermissionDetailResponse {
|
||||
permission: d.permission,
|
||||
level: enum_to_string(&d.level),
|
||||
description: d.description,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let resp = PermissionSummaryResponse {
|
||||
permissions: serde_json::to_value(&permissions).unwrap_or_default(),
|
||||
risk_level: enum_to_string(&risk_level),
|
||||
details,
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
/// `POST /api/extensions/risk-level` — get risk level for an extension.
|
||||
async fn get_risk_level(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
body: Result<Json<GetRiskLevelRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
let ext = state
|
||||
.registry
|
||||
.get_extension_by_name(&req.name)
|
||||
.await
|
||||
.ok_or_else(|| AppError::NotFound(format!("Extension not found: {}", req.name)))?;
|
||||
|
||||
let permissions = ext.manifest.permissions.clone().unwrap_or_default();
|
||||
let risk_level = calculate_risk_level(&permissions);
|
||||
|
||||
Ok(Json(ApiResponse::ok(
|
||||
serde_json::json!({ "riskLevel": enum_to_string(&risk_level) }),
|
||||
)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Management handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/extensions/enable` — enable an extension.
|
||||
async fn enable_extension(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
body: Result<Json<EnableExtensionRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
state.registry.enable_extension(&req.name).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
/// `POST /api/extensions/disable` — disable an extension.
|
||||
async fn disable_extension(
|
||||
State(state): State<ExtensionRouterState>,
|
||||
body: Result<Json<DisableExtensionRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
state
|
||||
.registry
|
||||
.disable_extension(&req.name, req.reason.as_deref())
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Serialize a serde enum to its JSON string representation.
|
||||
fn enum_to_string<T: serde::Serialize>(value: &T) -> String {
|
||||
serde_json::to_value(value)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn content_type_for_path(path: &FsPath) -> HeaderValue {
|
||||
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||
HeaderValue::from_str(mime.as_ref()).unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"))
|
||||
}
|
||||
|
||||
fn map_asset_lookup_error(error: std::io::Error) -> AppError {
|
||||
match error.kind() {
|
||||
std::io::ErrorKind::NotFound => AppError::NotFound("Extension asset not found".into()),
|
||||
_ => AppError::Internal(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::Request;
|
||||
use http_body_util::BodyExt;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::state::ExtensionStateStore;
|
||||
use crate::{ExtensionSource, ScanPath};
|
||||
use nomifun_realtime::BroadcastEventBus;
|
||||
|
||||
fn make_state() -> ExtensionRouterState {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
|
||||
let bus = Arc::new(BroadcastEventBus::new(64));
|
||||
std::mem::forget(tmp);
|
||||
let registry = ExtensionRegistry::new(store, bus, "1.0.0".into());
|
||||
ExtensionRouterState { registry }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_routes_builds_router() {
|
||||
let state = make_state();
|
||||
let _router = extension_routes(state);
|
||||
}
|
||||
|
||||
async fn make_router_with_extension() -> (Router, tempfile::TempDir, PathBuf) {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let ext_root = tmp.path().join("extensions");
|
||||
let ext_dir = ext_root.join("hello");
|
||||
std::fs::create_dir_all(ext_dir.join("settings")).unwrap();
|
||||
std::fs::write(
|
||||
ext_dir.join("nomi-extension.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"name": "hello",
|
||||
"version": "1.0.0"
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(ext_dir.join("settings").join("index.html"), "<h1>Hello</h1>").unwrap();
|
||||
|
||||
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
|
||||
let bus = Arc::new(BroadcastEventBus::new(64));
|
||||
let registry = ExtensionRegistry::new(store, bus, "1.0.0".into());
|
||||
registry
|
||||
.initialize_with_scan_paths(vec![ScanPath {
|
||||
path: ext_root,
|
||||
source: ExtensionSource::Env,
|
||||
}])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(extension_routes(ExtensionRouterState { registry }), tmp, ext_dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_extension_asset_serves_local_file() {
|
||||
let (router, _tmp, _ext_dir) = make_router_with_extension().await;
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/extensions/hello/assets/settings/index.html")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "public, max-age=3600");
|
||||
assert_eq!(response.headers()[header::CONTENT_TYPE], "text/html");
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(bytes, "<h1>Hello</h1>");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_extension_asset_rejects_traversal() {
|
||||
let (router, _tmp, _ext_dir) = make_router_with_extension().await;
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/extensions/hello/assets/%2E%2E%2Fsecret.txt")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_extension_asset_returns_not_found_for_missing_file() {
|
||||
let (router, _tmp, _ext_dir) = make_router_with_extension().await;
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/extensions/hello/assets/settings/missing.html")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_extension_asset_returns_not_found_for_unknown_extension() {
|
||||
let (router, _tmp, _ext_dir) = make_router_with_extension().await;
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/extensions/unknown/assets/settings/index.html")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use axum::extract::{Json, Path as AxumPath, State};
|
||||
use axum::routing::{delete, get, post, put};
|
||||
|
||||
use nomifun_api_types::{
|
||||
AddExternalPathRequest, ApiResponse, BuiltinAutoSkillResponse, ExportSkillRequest, ExternalSkillSourceResponse,
|
||||
ImportSkillRequest, ImportSkillResponse, MaterializeSkillsRequest, MaterializeSkillsResponse, MaterializedSkillRef,
|
||||
NamedPathResponse, ReadAssistantRuleRequest, ReadBuiltinResourceRequest, ReadSkillInfoRequest,
|
||||
ReadSkillInfoResponse, RemoveExternalPathRequest, ScanForSkillsRequest, ScanForSkillsResponse,
|
||||
ScannedSkillResponse, SetSkillTagsRequest, SkillListItemResponse, SkillPathsResponse, SkillSourceResponse,
|
||||
WriteAssistantRuleRequest,
|
||||
};
|
||||
use nomifun_common::AppError;
|
||||
use nomifun_db::ISkillTagRepository;
|
||||
|
||||
use crate::classifier::AssistantRuleDispatcher;
|
||||
use crate::external_paths::ExternalPathsManager;
|
||||
use crate::skill_service::{self, SkillPaths, SkillSource};
|
||||
|
||||
fn to_source_response(source: SkillSource) -> SkillSourceResponse {
|
||||
match source {
|
||||
SkillSource::Builtin => SkillSourceResponse::Builtin,
|
||||
SkillSource::Custom => SkillSourceResponse::Custom,
|
||||
SkillSource::Extension => SkillSourceResponse::Extension,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shared state for skill/rule route handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct SkillRouterState {
|
||||
pub skill_paths: SkillPaths,
|
||||
pub external_paths_manager: Arc<ExternalPathsManager>,
|
||||
/// Optional dispatcher that routes assistant-rule / assistant-skill
|
||||
/// read/write/delete by source (builtin / extension / user). When
|
||||
/// `None`, the legacy user-directory-only behavior is preserved.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub assistant_dispatcher: Option<Arc<dyn AssistantRuleDispatcher>>,
|
||||
/// Per-skill tag assignment repo (user assignments/overrides).
|
||||
pub skill_tag_repo: Arc<dyn ISkillTagRepository>,
|
||||
/// Built-in skill tag seed: skill name → (audience_tags, scenario_tags).
|
||||
pub builtin_skill_tags: Arc<HashMap<String, (Vec<String>, Vec<String>)>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the skill router with all `/api/skills/*` routes.
|
||||
///
|
||||
/// All routes require authentication (applied by the caller).
|
||||
pub fn skill_routes(state: SkillRouterState) -> Router {
|
||||
Router::new()
|
||||
// Skill listing & info
|
||||
.route("/api/skills", get(list_skills))
|
||||
.route("/api/skills/builtin-auto", get(list_builtin_auto_skills))
|
||||
.route("/api/skills/{name}/tags", put(set_skill_tags))
|
||||
.route("/api/skills/info", post(read_skill_info))
|
||||
.route("/api/skills/paths", get(get_skill_paths))
|
||||
// Import / export / delete
|
||||
.route("/api/skills/import", post(import_skill))
|
||||
.route("/api/skills/import-symlink", post(import_skill_symlink))
|
||||
.route("/api/skills/export-symlink", post(export_skill_symlink))
|
||||
.route("/api/skills/{name}", delete(delete_skill))
|
||||
// Scanning & discovery
|
||||
.route("/api/skills/scan", post(scan_for_skills))
|
||||
.route("/api/skills/detect-paths", get(detect_paths))
|
||||
.route("/api/skills/detect-external", get(detect_external))
|
||||
// Built-in resources
|
||||
.route("/api/skills/builtin-rule", post(read_builtin_rule))
|
||||
.route("/api/skills/builtin-skill", post(read_builtin_skill))
|
||||
// Per-agent skill resolution (for agent CLI symlink layout).
|
||||
.route("/api/skills/materialize-for-agent", post(materialize_for_agent))
|
||||
// Assistant rules CRUD
|
||||
.route("/api/skills/assistant-rule/read", post(read_assistant_rule))
|
||||
.route("/api/skills/assistant-rule/write", post(write_assistant_rule))
|
||||
.route("/api/skills/assistant-rule/{id}", delete(delete_assistant_rule))
|
||||
// Assistant skills CRUD
|
||||
.route("/api/skills/assistant-skill/read", post(read_assistant_skill))
|
||||
.route("/api/skills/assistant-skill/write", post(write_assistant_skill))
|
||||
.route("/api/skills/assistant-skill/{id}", delete(delete_assistant_skill))
|
||||
// External path management
|
||||
.route(
|
||||
"/api/skills/external-paths",
|
||||
get(get_external_paths)
|
||||
.post(add_external_path)
|
||||
.delete(remove_external_path),
|
||||
)
|
||||
// Skills market
|
||||
.route("/api/skills/market/enable", post(enable_skills_market))
|
||||
.route("/api/skills/market/disable", post(disable_skills_market))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill listing & info
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/skills` — list all available skills.
|
||||
async fn list_skills(
|
||||
State(state): State<SkillRouterState>,
|
||||
) -> Result<Json<ApiResponse<Vec<SkillListItemResponse>>>, AppError> {
|
||||
let items = skill_service::list_available_skills(&state.skill_paths).await?;
|
||||
// user sidecar assignments (decode JSON arrays), keyed by skill name
|
||||
let user_rows = state.skill_tag_repo.get_all().await.map_err(AppError::from)?;
|
||||
let mut user_map: HashMap<String, (Vec<String>, Vec<String>)> = HashMap::new();
|
||||
for r in user_rows {
|
||||
let aud = decode_tags(r.audience_tags.as_deref());
|
||||
let scn = decode_tags(r.scenario_tags.as_deref());
|
||||
user_map.insert(r.skill_name, (aud, scn));
|
||||
}
|
||||
let resp: Vec<SkillListItemResponse> = items
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let (audience_tags, scenario_tags) = user_map
|
||||
.get(&s.name)
|
||||
.cloned()
|
||||
.or_else(|| state.builtin_skill_tags.get(&s.name).cloned())
|
||||
.unwrap_or_default();
|
||||
SkillListItemResponse {
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
location: s.location,
|
||||
relative_location: s.relative_location,
|
||||
is_custom: s.is_custom,
|
||||
source: to_source_response(s.source),
|
||||
audience_tags,
|
||||
scenario_tags,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
/// Decode a JSON-array TEXT column into a `Vec<String>`. Fail-soft on purpose
|
||||
/// (intentionally unlike `nomifun-assistant`'s `decode_str_list`, which 500s on
|
||||
/// bad JSON): this is the read path for the skill list, so one corrupted sidecar
|
||||
/// row must not break the whole listing — it degrades to no tags for that skill.
|
||||
fn decode_tags(raw: Option<&str>) -> Vec<String> {
|
||||
match raw {
|
||||
Some(s) if !s.is_empty() => serde_json::from_str(s).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUT /api/skills/{name}/tags` — set a skill's tag assignment (user sidecar).
|
||||
async fn set_skill_tags(
|
||||
State(state): State<SkillRouterState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
body: Result<Json<SetSkillTagsRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let aud = serde_json::to_string(&req.audience_tags).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
let scn = serde_json::to_string(&req.scenario_tags).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
state
|
||||
.skill_tag_repo
|
||||
.upsert(&nomifun_db::UpsertSkillTagParams {
|
||||
skill_name: &name,
|
||||
audience_tags: Some(&aud),
|
||||
scenario_tags: Some(&scn),
|
||||
})
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
/// `GET /api/skills/builtin-auto` — list auto-injected built-in skills.
|
||||
async fn list_builtin_auto_skills(
|
||||
State(state): State<SkillRouterState>,
|
||||
) -> Result<Json<ApiResponse<Vec<BuiltinAutoSkillResponse>>>, AppError> {
|
||||
let items = skill_service::list_builtin_auto_skills(&state.skill_paths).await?;
|
||||
let resp: Vec<BuiltinAutoSkillResponse> = items
|
||||
.into_iter()
|
||||
.map(|s| BuiltinAutoSkillResponse {
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
location: s.location,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
/// `POST /api/skills/info` — read skill info without importing.
|
||||
async fn read_skill_info(
|
||||
body: Result<Json<ReadSkillInfoRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<ReadSkillInfoResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let (name, description) = skill_service::read_skill_info(Path::new(&req.skill_path)).await?;
|
||||
Ok(Json(ApiResponse::ok(ReadSkillInfoResponse { name, description })))
|
||||
}
|
||||
|
||||
/// `GET /api/skills/paths` — get user and built-in skill directories.
|
||||
async fn get_skill_paths(
|
||||
State(state): State<SkillRouterState>,
|
||||
) -> Result<Json<ApiResponse<SkillPathsResponse>>, AppError> {
|
||||
let (user_dir, builtin_dir) = skill_service::get_skill_paths(&state.skill_paths);
|
||||
Ok(Json(ApiResponse::ok(SkillPathsResponse {
|
||||
user_skills_dir: user_dir,
|
||||
builtin_skills_dir: builtin_dir,
|
||||
})))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import / export / delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/skills/import` — import a skill by copying.
|
||||
async fn import_skill(
|
||||
State(state): State<SkillRouterState>,
|
||||
body: Result<Json<ImportSkillRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<ImportSkillResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let name = skill_service::import_skill(&state.skill_paths, Path::new(&req.skill_path)).await?;
|
||||
Ok(Json(ApiResponse::ok(ImportSkillResponse {
|
||||
skill_name: name.clone(),
|
||||
skill_names: vec![name],
|
||||
})))
|
||||
}
|
||||
|
||||
/// `POST /api/skills/import-symlink` — import a skill by symlink.
|
||||
async fn import_skill_symlink(
|
||||
State(state): State<SkillRouterState>,
|
||||
body: Result<Json<ImportSkillRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<ImportSkillResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let names = skill_service::import_skills_with_symlink(&state.skill_paths, Path::new(&req.skill_path)).await?;
|
||||
let first_name = names.first().cloned().unwrap_or_default();
|
||||
Ok(Json(ApiResponse::ok(ImportSkillResponse {
|
||||
skill_name: first_name,
|
||||
skill_names: names,
|
||||
})))
|
||||
}
|
||||
|
||||
/// `POST /api/skills/export-symlink` — export a skill symlink.
|
||||
async fn export_skill_symlink(
|
||||
body: Result<Json<ExportSkillRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
skill_service::export_skill_with_symlink(Path::new(&req.skill_path), Path::new(&req.target_dir)).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
/// `DELETE /api/skills/:name` — delete a user-custom skill.
|
||||
async fn delete_skill(
|
||||
State(state): State<SkillRouterState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
skill_service::delete_skill(&state.skill_paths, &name).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scanning & discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/skills/scan` — scan a directory for skills.
|
||||
async fn scan_for_skills(
|
||||
body: Result<Json<ScanForSkillsRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<ScanForSkillsResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let skills = skill_service::scan_for_skills(Path::new(&req.folder_path)).await?;
|
||||
let resp = ScanForSkillsResponse {
|
||||
skills: skills
|
||||
.into_iter()
|
||||
.map(|s| ScannedSkillResponse {
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
path: s.path,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
/// `GET /api/skills/detect-paths` — detect common skill paths.
|
||||
async fn detect_paths() -> Result<Json<ApiResponse<Vec<NamedPathResponse>>>, AppError> {
|
||||
let paths = skill_service::detect_common_skill_paths().await;
|
||||
let resp: Vec<NamedPathResponse> = paths
|
||||
.into_iter()
|
||||
.map(|p| NamedPathResponse {
|
||||
name: p.name,
|
||||
path: p.path,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
/// `GET /api/skills/detect-external` — discover external skills from all sources.
|
||||
async fn detect_external(
|
||||
State(state): State<SkillRouterState>,
|
||||
) -> Result<Json<ApiResponse<Vec<ExternalSkillSourceResponse>>>, AppError> {
|
||||
let custom = state.external_paths_manager.get_custom_external_paths().await;
|
||||
let sources = skill_service::detect_and_count_external_skills(&custom).await;
|
||||
let resp: Vec<ExternalSkillSourceResponse> = sources
|
||||
.into_iter()
|
||||
.map(|s| ExternalSkillSourceResponse {
|
||||
name: s.name,
|
||||
path: s.path,
|
||||
source: s.source,
|
||||
skill_count: s.skill_count,
|
||||
skills: s
|
||||
.skills
|
||||
.into_iter()
|
||||
.map(|sk| ScannedSkillResponse {
|
||||
name: sk.name,
|
||||
description: sk.description,
|
||||
path: sk.path,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Built-in resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/skills/builtin-rule` — read a built-in rule file.
|
||||
async fn read_builtin_rule(
|
||||
State(state): State<SkillRouterState>,
|
||||
body: Result<Json<ReadBuiltinResourceRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<String>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let content = skill_service::read_builtin_rule(&state.skill_paths, &req.file_name).await?;
|
||||
Ok(Json(ApiResponse::ok(content)))
|
||||
}
|
||||
|
||||
/// `POST /api/skills/builtin-skill` — read a built-in skill file.
|
||||
async fn read_builtin_skill(
|
||||
State(state): State<SkillRouterState>,
|
||||
body: Result<Json<ReadBuiltinResourceRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<String>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let content = skill_service::read_builtin_skill(&state.skill_paths, &req.file_name).await?;
|
||||
Ok(Json(ApiResponse::ok(content)))
|
||||
}
|
||||
|
||||
/// `POST /api/skills/materialize-for-agent` — resolve each requested skill
|
||||
/// name to its on-disk source directory. The frontend symlinks each
|
||||
/// returned `source_path` into the agent CLI's native skills dir. The
|
||||
/// backend no longer copies any files per-conversation.
|
||||
async fn materialize_for_agent(
|
||||
State(state): State<SkillRouterState>,
|
||||
body: Result<Json<MaterializeSkillsRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<MaterializeSkillsResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
if req.conversation_id <= 0 {
|
||||
return Err(AppError::BadRequest("conversationId must not be empty".into()));
|
||||
}
|
||||
let conversation_id = req.conversation_id.to_string();
|
||||
let resolved =
|
||||
skill_service::materialize_skills_for_agent(&state.skill_paths, &conversation_id, &req.skills).await?;
|
||||
let skills: Vec<MaterializedSkillRef> = resolved
|
||||
.into_iter()
|
||||
.map(|s| MaterializedSkillRef {
|
||||
name: s.name,
|
||||
source_path: s.source_path.to_string_lossy().into_owned(),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ApiResponse::ok(MaterializeSkillsResponse { skills })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assistant rules CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/skills/assistant-rule/read` — read an assistant rule.
|
||||
///
|
||||
/// Dispatches by source via [`AssistantRuleDispatcher`] when wired; falls
|
||||
/// back to user-directory-only legacy behavior otherwise.
|
||||
async fn read_assistant_rule(
|
||||
State(state): State<SkillRouterState>,
|
||||
body: Result<Json<ReadAssistantRuleRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<String>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
if let Some(dispatcher) = &state.assistant_dispatcher {
|
||||
let content = dispatcher.read_rule(&req.assistant_id, req.locale.as_deref()).await?;
|
||||
return Ok(Json(ApiResponse::ok(content)));
|
||||
}
|
||||
let content =
|
||||
skill_service::read_assistant_rule(&state.skill_paths, &req.assistant_id, req.locale.as_deref()).await?;
|
||||
Ok(Json(ApiResponse::ok(content)))
|
||||
}
|
||||
|
||||
/// `POST /api/skills/assistant-rule/write` — write an assistant rule.
|
||||
///
|
||||
/// Dispatches by source: builtin / extension ids reject with 400.
|
||||
async fn write_assistant_rule(
|
||||
State(state): State<SkillRouterState>,
|
||||
body: Result<Json<WriteAssistantRuleRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<bool>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
if let Some(dispatcher) = &state.assistant_dispatcher {
|
||||
dispatcher
|
||||
.write_rule(&req.assistant_id, req.locale.as_deref(), &req.content)
|
||||
.await?;
|
||||
return Ok(Json(ApiResponse::ok(true)));
|
||||
}
|
||||
let ok = skill_service::write_assistant_rule(
|
||||
&state.skill_paths,
|
||||
&req.assistant_id,
|
||||
&req.content,
|
||||
req.locale.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(ok)))
|
||||
}
|
||||
|
||||
/// `DELETE /api/skills/assistant-rule/:id` — delete all locale versions.
|
||||
async fn delete_assistant_rule(
|
||||
State(state): State<SkillRouterState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Result<Json<ApiResponse<bool>>, AppError> {
|
||||
if let Some(dispatcher) = &state.assistant_dispatcher {
|
||||
let ok = dispatcher.delete_rule(&id).await?;
|
||||
return Ok(Json(ApiResponse::ok(ok)));
|
||||
}
|
||||
let ok = skill_service::delete_assistant_rule(&state.skill_paths, &id).await?;
|
||||
Ok(Json(ApiResponse::ok(ok)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assistant skills CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/skills/assistant-skill/read` — read an assistant skill.
|
||||
///
|
||||
/// Dispatches by source via [`AssistantRuleDispatcher`] when wired.
|
||||
async fn read_assistant_skill(
|
||||
State(state): State<SkillRouterState>,
|
||||
body: Result<Json<ReadAssistantRuleRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<String>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
if let Some(dispatcher) = &state.assistant_dispatcher {
|
||||
let content = dispatcher.read_skill(&req.assistant_id, req.locale.as_deref()).await?;
|
||||
return Ok(Json(ApiResponse::ok(content)));
|
||||
}
|
||||
let content =
|
||||
skill_service::read_assistant_skill(&state.skill_paths, &req.assistant_id, req.locale.as_deref()).await?;
|
||||
Ok(Json(ApiResponse::ok(content)))
|
||||
}
|
||||
|
||||
/// `POST /api/skills/assistant-skill/write` — write an assistant skill.
|
||||
///
|
||||
/// Dispatches by source: builtin / extension ids reject with 400.
|
||||
async fn write_assistant_skill(
|
||||
State(state): State<SkillRouterState>,
|
||||
body: Result<Json<WriteAssistantRuleRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<bool>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
if let Some(dispatcher) = &state.assistant_dispatcher {
|
||||
dispatcher
|
||||
.write_skill(&req.assistant_id, req.locale.as_deref(), &req.content)
|
||||
.await?;
|
||||
return Ok(Json(ApiResponse::ok(true)));
|
||||
}
|
||||
let ok = skill_service::write_assistant_skill(
|
||||
&state.skill_paths,
|
||||
&req.assistant_id,
|
||||
&req.content,
|
||||
req.locale.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(ok)))
|
||||
}
|
||||
|
||||
/// `DELETE /api/skills/assistant-skill/:id` — delete all locale versions.
|
||||
async fn delete_assistant_skill(
|
||||
State(state): State<SkillRouterState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Result<Json<ApiResponse<bool>>, AppError> {
|
||||
if let Some(dispatcher) = &state.assistant_dispatcher {
|
||||
let ok = dispatcher.delete_skill(&id).await?;
|
||||
return Ok(Json(ApiResponse::ok(ok)));
|
||||
}
|
||||
let ok = skill_service::delete_assistant_skill(&state.skill_paths, &id).await?;
|
||||
Ok(Json(ApiResponse::ok(ok)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External path management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/skills/external-paths` — list custom external paths.
|
||||
async fn get_external_paths(
|
||||
State(state): State<SkillRouterState>,
|
||||
) -> Result<Json<ApiResponse<Vec<NamedPathResponse>>>, AppError> {
|
||||
let paths = state.external_paths_manager.get_custom_external_paths().await;
|
||||
let resp: Vec<NamedPathResponse> = paths
|
||||
.into_iter()
|
||||
.map(|p| NamedPathResponse {
|
||||
name: p.name,
|
||||
path: p.path,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
/// `POST /api/skills/external-paths` — add a custom external path.
|
||||
async fn add_external_path(
|
||||
State(state): State<SkillRouterState>,
|
||||
body: Result<Json<AddExternalPathRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
state
|
||||
.external_paths_manager
|
||||
.add_custom_external_path(&req.name, &req.path)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
/// `DELETE /api/skills/external-paths` — remove a custom external path.
|
||||
async fn remove_external_path(
|
||||
State(state): State<SkillRouterState>,
|
||||
body: Result<Json<RemoveExternalPathRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
state
|
||||
.external_paths_manager
|
||||
.remove_custom_external_path(&req.path)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skills market
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/skills/market/enable` — enable the nomifun skills market.
|
||||
async fn enable_skills_market(State(state): State<SkillRouterState>) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state.external_paths_manager.enable_skills_market().await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
/// `POST /api/skills/market/disable` — disable the nomifun skills market.
|
||||
async fn disable_skills_market(State(state): State<SkillRouterState>) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state.external_paths_manager.disable_skills_market().await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemorySkillTagRepo {
|
||||
rows: std::sync::Mutex<Vec<nomifun_db::SkillTagRow>>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl nomifun_db::ISkillTagRepository for InMemorySkillTagRepo {
|
||||
async fn get_all(&self) -> Result<Vec<nomifun_db::SkillTagRow>, nomifun_db::DbError> {
|
||||
Ok(self.rows.lock().unwrap().clone())
|
||||
}
|
||||
async fn upsert(
|
||||
&self,
|
||||
p: &nomifun_db::UpsertSkillTagParams<'_>,
|
||||
) -> Result<nomifun_db::SkillTagRow, nomifun_db::DbError> {
|
||||
let row = nomifun_db::SkillTagRow {
|
||||
skill_name: p.skill_name.into(),
|
||||
audience_tags: p.audience_tags.map(String::from),
|
||||
scenario_tags: p.scenario_tags.map(String::from),
|
||||
updated_at: 0,
|
||||
};
|
||||
let mut g = self.rows.lock().unwrap();
|
||||
g.retain(|r| r.skill_name != row.skill_name);
|
||||
g.push(row.clone());
|
||||
Ok(row)
|
||||
}
|
||||
async fn delete(&self, name: &str) -> Result<bool, nomifun_db::DbError> {
|
||||
let mut g = self.rows.lock().unwrap();
|
||||
let before = g.len();
|
||||
g.retain(|r| r.skill_name != name);
|
||||
Ok(g.len() != before)
|
||||
}
|
||||
}
|
||||
|
||||
async fn make_state() -> SkillRouterState {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let paths = SkillPaths {
|
||||
data_dir: tmp.path().to_path_buf(),
|
||||
user_skills_dir: tmp.path().join("skills"),
|
||||
cron_skills_dir: tmp.path().join("cron").join("skills"),
|
||||
builtin_skills_dir: tmp.path().join("builtin-skills"),
|
||||
builtin_rules_dir: tmp.path().join("builtin-rules"),
|
||||
assistant_rules_dir: tmp.path().join("assistant-rules"),
|
||||
assistant_skills_dir: tmp.path().join("assistant-skills"),
|
||||
};
|
||||
let ext_mgr = Arc::new(ExternalPathsManager::with_file(tmp.path().join("paths.json")).await);
|
||||
std::mem::forget(tmp);
|
||||
SkillRouterState {
|
||||
skill_paths: paths,
|
||||
external_paths_manager: ext_mgr,
|
||||
assistant_dispatcher: None,
|
||||
skill_tag_repo: std::sync::Arc::new(InMemorySkillTagRepo::default()),
|
||||
builtin_skill_tags: std::sync::Arc::new(std::collections::HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_routes_builds_router() {
|
||||
let state = make_state().await;
|
||||
let _router = skill_routes(state);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,317 @@
|
||||
//! Startup-time materialization of the embedded builtin skills corpus to
|
||||
//! `{data_dir}/builtin-skills/`. Gated on a `.version` file so repeat
|
||||
//! starts with the same binary skip the rewrite.
|
||||
//!
|
||||
//! Algorithm:
|
||||
//! staging = data_dir/.builtin-skills.tmp (fresh each call)
|
||||
//! write all BUILTIN_SKILLS entries into staging
|
||||
//! write staging/.version ← binary version
|
||||
//! atomic rename(target → .builtin-skills.old, staging → target)
|
||||
//! best-effort remove .builtin-skills.old
|
||||
//!
|
||||
//! The atomic rename guarantees that concurrent backend processes, or a
|
||||
//! crash mid-write, never observe a half-populated target — the old tree
|
||||
//! stays in place until staging is fully ready.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use fs2::FileExt;
|
||||
use include_dir::Dir;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::error::ExtensionError;
|
||||
|
||||
const VERSION_FILE: &str = ".version";
|
||||
const LOCK_FILE_NAME: &str = ".builtin-skills.lock";
|
||||
const STAGING_DIR_NAME: &str = ".builtin-skills.tmp";
|
||||
const OLD_DIR_NAME: &str = ".builtin-skills.old";
|
||||
const STARTUP_FILE_RETRY_DELAYS: [Duration; 5] = [
|
||||
Duration::from_millis(50),
|
||||
Duration::from_millis(100),
|
||||
Duration::from_millis(200),
|
||||
Duration::from_millis(400),
|
||||
Duration::from_millis(800),
|
||||
];
|
||||
|
||||
/// Decide whether to materialize based on the `.version` file, then do it.
|
||||
/// Returns `true` if a write happened, `false` if the gate said "skip".
|
||||
///
|
||||
/// When `BUILTIN_SKILLS_ENV_VAR` is set and non-empty, the caller has
|
||||
/// already routed `builtin_skills_dir` at the env-var path — this
|
||||
/// function still runs but the gate will see whatever version the dev
|
||||
/// tree has on disk (or missing, and materialize into that dev path,
|
||||
/// which is wrong). Callers MUST check the env var before calling.
|
||||
pub async fn materialize_if_needed(
|
||||
data_dir: &Path,
|
||||
corpus: &Dir<'static>,
|
||||
binary_version: &str,
|
||||
) -> Result<bool, ExtensionError> {
|
||||
let target = data_dir.join(crate::constants::BUILTIN_SKILLS_DIR_NAME);
|
||||
|
||||
if version_file_matches(&target, binary_version).await {
|
||||
info!(
|
||||
target = %target.display(),
|
||||
version = binary_version,
|
||||
"builtin skills up to date; skipping materialize"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
info!(
|
||||
target = %target.display(),
|
||||
version = binary_version,
|
||||
"materializing embedded builtin skills"
|
||||
);
|
||||
let _guard = MaterializeLockGuard::acquire(data_dir).await?;
|
||||
if version_file_matches(&target, binary_version).await {
|
||||
info!(
|
||||
target = %target.display(),
|
||||
version = binary_version,
|
||||
"builtin skills up to date after materialize lock; skipping rewrite"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match materialize_embedded_builtin_skills_unlocked(data_dir, corpus, binary_version).await {
|
||||
Ok(()) => {}
|
||||
Err(e) if existing_builtin_skills_looks_usable(&target).await => {
|
||||
warn!(
|
||||
target = %target.display(),
|
||||
version = binary_version,
|
||||
error = %e,
|
||||
"failed to refresh builtin skills; continuing with existing tree"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Read `.version` and compare against the provided `binary_version`.
|
||||
/// Returns `true` only on exact match. Missing file / IO error /
|
||||
/// mismatch all return `false`.
|
||||
async fn version_file_matches(target: &Path, binary_version: &str) -> bool {
|
||||
let version_path = target.join(VERSION_FILE);
|
||||
match tokio::fs::read_to_string(&version_path).await {
|
||||
Ok(s) => s == binary_version,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unconditional materialize: stage, write each file, atomic rename.
|
||||
/// Exposed separately for tests that want to bypass the gate.
|
||||
pub async fn materialize_embedded_builtin_skills(
|
||||
data_dir: &Path,
|
||||
corpus: &Dir<'static>,
|
||||
binary_version: &str,
|
||||
) -> Result<(), ExtensionError> {
|
||||
let _guard = MaterializeLockGuard::acquire(data_dir).await?;
|
||||
materialize_embedded_builtin_skills_unlocked(data_dir, corpus, binary_version).await
|
||||
}
|
||||
|
||||
async fn materialize_embedded_builtin_skills_unlocked(
|
||||
data_dir: &Path,
|
||||
corpus: &Dir<'static>,
|
||||
binary_version: &str,
|
||||
) -> Result<(), ExtensionError> {
|
||||
let target = data_dir.join(crate::constants::BUILTIN_SKILLS_DIR_NAME);
|
||||
let staging = data_dir.join(STAGING_DIR_NAME);
|
||||
let old = data_dir.join(OLD_DIR_NAME);
|
||||
|
||||
// Ensure data_dir itself exists before we try to write into it.
|
||||
tokio::fs::create_dir_all(data_dir).await?;
|
||||
|
||||
// Clean any leftover staging from a previous crashed run.
|
||||
if staging.exists() {
|
||||
retry_startup_file_op("remove builtin skills staging dir", &staging, || {
|
||||
tokio::fs::remove_dir_all(&staging)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ExtensionError::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
format!("failed to clean staging dir {}: {e}", staging.display()),
|
||||
))
|
||||
})?;
|
||||
}
|
||||
tokio::fs::create_dir_all(&staging).await?;
|
||||
|
||||
write_dir_recursive(corpus, &staging).await?;
|
||||
|
||||
let version_path = staging.join(VERSION_FILE);
|
||||
tokio::fs::write(&version_path, binary_version).await?;
|
||||
|
||||
// Move existing target out of the way, then move staging in.
|
||||
if target.exists() {
|
||||
if old.exists() {
|
||||
// Tolerate leftover .old from a crashed rename sequence.
|
||||
if let Err(e) = retry_startup_file_op("remove old builtin skills dir", &old, || {
|
||||
tokio::fs::remove_dir_all(&old)
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
old = %old.display(),
|
||||
error = %e,
|
||||
"failed to remove stale old builtin skills tree before refresh"
|
||||
);
|
||||
}
|
||||
}
|
||||
retry_startup_file_op("rename builtin skills target to old", &target, || {
|
||||
tokio::fs::rename(&target, &old)
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Err(e) = retry_startup_file_op("rename builtin skills staging to target", &staging, || {
|
||||
tokio::fs::rename(&staging, &target)
|
||||
})
|
||||
.await
|
||||
{
|
||||
// Try to restore the original target so we don't leave the user
|
||||
// with no builtin skills.
|
||||
if old.exists()
|
||||
&& let Err(restore_error) = retry_startup_file_op("restore old builtin skills target", &old, || {
|
||||
tokio::fs::rename(&old, &target)
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
old = %old.display(),
|
||||
target = %target.display(),
|
||||
error = %restore_error,
|
||||
"failed to restore old builtin skills tree after refresh failure"
|
||||
);
|
||||
}
|
||||
return Err(ExtensionError::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
format!(
|
||||
"atomic rename staging→target failed ({} → {}): {e}",
|
||||
staging.display(),
|
||||
target.display()
|
||||
),
|
||||
)));
|
||||
}
|
||||
|
||||
// Best-effort cleanup of the superseded tree.
|
||||
if old.exists()
|
||||
&& let Err(e) = retry_startup_file_op("remove superseded builtin skills dir", &old, || {
|
||||
tokio::fs::remove_dir_all(&old)
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
old = %old.display(),
|
||||
error = %e,
|
||||
"failed to remove superseded builtin skills tree (leaving behind)"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn existing_builtin_skills_looks_usable(target: &Path) -> bool {
|
||||
if !target.is_dir() {
|
||||
return false;
|
||||
}
|
||||
tokio::fs::metadata(target.join(VERSION_FILE))
|
||||
.await
|
||||
.map(|metadata| metadata.is_file())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn retry_startup_file_op<T, F, Fut>(operation: &str, path: &Path, mut op: F) -> std::io::Result<T>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = std::io::Result<T>>,
|
||||
{
|
||||
for (attempt, delay) in STARTUP_FILE_RETRY_DELAYS.iter().enumerate() {
|
||||
match op().await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(e) if is_retryable_startup_file_error(&e) => {
|
||||
warn!(
|
||||
operation,
|
||||
path = %path.display(),
|
||||
attempt = attempt + 1,
|
||||
retry_after_ms = delay.as_millis(),
|
||||
raw_os_error = ?e.raw_os_error(),
|
||||
error = %e,
|
||||
"Startup file operation failed; retrying"
|
||||
);
|
||||
tokio::time::sleep(*delay).await;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
op().await
|
||||
}
|
||||
|
||||
fn is_retryable_startup_file_error(error: &std::io::Error) -> bool {
|
||||
match error.kind() {
|
||||
std::io::ErrorKind::Interrupted
|
||||
| std::io::ErrorKind::PermissionDenied
|
||||
| std::io::ErrorKind::TimedOut
|
||||
| std::io::ErrorKind::WouldBlock => true,
|
||||
_ => matches!(error.raw_os_error(), Some(5 | 32 | 33)),
|
||||
}
|
||||
}
|
||||
|
||||
struct MaterializeLockGuard {
|
||||
file: std::fs::File,
|
||||
}
|
||||
|
||||
impl MaterializeLockGuard {
|
||||
async fn acquire(data_dir: &Path) -> std::io::Result<Self> {
|
||||
let data_dir = data_dir.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
let lock_path = data_dir.join(LOCK_FILE_NAME);
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(lock_path)?;
|
||||
FileExt::lock_exclusive(&file)?;
|
||||
Ok(Self { file })
|
||||
})
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(format!("builtin skills lock task failed: {e}")))?
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MaterializeLockGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = FileExt::unlock(&self.file);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively copy every file in an `include_dir::Dir` tree into `dest`.
|
||||
/// Directories are created as needed. Files overwrite silently.
|
||||
async fn write_dir_recursive(dir: &Dir<'static>, dest: &Path) -> Result<(), ExtensionError> {
|
||||
// The include_dir API is synchronous; we flatten into a Vec then
|
||||
// feed the writes through tokio::fs to stay off the reactor's thread
|
||||
// for big IO bursts.
|
||||
let mut stack: Vec<(&Dir<'static>, PathBuf)> = vec![(dir, dest.to_path_buf())];
|
||||
while let Some((d, prefix)) = stack.pop() {
|
||||
for file in d.files() {
|
||||
let rel = file.path();
|
||||
let out_path = prefix.join(rel.strip_prefix(d.path()).unwrap_or(rel));
|
||||
if let Some(parent) = out_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
tokio::fs::write(&out_path, file.contents()).await?;
|
||||
}
|
||||
for sub in d.dirs() {
|
||||
let sub_rel = sub.path();
|
||||
let sub_dest = prefix.join(sub_rel.strip_prefix(d.path()).unwrap_or(sub_rel));
|
||||
tokio::fs::create_dir_all(&sub_dest).await?;
|
||||
stack.push((sub, sub_dest));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{Mutex, Notify};
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::constants::STATE_PERSIST_DEBOUNCE_MS;
|
||||
use crate::error::ExtensionError;
|
||||
use crate::types::ExtensionState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ExtensionStateStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Manages loading and saving extension states to a JSON file with debounced
|
||||
/// writes.
|
||||
///
|
||||
/// State is persisted to `extension-states.json`. Writes are debounced by
|
||||
/// [`STATE_PERSIST_DEBOUNCE_MS`] to avoid excessive disk I/O when multiple
|
||||
/// state changes happen in quick succession.
|
||||
#[derive(Clone)]
|
||||
pub struct ExtensionStateStore {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
/// Path to the state JSON file.
|
||||
file_path: PathBuf,
|
||||
/// In-memory state map protected by a mutex.
|
||||
states: Mutex<HashMap<String, ExtensionState>>,
|
||||
/// Notifier used to trigger a debounced write.
|
||||
write_notify: Notify,
|
||||
/// Whether the background writer task has been spawned.
|
||||
writer_spawned: Mutex<bool>,
|
||||
}
|
||||
|
||||
const EXTENSION_STATES_FILE_ENV: &str = "NOMIFUN_EXTENSION_STATES_FILE";
|
||||
const DEFAULT_STATES_FILE: &str = "extension-states.json";
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct PersistedStates {
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
extensions: BTreeMap<String, PersistedExtensionState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
struct PersistedExtensionState {
|
||||
enabled: bool,
|
||||
#[serde(default)]
|
||||
installed: Option<bool>,
|
||||
#[serde(default, alias = "lastVersion", rename = "lastVersion")]
|
||||
last_version: Option<String>,
|
||||
}
|
||||
|
||||
impl ExtensionStateStore {
|
||||
/// Create a new store backed by the given file path.
|
||||
pub fn new(file_path: PathBuf) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
file_path,
|
||||
states: Mutex::new(HashMap::new()),
|
||||
write_notify: Notify::new(),
|
||||
writer_spawned: Mutex::new(false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the file path backing this store.
|
||||
pub fn file_path(&self) -> &Path {
|
||||
&self.inner.file_path
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Load
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Load persisted states from disk into memory.
|
||||
///
|
||||
/// If the file does not exist, an empty map is used (all extensions will
|
||||
/// default to enabled). Parse errors are propagated as `ExtensionError`.
|
||||
pub async fn load(&self) -> Result<HashMap<String, ExtensionState>, ExtensionError> {
|
||||
let states = load_states_from_file(&self.inner.file_path)?;
|
||||
let mut guard = self.inner.states.lock().await;
|
||||
*guard = states.clone();
|
||||
Ok(states)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Read helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Get the persisted state for a single extension (or `None` if unknown).
|
||||
pub async fn get(&self, name: &str) -> Option<ExtensionState> {
|
||||
let guard = self.inner.states.lock().await;
|
||||
guard.get(name).cloned()
|
||||
}
|
||||
|
||||
/// Snapshot of all current states.
|
||||
pub async fn get_all(&self) -> HashMap<String, ExtensionState> {
|
||||
let guard = self.inner.states.lock().await;
|
||||
guard.clone()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Write (debounced)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Update (or insert) the state for a single extension and schedule a
|
||||
/// debounced write to disk.
|
||||
pub async fn set(&self, state: ExtensionState) {
|
||||
{
|
||||
let mut guard = self.inner.states.lock().await;
|
||||
guard.insert(state.name.clone(), state);
|
||||
}
|
||||
self.schedule_write().await;
|
||||
}
|
||||
|
||||
/// Replace the entire state map and schedule a debounced write.
|
||||
pub async fn set_all(&self, states: HashMap<String, ExtensionState>) {
|
||||
{
|
||||
let mut guard = self.inner.states.lock().await;
|
||||
*guard = states;
|
||||
}
|
||||
self.schedule_write().await;
|
||||
}
|
||||
|
||||
/// Remove the persisted state for an extension and schedule a write.
|
||||
pub async fn remove(&self, name: &str) {
|
||||
{
|
||||
let mut guard = self.inner.states.lock().await;
|
||||
guard.remove(name);
|
||||
}
|
||||
self.schedule_write().await;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Synchronous write (for shutdown or testing)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Immediately write the current in-memory states to disk (no debounce).
|
||||
pub async fn flush(&self) -> Result<(), ExtensionError> {
|
||||
let snapshot = {
|
||||
let guard = self.inner.states.lock().await;
|
||||
guard.clone()
|
||||
};
|
||||
save_states_to_file(&self.inner.file_path, &snapshot)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Debounce internals
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Notify the background writer that a write is pending. Spawns the
|
||||
/// background task on first call.
|
||||
async fn schedule_write(&self) {
|
||||
self.ensure_writer_spawned().await;
|
||||
self.inner.write_notify.notify_one();
|
||||
}
|
||||
|
||||
/// Spawn the background debounce writer if not already running.
|
||||
async fn ensure_writer_spawned(&self) {
|
||||
let mut spawned = self.inner.writer_spawned.lock().await;
|
||||
if *spawned {
|
||||
return;
|
||||
}
|
||||
*spawned = true;
|
||||
|
||||
let inner = Arc::clone(&self.inner);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
inner.write_notify.notified().await;
|
||||
|
||||
// Debounce: wait for the configured duration, collapsing
|
||||
// additional notifications.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(STATE_PERSIST_DEBOUNCE_MS)).await;
|
||||
|
||||
let snapshot = {
|
||||
let guard = inner.states.lock().await;
|
||||
guard.clone()
|
||||
};
|
||||
|
||||
if let Err(e) = save_states_to_file(&inner.file_path, &snapshot) {
|
||||
error!(error = %e, "failed to persist extension states");
|
||||
} else {
|
||||
debug!(path = %inner.file_path.display(), "extension states persisted");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File I/O (pure functions, no async needed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Load extension states from a JSON file.
|
||||
///
|
||||
/// Returns an empty map if the file does not exist.
|
||||
pub fn load_states_from_file(path: &Path) -> Result<HashMap<String, ExtensionState>, ExtensionError> {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => parse_states_from_bytes(path, &bytes),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
debug!(path = %path.display(), "no state file found — starting fresh");
|
||||
Ok(HashMap::new())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(path = %path.display(), error = %e, "failed to read state file");
|
||||
Err(ExtensionError::Io(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_states_from_bytes(path: &Path, bytes: &[u8]) -> Result<HashMap<String, ExtensionState>, ExtensionError> {
|
||||
let persisted: PersistedStates = serde_json::from_slice(bytes)?;
|
||||
if persisted.version != 1 {
|
||||
return Err(ExtensionError::StatePersistence(format!(
|
||||
"unsupported extension state file version {} at {}",
|
||||
persisted.version,
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(persisted
|
||||
.extensions
|
||||
.into_iter()
|
||||
.map(|(name, state)| {
|
||||
let version = state.last_version.unwrap_or_default();
|
||||
let installed_at = if state.installed == Some(true) { Some(0) } else { None };
|
||||
|
||||
(
|
||||
name.clone(),
|
||||
ExtensionState {
|
||||
name,
|
||||
version,
|
||||
enabled: state.enabled,
|
||||
installed_at,
|
||||
last_activated_at: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Write extension states to a JSON file atomically.
|
||||
///
|
||||
/// Creates parent directories if they do not exist.
|
||||
pub fn save_states_to_file(path: &Path, states: &HashMap<String, ExtensionState>) -> Result<(), ExtensionError> {
|
||||
// Ensure parent directory exists.
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.exists()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Collect into a stable map keyed by extension name to match the
|
||||
// historical Electron format consumed by existing users.
|
||||
let mut names: Vec<&String> = states.keys().collect();
|
||||
names.sort();
|
||||
|
||||
let mut extensions = BTreeMap::new();
|
||||
for name in names {
|
||||
let state = &states[name];
|
||||
extensions.insert(
|
||||
name.clone(),
|
||||
PersistedExtensionState {
|
||||
enabled: state.enabled,
|
||||
installed: state.installed_at.map(|_| true),
|
||||
last_version: (!state.version.is_empty()).then(|| state.version.clone()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(&PersistedStates { version: 1, extensions })?;
|
||||
|
||||
// Write to a temp file then rename for atomicity.
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp_path, json.as_bytes())?;
|
||||
std::fs::rename(&tmp_path, path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the extension state file path using the historical Nomi rules.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. `NOMIFUN_EXTENSION_STATES_FILE`
|
||||
/// 2. `<data_dir>/extension-states.json`
|
||||
pub fn resolve_state_file_path(data_dir: &Path) -> PathBuf {
|
||||
resolve_state_file_path_inner(std::env::var_os(EXTENSION_STATES_FILE_ENV).as_ref(), data_dir)
|
||||
}
|
||||
|
||||
fn resolve_state_file_path_inner(override_path: Option<&std::ffi::OsString>, data_dir: &Path) -> PathBuf {
|
||||
if let Some(override_path) = override_path {
|
||||
let trimmed = override_path.to_string_lossy().trim().to_owned();
|
||||
if !trimmed.is_empty() {
|
||||
return PathBuf::from(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
data_dir.join(DEFAULT_STATES_FILE)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nomifun_common::now_ms;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_state(name: &str, version: &str, enabled: bool) -> ExtensionState {
|
||||
ExtensionState {
|
||||
name: name.to_string(),
|
||||
version: version.to_string(),
|
||||
enabled,
|
||||
installed_at: Some(now_ms()),
|
||||
last_activated_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
// -- load_states_from_file / save_states_to_file -------------------------
|
||||
|
||||
#[test]
|
||||
fn load_nonexistent_file_returns_empty() {
|
||||
let result = load_states_from_file(Path::new("/nonexistent/states.json")).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("states.json");
|
||||
|
||||
let mut states = HashMap::new();
|
||||
states.insert("ext-a".to_string(), make_state("ext-a", "1.0.0", true));
|
||||
states.insert("ext-b".to_string(), make_state("ext-b", "2.0.0", false));
|
||||
|
||||
save_states_to_file(&path, &states).unwrap();
|
||||
let loaded = load_states_from_file(&path).unwrap();
|
||||
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert_eq!(loaded["ext-a"].version, "1.0.0");
|
||||
assert!(loaded["ext-a"].enabled);
|
||||
assert_eq!(loaded["ext-b"].version, "2.0.0");
|
||||
assert!(!loaded["ext-b"].enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_creates_parent_directories() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("nested").join("dir").join("states.json");
|
||||
|
||||
let states = HashMap::new();
|
||||
save_states_to_file(&path, &states).unwrap();
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_produces_sorted_output() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("states.json");
|
||||
|
||||
let mut states = HashMap::new();
|
||||
states.insert("z-ext".to_string(), make_state("z-ext", "1.0.0", true));
|
||||
states.insert("a-ext".to_string(), make_state("a-ext", "1.0.0", true));
|
||||
|
||||
save_states_to_file(&path, &states).unwrap();
|
||||
|
||||
let raw = std::fs::read_to_string(&path).unwrap();
|
||||
let parsed: PersistedStates = serde_json::from_str(&raw).unwrap();
|
||||
let ordered: Vec<&str> = parsed.extensions.keys().map(|k| k.as_str()).collect();
|
||||
assert_eq!(ordered, vec!["a-ext", "z-ext"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_invalid_json_returns_error() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("states.json");
|
||||
std::fs::write(&path, b"not valid json").unwrap();
|
||||
|
||||
let result = load_states_from_file(&path);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_object_format_returns_states() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("states.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{
|
||||
"version": 1,
|
||||
"extensions": {
|
||||
"ext-a": {
|
||||
"enabled": true,
|
||||
"installed": true,
|
||||
"lastVersion": "1.2.3"
|
||||
},
|
||||
"ext-b": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let loaded = load_states_from_file(&path).unwrap();
|
||||
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert!(loaded["ext-a"].enabled);
|
||||
assert_eq!(loaded["ext-a"].version, "1.2.3");
|
||||
assert!(!loaded["ext-b"].enabled);
|
||||
assert_eq!(loaded["ext-b"].version, "");
|
||||
assert!(loaded["ext-a"].installed_at.is_some());
|
||||
assert!(loaded["ext-a"].last_activated_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_preserves_object_format() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("states.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{
|
||||
"version": 1,
|
||||
"extensions": {
|
||||
"ext-a": {
|
||||
"enabled": true,
|
||||
"lastVersion": "1.2.3"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let loaded = load_states_from_file(&path).unwrap();
|
||||
save_states_to_file(&path, &loaded).unwrap();
|
||||
|
||||
let raw = std::fs::read_to_string(&path).unwrap();
|
||||
let parsed: PersistedStates = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(parsed.version, 1);
|
||||
assert_eq!(parsed.extensions.len(), 1);
|
||||
assert_eq!(parsed.extensions["ext-a"].last_version.as_deref(), Some("1.2.3"));
|
||||
assert!(parsed.extensions["ext-a"].enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_state_file_path_prefers_data_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = resolve_state_file_path_inner(None, dir.path());
|
||||
assert_eq!(path, dir.path().join("extension-states.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_state_file_path_honors_env_override() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let override_path = dir.path().join("custom-states.json");
|
||||
let override_os = override_path.as_os_str().to_os_string();
|
||||
let path = resolve_state_file_path_inner(Some(&override_os), Path::new("/ignored"));
|
||||
assert_eq!(path, override_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_atomic_write() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("states.json");
|
||||
|
||||
let mut states = HashMap::new();
|
||||
states.insert("ext-a".to_string(), make_state("ext-a", "1.0.0", true));
|
||||
save_states_to_file(&path, &states).unwrap();
|
||||
|
||||
// Temp file should be cleaned up.
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
assert!(!tmp_path.exists());
|
||||
}
|
||||
|
||||
// -- ExtensionStateStore (async) ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_load_nonexistent_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("nonexistent.json");
|
||||
let store = ExtensionStateStore::new(path);
|
||||
|
||||
let states = store.load().await.unwrap();
|
||||
assert!(states.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_set_and_get() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("states.json");
|
||||
let store = ExtensionStateStore::new(path);
|
||||
|
||||
store.load().await.unwrap();
|
||||
store.set(make_state("ext-a", "1.0.0", true)).await;
|
||||
|
||||
let state = store.get("ext-a").await;
|
||||
assert!(state.is_some());
|
||||
assert!(state.unwrap().enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_set_all_replaces_everything() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("states.json");
|
||||
let store = ExtensionStateStore::new(path);
|
||||
|
||||
store.load().await.unwrap();
|
||||
store.set(make_state("old", "1.0.0", true)).await;
|
||||
|
||||
let mut new_states = HashMap::new();
|
||||
new_states.insert("new".to_string(), make_state("new", "2.0.0", false));
|
||||
store.set_all(new_states).await;
|
||||
|
||||
assert!(store.get("old").await.is_none());
|
||||
assert!(store.get("new").await.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_remove() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("states.json");
|
||||
let store = ExtensionStateStore::new(path);
|
||||
|
||||
store.load().await.unwrap();
|
||||
store.set(make_state("ext-a", "1.0.0", true)).await;
|
||||
assert!(store.get("ext-a").await.is_some());
|
||||
|
||||
store.remove("ext-a").await;
|
||||
assert!(store.get("ext-a").await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_flush_persists_to_disk() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("states.json");
|
||||
let store = ExtensionStateStore::new(path.clone());
|
||||
|
||||
store.load().await.unwrap();
|
||||
store.set(make_state("ext-a", "1.0.0", true)).await;
|
||||
store.flush().await.unwrap();
|
||||
|
||||
// Verify file exists and contains the state.
|
||||
let loaded = load_states_from_file(&path).unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert!(loaded.contains_key("ext-a"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_load_restores_existing_states() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("states.json");
|
||||
|
||||
// Pre-populate the file.
|
||||
let mut states = HashMap::new();
|
||||
states.insert("ext-a".to_string(), make_state("ext-a", "1.0.0", false));
|
||||
save_states_to_file(&path, &states).unwrap();
|
||||
|
||||
// Load into a fresh store.
|
||||
let store = ExtensionStateStore::new(path);
|
||||
let loaded = store.load().await.unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert!(!loaded["ext-a"].enabled);
|
||||
|
||||
// Memory state matches.
|
||||
let state = store.get("ext-a").await.unwrap();
|
||||
assert!(!state.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_debounced_write() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("states.json");
|
||||
let store = ExtensionStateStore::new(path.clone());
|
||||
|
||||
store.load().await.unwrap();
|
||||
|
||||
// Multiple rapid writes should be collapsed.
|
||||
for i in 0..5 {
|
||||
store.set(make_state(&format!("ext-{i}"), "1.0.0", true)).await;
|
||||
}
|
||||
|
||||
// Wait for debounce to settle.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(STATE_PERSIST_DEBOUNCE_MS + 200)).await;
|
||||
|
||||
let loaded = load_states_from_file(&path).unwrap();
|
||||
assert_eq!(loaded.len(), 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::ExtensionError;
|
||||
|
||||
/// Resolve `${ENV_VAR}` placeholders in a string value.
|
||||
///
|
||||
/// - **Lenient mode** (default): undefined variables are replaced with empty string.
|
||||
/// - **Strict mode** (`strict = true`): undefined variables return an error.
|
||||
pub fn resolve_env_templates(value: &str, strict: bool) -> Result<String, ExtensionError> {
|
||||
let mut result = String::with_capacity(value.len());
|
||||
let mut chars = value.chars().peekable();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '$' && chars.peek() == Some(&'{') {
|
||||
chars.next(); // consume '{'
|
||||
let var_name = collect_until_closing_brace(&mut chars);
|
||||
if var_name.is_empty() {
|
||||
// Malformed `${}` — pass through literally
|
||||
result.push_str("${}");
|
||||
continue;
|
||||
}
|
||||
match std::env::var(&var_name) {
|
||||
Ok(val) => result.push_str(&val),
|
||||
Err(_) if strict => {
|
||||
return Err(ExtensionError::UndefinedEnvVariable(var_name));
|
||||
}
|
||||
Err(_) => { /* lenient: replace with empty string */ }
|
||||
}
|
||||
} else {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Collect characters until `}` or end-of-string.
|
||||
fn collect_until_closing_brace(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
|
||||
let mut name = String::new();
|
||||
for ch in chars.by_ref() {
|
||||
if ch == '}' {
|
||||
return name;
|
||||
}
|
||||
name.push(ch);
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// If `value` starts with `@file:`, read the referenced file content relative to `ext_dir`.
|
||||
/// Otherwise return the value unchanged.
|
||||
///
|
||||
/// Path traversal protection: the resolved path must remain within `ext_dir`.
|
||||
pub fn resolve_file_reference(value: &str, ext_dir: &Path) -> Result<String, ExtensionError> {
|
||||
let Some(rel_path) = value.strip_prefix("@file:") else {
|
||||
return Ok(value.to_owned());
|
||||
};
|
||||
|
||||
if rel_path.is_empty() {
|
||||
return Err(ExtensionError::FileReferenceNotFound("@file: with empty path".into()));
|
||||
}
|
||||
|
||||
let full_path = ext_dir.join(rel_path);
|
||||
if !full_path.exists() {
|
||||
return Err(ExtensionError::FileReferenceNotFound(full_path.display().to_string()));
|
||||
}
|
||||
|
||||
// Canonicalize both paths to resolve symlinks and `..` components,
|
||||
// then verify the target stays within the extension directory.
|
||||
let canonical_dir = ext_dir.canonicalize().map_err(ExtensionError::from)?;
|
||||
let canonical_file = full_path.canonicalize().map_err(ExtensionError::from)?;
|
||||
|
||||
if !canonical_file.starts_with(&canonical_dir) {
|
||||
return Err(ExtensionError::PathTraversal(rel_path.to_owned()));
|
||||
}
|
||||
|
||||
std::fs::read_to_string(&canonical_file).map_err(ExtensionError::from)
|
||||
}
|
||||
|
||||
/// Resolve all `${ENV_VAR}` placeholders in a map of key-value env entries.
|
||||
pub fn resolve_env_map(
|
||||
env: &std::collections::HashMap<String, String>,
|
||||
strict: bool,
|
||||
) -> Result<std::collections::HashMap<String, String>, ExtensionError> {
|
||||
env.iter()
|
||||
.map(|(k, v)| {
|
||||
let resolved = resolve_env_templates(v, strict)?;
|
||||
Ok((k.clone(), resolved))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// -- resolve_env_templates --
|
||||
|
||||
#[test]
|
||||
fn test_no_placeholders() {
|
||||
let result = resolve_env_templates("hello world", false).unwrap();
|
||||
assert_eq!(result, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_env_var() {
|
||||
unsafe { std::env::set_var("_TEST_RESOLVE_SINGLE", "resolved_value") };
|
||||
let result = resolve_env_templates("key=${_TEST_RESOLVE_SINGLE}", false).unwrap();
|
||||
assert_eq!(result, "key=resolved_value");
|
||||
unsafe { std::env::remove_var("_TEST_RESOLVE_SINGLE") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_env_vars() {
|
||||
unsafe { std::env::set_var("_TEST_A", "alpha") };
|
||||
unsafe { std::env::set_var("_TEST_B", "beta") };
|
||||
let result = resolve_env_templates("${_TEST_A} and ${_TEST_B}", false).unwrap();
|
||||
assert_eq!(result, "alpha and beta");
|
||||
unsafe { std::env::remove_var("_TEST_A") };
|
||||
unsafe { std::env::remove_var("_TEST_B") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undefined_lenient_replaces_empty() {
|
||||
let result = resolve_env_templates("val=${_NONEXISTENT_VAR_123}", false).unwrap();
|
||||
assert_eq!(result, "val=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undefined_strict_returns_error() {
|
||||
let err = resolve_env_templates("${_NONEXISTENT_VAR_456}", true).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::UndefinedEnvVariable(ref v) if v == "_NONEXISTENT_VAR_456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_braces_pass_through() {
|
||||
let result = resolve_env_templates("before${}after", false).unwrap();
|
||||
assert_eq!(result, "before${}after");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dollar_without_brace_pass_through() {
|
||||
let result = resolve_env_templates("cost is $50", false).unwrap();
|
||||
assert_eq!(result, "cost is $50");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nested_dollar_brace() {
|
||||
unsafe { std::env::set_var("_TEST_NESTED", "inner") };
|
||||
let result = resolve_env_templates("${_TEST_NESTED}", false).unwrap();
|
||||
assert_eq!(result, "inner");
|
||||
unsafe { std::env::remove_var("_TEST_NESTED") };
|
||||
}
|
||||
|
||||
// -- resolve_file_reference --
|
||||
|
||||
#[test]
|
||||
fn test_non_file_reference_unchanged() {
|
||||
let result = resolve_file_reference("just a string", Path::new("/tmp")).unwrap();
|
||||
assert_eq!(result, "just a string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_reference_reads_content() {
|
||||
let dir = std::env::temp_dir().join("ext_test_file_ref");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file_path = dir.join("prompt.md");
|
||||
std::fs::write(&file_path, "You are a helpful assistant.").unwrap();
|
||||
|
||||
let result = resolve_file_reference("@file:prompt.md", &dir).unwrap();
|
||||
assert_eq!(result, "You are a helpful assistant.");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_reference_not_found() {
|
||||
let err = resolve_file_reference("@file:nonexistent.md", Path::new("/tmp/no_such_ext")).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::FileReferenceNotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_reference_empty_path() {
|
||||
let err = resolve_file_reference("@file:", Path::new("/tmp")).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::FileReferenceNotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_reference_path_traversal_blocked() {
|
||||
let dir = std::env::temp_dir().join("ext_test_traversal");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// Create a file outside the extension directory
|
||||
let outside_file = std::env::temp_dir().join("ext_test_traversal_secret.txt");
|
||||
std::fs::write(&outside_file, "secret data").unwrap();
|
||||
|
||||
let err = resolve_file_reference("@file:../ext_test_traversal_secret.txt", &dir).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::PathTraversal(_)));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
std::fs::remove_file(&outside_file).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_reference_nested_traversal_blocked() {
|
||||
let dir = std::env::temp_dir().join("ext_test_nested_traversal");
|
||||
let sub = dir.join("sub");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
|
||||
let outside_file = std::env::temp_dir().join("ext_test_nested_secret.txt");
|
||||
std::fs::write(&outside_file, "nested secret").unwrap();
|
||||
|
||||
let err = resolve_file_reference("@file:sub/../../ext_test_nested_secret.txt", &dir).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::PathTraversal(_)));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
std::fs::remove_file(&outside_file).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_reference_valid_subdir_allowed() {
|
||||
let dir = std::env::temp_dir().join("ext_test_valid_subdir");
|
||||
let sub = dir.join("prompts");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
std::fs::write(sub.join("system.md"), "valid content").unwrap();
|
||||
|
||||
let result = resolve_file_reference("@file:prompts/system.md", &dir).unwrap();
|
||||
assert_eq!(result, "valid content");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
// -- resolve_env_map --
|
||||
|
||||
#[test]
|
||||
fn test_resolve_env_map_lenient() {
|
||||
unsafe { std::env::set_var("_TEST_MAP_KEY", "secret123") };
|
||||
let mut env = HashMap::new();
|
||||
env.insert("API_KEY".into(), "${_TEST_MAP_KEY}".into());
|
||||
env.insert("STATIC".into(), "static_value".into());
|
||||
|
||||
let resolved = resolve_env_map(&env, false).unwrap();
|
||||
assert_eq!(resolved["API_KEY"], "secret123");
|
||||
assert_eq!(resolved["STATIC"], "static_value");
|
||||
|
||||
unsafe { std::env::remove_var("_TEST_MAP_KEY") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_env_map_strict_error() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("KEY".into(), "${_MISSING_MAP_VAR}".into());
|
||||
|
||||
let err = resolve_env_map(&env, true).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::UndefinedEnvVariable(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nomifun_common::TimestampMs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A. Permissions & Risk
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Network access permission — either unrestricted (`true`) or domain-scoped.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum NetworkPermission {
|
||||
/// Unrestricted network access (dangerous).
|
||||
Unrestricted(bool),
|
||||
/// Domain-scoped network access (moderate).
|
||||
Scoped {
|
||||
#[serde(rename = "allowedDomains")]
|
||||
allowed_domains: Vec<String>,
|
||||
reasoning: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Filesystem access scope.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum FilesystemScope {
|
||||
ExtensionOnly,
|
||||
Workspace,
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Extension permission declarations.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtPermissions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub storage: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub network: Option<NetworkPermission>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub shell: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub filesystem: Option<FilesystemScope>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub clipboard: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active_user: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub events: Option<bool>,
|
||||
}
|
||||
|
||||
/// Overall risk level derived from permission declarations.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RiskLevel {
|
||||
Safe,
|
||||
Moderate,
|
||||
Dangerous,
|
||||
}
|
||||
|
||||
/// Granularity of a single permission entry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PermissionLevel {
|
||||
None,
|
||||
Limited,
|
||||
Full,
|
||||
}
|
||||
|
||||
/// A single permission detail for display purposes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PermissionDetail {
|
||||
pub permission: String,
|
||||
pub level: PermissionLevel,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Complete permission analysis summary.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PermissionSummary {
|
||||
pub permissions: ExtPermissions,
|
||||
pub risk_level: RiskLevel,
|
||||
pub details: Vec<PermissionDetail>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B. Contribution types (what an extension provides)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// ACP adapter contributed by an extension.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtAcpAdapter {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cli_command: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_cli_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub acp_args: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub env: HashMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub avatar: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_required: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub supports_streaming: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub connection_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub yolo_mode: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub health_check: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub api_key_fields: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// MCP server contributed by an extension.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtMcpServer {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub config: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Assistant contributed by an extension.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtAssistant {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub system_prompt: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", alias = "presetAgentType")]
|
||||
pub preset_agent_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty", alias = "enabledSkills")]
|
||||
pub enabled_skills: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub prompts: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// Autonomous agent contributed by an extension.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtAgent {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty", alias = "enabledSkills")]
|
||||
pub enabled_skills: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub prompts: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// Skill contributed by an extension.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtSkill {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// Theme contributed by an extension.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtTheme {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Relative path to the CSS file.
|
||||
pub css_file: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cover_image: Option<String>,
|
||||
}
|
||||
|
||||
/// Channel plugin contributed by an extension.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtChannelPlugin {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub platform: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub entry_point: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty", alias = "credentialFields")]
|
||||
pub credential_fields: Vec<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty", alias = "configFields")]
|
||||
pub config_fields: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// WebUI route definition.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtWebuiRoute {
|
||||
pub path: String,
|
||||
pub method: String,
|
||||
pub handler: String,
|
||||
}
|
||||
|
||||
/// WebUI contribution from an extension.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtWebui {
|
||||
pub id: String,
|
||||
pub directory: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub routes: Vec<ExtWebuiRoute>,
|
||||
}
|
||||
|
||||
/// Settings tab position relative to a built-in tab.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SettingsTabPosition {
|
||||
#[serde(rename = "relativeTo", alias = "anchor", alias = "relative_to")]
|
||||
pub relative_to: String,
|
||||
pub placement: String,
|
||||
}
|
||||
|
||||
fn default_settings_tab_order() -> u32 {
|
||||
100
|
||||
}
|
||||
|
||||
/// Settings tab contributed by an extension.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtSettingsTab {
|
||||
pub id: String,
|
||||
#[serde(alias = "name")]
|
||||
pub label: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(alias = "entryPoint")]
|
||||
pub url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<SettingsTabPosition>,
|
||||
#[serde(default = "default_settings_tab_order")]
|
||||
pub order: u32,
|
||||
}
|
||||
|
||||
/// Model provider contributed by an extension.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtModelProvider {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub protocol: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// All contributions declared by an extension.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtContributes {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub acp_adapters: Vec<ExtAcpAdapter>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub mcp_servers: Vec<ExtMcpServer>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assistants: Vec<ExtAssistant>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub agents: Vec<ExtAgent>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub skills: Vec<ExtSkill>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub themes: Vec<ExtTheme>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub channel_plugins: Vec<ExtChannelPlugin>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub webui: Vec<ExtWebui>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub settings_tabs: Vec<ExtSettingsTab>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub model_providers: Vec<ExtModelProvider>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C. Extension manifest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// i18n configuration block.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct I18nConfig {
|
||||
pub locales: Vec<String>,
|
||||
#[serde(default = "default_i18n_directory")]
|
||||
pub directory: String,
|
||||
}
|
||||
|
||||
fn default_i18n_directory() -> String {
|
||||
"i18n".to_owned()
|
||||
}
|
||||
|
||||
/// Engine compatibility declaration.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct EngineConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub nomifun: Option<String>,
|
||||
}
|
||||
|
||||
/// Lifecycle hook declarations (paths relative to extension root).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct LifecycleHooks {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_install: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_uninstall: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_activate: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_deactivate: Option<String>,
|
||||
}
|
||||
|
||||
/// Complete extension manifest parsed from `nomi-extension.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtensionManifest {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub license: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub homepage: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub engine: Option<EngineConfig>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_version: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub dependencies: HashMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub entry_point: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permissions: Option<ExtPermissions>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub contributes: Option<ExtContributes>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub lifecycle: Option<LifecycleHooks>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub i18n: Option<I18nConfig>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// D. Extension runtime state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Where the extension was loaded from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ExtensionSource {
|
||||
Local,
|
||||
Appdata,
|
||||
Env,
|
||||
}
|
||||
|
||||
/// Persisted state for an extension.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtensionState {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub enabled: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub installed_at: Option<TimestampMs>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_activated_at: Option<TimestampMs>,
|
||||
}
|
||||
|
||||
/// A fully loaded extension with its manifest, location, and runtime state.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct LoadedExtension {
|
||||
pub manifest: ExtensionManifest,
|
||||
pub directory: String,
|
||||
pub source: ExtensionSource,
|
||||
pub state: ExtensionState,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E. Extension system events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Events emitted by the extension system.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum ExtensionSystemEvent {
|
||||
ExtensionActivated,
|
||||
ExtensionDeactivated,
|
||||
ExtensionInstalled,
|
||||
ExtensionUninstalled,
|
||||
RegistryReloaded,
|
||||
StatesPersisted,
|
||||
}
|
||||
|
||||
/// Payload for extension lifecycle events (M-46).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExtensionLifecyclePayload {
|
||||
pub extension_name: String,
|
||||
pub event: ExtensionSystemEvent,
|
||||
pub timestamp: TimestampMs,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F. Hub types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Installation status of a Hub extension.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HubExtensionStatus {
|
||||
NotInstalled,
|
||||
Installed,
|
||||
UpdateAvailable,
|
||||
Installing,
|
||||
InstallFailed,
|
||||
}
|
||||
|
||||
/// A Hub extension entry with runtime status.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct HubExtensionWithStatus {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub bundled: bool,
|
||||
pub status: HubExtensionStatus,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// G. Resolved contribution types (post-processing output)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolved ACP adapter (after env template resolution).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ResolvedAcpAdapter {
|
||||
pub extension_name: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cli_command: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_cli_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub acp_args: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub env: HashMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub avatar: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_required: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub supports_streaming: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub connection_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub yolo_mode: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub health_check: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub api_key_fields: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Resolved MCP server (after env template resolution).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ResolvedMcpServer {
|
||||
pub extension_name: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub config: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Resolved assistant (after @file: and env template resolution).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ResolvedAssistant {
|
||||
pub extension_name: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub system_prompt: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preset_agent_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub enabled_skills: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub prompts: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// Resolved agent (after @file: and env template resolution).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ResolvedAgent {
|
||||
pub extension_name: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub enabled_skills: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub prompts: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// Resolved skill contributed by an extension.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ResolvedSkill {
|
||||
pub extension_name: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolved theme (CSS content loaded into memory).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ResolvedTheme {
|
||||
pub extension_name: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub css_content: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cover_image: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolved channel plugin.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ResolvedChannelPlugin {
|
||||
pub extension_name: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub platform: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub entry_point: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub credential_fields: Vec<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub config_fields: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Resolved WebUI contribution (after route validation).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct WebuiContribution {
|
||||
pub extension_name: String,
|
||||
pub id: String,
|
||||
pub directory: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub routes: Vec<ExtWebuiRoute>,
|
||||
}
|
||||
|
||||
/// Resolved settings tab (after position parsing).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ResolvedSettingsTab {
|
||||
#[serde(rename = "extensionName")]
|
||||
pub extension_name: String,
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
pub url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<SettingsTabPosition>,
|
||||
pub order: u32,
|
||||
}
|
||||
|
||||
/// Resolved model provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ResolvedModelProvider {
|
||||
pub extension_name: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub protocol: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// H. Resolved contributions container
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// All resolved contributions from enabled extensions.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ResolvedContributions {
|
||||
pub acp_adapters: Vec<ResolvedAcpAdapter>,
|
||||
pub mcp_servers: Vec<ResolvedMcpServer>,
|
||||
pub assistants: Vec<ResolvedAssistant>,
|
||||
pub agents: Vec<ResolvedAgent>,
|
||||
pub skills: Vec<ResolvedSkill>,
|
||||
pub themes: Vec<ResolvedTheme>,
|
||||
pub channel_plugins: Vec<ResolvedChannelPlugin>,
|
||||
pub webui: Vec<WebuiContribution>,
|
||||
pub settings_tabs: Vec<ResolvedSettingsTab>,
|
||||
pub model_providers: Vec<ResolvedModelProvider>,
|
||||
/// i18n data keyed by extension name, then by message key.
|
||||
pub i18n: HashMap<String, HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "types_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,558 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// -- Permissions & Risk --
|
||||
|
||||
#[test]
|
||||
fn test_risk_level_serde() {
|
||||
assert_eq!(serde_json::to_string(&RiskLevel::Safe).unwrap(), r#""safe""#);
|
||||
assert_eq!(serde_json::to_string(&RiskLevel::Moderate).unwrap(), r#""moderate""#);
|
||||
assert_eq!(serde_json::to_string(&RiskLevel::Dangerous).unwrap(), r#""dangerous""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_permission_unrestricted() {
|
||||
let perm = NetworkPermission::Unrestricted(true);
|
||||
let json = serde_json::to_value(&perm).unwrap();
|
||||
assert_eq!(json, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_permission_scoped() {
|
||||
let perm = NetworkPermission::Scoped {
|
||||
allowed_domains: vec!["api.example.com".into()],
|
||||
reasoning: "needed for API calls".into(),
|
||||
};
|
||||
let json = serde_json::to_value(&perm).unwrap();
|
||||
assert_eq!(json["allowedDomains"], json!(["api.example.com"]));
|
||||
assert_eq!(json["reasoning"], "needed for API calls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_permission_scoped_deserialize() {
|
||||
let raw = json!({"allowedDomains": ["a.com"], "reasoning": "test"});
|
||||
let perm: NetworkPermission = serde_json::from_value(raw).unwrap();
|
||||
assert!(matches!(perm, NetworkPermission::Scoped { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filesystem_scope_serde() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&FilesystemScope::ExtensionOnly).unwrap(),
|
||||
r#""extension-only""#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&FilesystemScope::Workspace).unwrap(),
|
||||
r#""workspace""#
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&FilesystemScope::Full).unwrap(), r#""full""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ext_permissions_empty() {
|
||||
let perms = ExtPermissions::default();
|
||||
let json = serde_json::to_value(&perms).unwrap();
|
||||
assert_eq!(json, json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ext_permissions_roundtrip() {
|
||||
let perms = ExtPermissions {
|
||||
storage: Some(true),
|
||||
network: Some(NetworkPermission::Unrestricted(true)),
|
||||
shell: Some(true),
|
||||
filesystem: Some(FilesystemScope::Full),
|
||||
clipboard: None,
|
||||
active_user: None,
|
||||
events: Some(true),
|
||||
};
|
||||
let json_str = serde_json::to_string(&perms).unwrap();
|
||||
let parsed: ExtPermissions = serde_json::from_str(&json_str).unwrap();
|
||||
assert_eq!(parsed, perms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_permission_level_serde() {
|
||||
let cases = [
|
||||
(PermissionLevel::None, r#""none""#),
|
||||
(PermissionLevel::Limited, r#""limited""#),
|
||||
(PermissionLevel::Full, r#""full""#),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Contributions --
|
||||
|
||||
#[test]
|
||||
fn test_ext_contributes_empty() {
|
||||
let c = ExtContributes::default();
|
||||
let json = serde_json::to_value(&c).unwrap();
|
||||
assert_eq!(json, json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ext_contributes_with_skills() {
|
||||
let c = ExtContributes {
|
||||
skills: vec![ExtSkill {
|
||||
name: "my-skill".into(),
|
||||
description: Some("A test skill".into()),
|
||||
path: Some("skills/my-skill".into()),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_value(&c).unwrap();
|
||||
assert_eq!(json["skills"][0]["name"], "my-skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ext_acp_adapter_minimal() {
|
||||
let adapter = ExtAcpAdapter {
|
||||
id: "claude-adapter".into(),
|
||||
name: "Claude".into(),
|
||||
description: None,
|
||||
cli_command: Some("claude".into()),
|
||||
default_cli_path: None,
|
||||
acp_args: vec![],
|
||||
env: HashMap::new(),
|
||||
avatar: None,
|
||||
auth_required: None,
|
||||
supports_streaming: Some(true),
|
||||
connection_type: None,
|
||||
endpoint: None,
|
||||
models: vec![],
|
||||
yolo_mode: None,
|
||||
health_check: None,
|
||||
api_key_fields: vec![],
|
||||
};
|
||||
let json = serde_json::to_value(&adapter).unwrap();
|
||||
assert_eq!(json["id"], "claude-adapter");
|
||||
assert_eq!(json["cli_command"], "claude");
|
||||
assert_eq!(json["supports_streaming"], true);
|
||||
// Empty vecs should be omitted
|
||||
assert!(json.get("acp_args").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ext_theme_serde() {
|
||||
let theme = ExtTheme {
|
||||
id: "dark".into(),
|
||||
name: "Dark Mode".into(),
|
||||
description: Some("A dark theme".into()),
|
||||
css_file: "themes/dark.css".into(),
|
||||
cover_image: Some("images/dark-preview.png".into()),
|
||||
};
|
||||
let json = serde_json::to_value(&theme).unwrap();
|
||||
assert_eq!(json["css_file"], "themes/dark.css");
|
||||
assert_eq!(json["cover_image"], "images/dark-preview.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ext_webui_with_routes() {
|
||||
let webui = ExtWebui {
|
||||
id: "my-panel".into(),
|
||||
directory: "webui/dist".into(),
|
||||
routes: vec![ExtWebuiRoute {
|
||||
path: "/my-ext/api/data".into(),
|
||||
method: "GET".into(),
|
||||
handler: "handlers/data.js".into(),
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_value(&webui).unwrap();
|
||||
assert_eq!(json["routes"][0]["path"], "/my-ext/api/data");
|
||||
assert_eq!(json["routes"][0]["method"], "GET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ext_settings_tab_with_position() {
|
||||
let tab = ExtSettingsTab {
|
||||
id: "ext-settings".into(),
|
||||
label: "Extension Settings".into(),
|
||||
icon: None,
|
||||
url: "settings/index.html".into(),
|
||||
position: Some(SettingsTabPosition {
|
||||
relative_to: "general".into(),
|
||||
placement: "after".into(),
|
||||
}),
|
||||
order: 80,
|
||||
};
|
||||
let json = serde_json::to_value(&tab).unwrap();
|
||||
assert_eq!(json["position"]["relativeTo"], "general");
|
||||
assert_eq!(json["position"]["placement"], "after");
|
||||
assert_eq!(json["order"], 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ext_settings_tab_accepts_legacy_field_aliases() {
|
||||
let raw = json!({
|
||||
"id": "legacy-settings",
|
||||
"name": "Legacy Settings",
|
||||
"entryPoint": "settings/legacy.html",
|
||||
"position": {
|
||||
"anchor": "general",
|
||||
"placement": "after"
|
||||
}
|
||||
});
|
||||
|
||||
let tab: ExtSettingsTab = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(tab.label, "Legacy Settings");
|
||||
assert_eq!(tab.url, "settings/legacy.html");
|
||||
assert_eq!(tab.position.unwrap().relative_to, "general");
|
||||
assert_eq!(tab.order, 100);
|
||||
}
|
||||
|
||||
// -- ExtMcpServer flatten roundtrip (M-50) --
|
||||
|
||||
#[test]
|
||||
fn test_ext_mcp_server_roundtrip_with_extra_config() {
|
||||
let raw = json!({
|
||||
"id": "my-mcp",
|
||||
"name": "My MCP Server",
|
||||
"description": "A test MCP server",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
"transport": "stdio"
|
||||
});
|
||||
let server: ExtMcpServer = serde_json::from_value(raw.clone()).unwrap();
|
||||
assert_eq!(server.id, "my-mcp");
|
||||
assert_eq!(server.name, "My MCP Server");
|
||||
assert_eq!(server.description.as_deref(), Some("A test MCP server"));
|
||||
|
||||
// Flattened config should contain the extra fields
|
||||
let re_serialized = serde_json::to_value(&server).unwrap();
|
||||
assert_eq!(re_serialized["command"], "npx");
|
||||
assert_eq!(re_serialized["transport"], "stdio");
|
||||
assert_eq!(re_serialized["id"], "my-mcp");
|
||||
assert_eq!(re_serialized["name"], "My MCP Server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ext_mcp_server_minimal() {
|
||||
let raw = json!({"id": "s1", "name": "S1"});
|
||||
let server: ExtMcpServer = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(server.id, "s1");
|
||||
let re_serialized = serde_json::to_value(&server).unwrap();
|
||||
assert_eq!(re_serialized["id"], "s1");
|
||||
assert_eq!(re_serialized["name"], "S1");
|
||||
}
|
||||
|
||||
// -- Manifest --
|
||||
|
||||
#[test]
|
||||
fn test_manifest_minimal_deserialize() {
|
||||
let raw = json!({
|
||||
"name": "my-ext",
|
||||
"version": "1.0.0"
|
||||
});
|
||||
let manifest: ExtensionManifest = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(manifest.name, "my-ext");
|
||||
assert_eq!(manifest.version, "1.0.0");
|
||||
assert!(manifest.contributes.is_none());
|
||||
assert!(manifest.permissions.is_none());
|
||||
assert!(manifest.dependencies.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manifest_full_roundtrip() {
|
||||
let manifest = ExtensionManifest {
|
||||
name: "test-ext".into(),
|
||||
version: "2.1.0".into(),
|
||||
display_name: Some("Test Extension".into()),
|
||||
description: Some("A test extension".into()),
|
||||
author: Some("Test Author".into()),
|
||||
license: Some("MIT".into()),
|
||||
homepage: Some("https://example.com".into()),
|
||||
icon: Some("icon.png".into()),
|
||||
engine: Some(EngineConfig {
|
||||
nomifun: Some("^1.0.0".into()),
|
||||
}),
|
||||
api_version: Some("1.0.0".into()),
|
||||
dependencies: HashMap::from([("dep-ext".into(), "^1.0.0".into())]),
|
||||
entry_point: Some("main.js".into()),
|
||||
permissions: Some(ExtPermissions {
|
||||
storage: Some(true),
|
||||
events: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
contributes: Some(ExtContributes::default()),
|
||||
lifecycle: Some(LifecycleHooks {
|
||||
on_install: Some("scripts/install.sh".into()),
|
||||
on_activate: Some("scripts/activate.sh".into()),
|
||||
on_deactivate: None,
|
||||
on_uninstall: None,
|
||||
}),
|
||||
i18n: Some(I18nConfig {
|
||||
locales: vec!["en".into(), "zh-CN".into()],
|
||||
directory: "i18n".into(),
|
||||
}),
|
||||
};
|
||||
let json_str = serde_json::to_string(&manifest).unwrap();
|
||||
let parsed: ExtensionManifest = serde_json::from_str(&json_str).unwrap();
|
||||
assert_eq!(parsed, manifest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manifest_snake_case_keys() {
|
||||
let manifest = ExtensionManifest {
|
||||
name: "x".into(),
|
||||
version: "1.0.0".into(),
|
||||
display_name: Some("X".into()),
|
||||
api_version: Some("1.0.0".into()),
|
||||
entry_point: Some("main.js".into()),
|
||||
description: None,
|
||||
author: None,
|
||||
license: None,
|
||||
homepage: None,
|
||||
icon: None,
|
||||
engine: None,
|
||||
dependencies: HashMap::new(),
|
||||
permissions: None,
|
||||
contributes: None,
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
};
|
||||
let json = serde_json::to_value(&manifest).unwrap();
|
||||
assert!(json.get("display_name").is_some());
|
||||
assert!(json.get("api_version").is_some());
|
||||
assert!(json.get("entry_point").is_some());
|
||||
// camelCase keys should not exist
|
||||
assert!(json.get("displayName").is_none());
|
||||
assert!(json.get("apiVersion").is_none());
|
||||
}
|
||||
|
||||
// -- Extension state & source --
|
||||
|
||||
#[test]
|
||||
fn test_extension_source_serde() {
|
||||
let cases = [
|
||||
(ExtensionSource::Local, r#""local""#),
|
||||
(ExtensionSource::Appdata, r#""appdata""#),
|
||||
(ExtensionSource::Env, r#""env""#),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(json, expected);
|
||||
let parsed: ExtensionSource = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extension_state_roundtrip() {
|
||||
let state = ExtensionState {
|
||||
name: "my-ext".into(),
|
||||
version: "1.0.0".into(),
|
||||
enabled: true,
|
||||
installed_at: Some(1700000000000),
|
||||
last_activated_at: Some(1700001000000),
|
||||
};
|
||||
let json_str = serde_json::to_string(&state).unwrap();
|
||||
let parsed: ExtensionState = serde_json::from_str(&json_str).unwrap();
|
||||
assert_eq!(parsed, state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extension_state_optional_timestamps() {
|
||||
let raw = json!({
|
||||
"name": "x",
|
||||
"version": "1.0.0",
|
||||
"enabled": false
|
||||
});
|
||||
let state: ExtensionState = serde_json::from_value(raw).unwrap();
|
||||
assert!(!state.enabled);
|
||||
assert!(state.installed_at.is_none());
|
||||
assert!(state.last_activated_at.is_none());
|
||||
}
|
||||
|
||||
// -- Events --
|
||||
|
||||
#[test]
|
||||
fn test_extension_system_event_serde() {
|
||||
let cases = [
|
||||
(ExtensionSystemEvent::ExtensionActivated, r#""EXTENSION_ACTIVATED""#),
|
||||
(ExtensionSystemEvent::ExtensionDeactivated, r#""EXTENSION_DEACTIVATED""#),
|
||||
(ExtensionSystemEvent::ExtensionInstalled, r#""EXTENSION_INSTALLED""#),
|
||||
(ExtensionSystemEvent::ExtensionUninstalled, r#""EXTENSION_UNINSTALLED""#),
|
||||
(ExtensionSystemEvent::RegistryReloaded, r#""REGISTRY_RELOADED""#),
|
||||
(ExtensionSystemEvent::StatesPersisted, r#""STATES_PERSISTED""#),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(json, expected);
|
||||
let parsed: ExtensionSystemEvent = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lifecycle_payload_roundtrip() {
|
||||
let payload = ExtensionLifecyclePayload {
|
||||
extension_name: "my-ext".into(),
|
||||
event: ExtensionSystemEvent::ExtensionActivated,
|
||||
timestamp: 1700000000000,
|
||||
data: Some(json!({"reason": "user action"})),
|
||||
};
|
||||
let json_str = serde_json::to_string(&payload).unwrap();
|
||||
let parsed: ExtensionLifecyclePayload = serde_json::from_str(&json_str).unwrap();
|
||||
assert_eq!(parsed, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lifecycle_payload_without_data() {
|
||||
let payload = ExtensionLifecyclePayload {
|
||||
extension_name: "test".into(),
|
||||
event: ExtensionSystemEvent::RegistryReloaded,
|
||||
timestamp: 1700000000000,
|
||||
data: None,
|
||||
};
|
||||
let json = serde_json::to_value(&payload).unwrap();
|
||||
assert!(json.get("data").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolved_settings_tab_serializes_backend_contract_keys() {
|
||||
let tab = ResolvedSettingsTab {
|
||||
extension_name: "hello".into(),
|
||||
id: "ext-hello-settings".into(),
|
||||
label: "Hello Settings".into(),
|
||||
icon: Some("/api/extensions/hello/assets/icons/gear.svg".into()),
|
||||
url: "/api/extensions/hello/assets/settings/index.html".into(),
|
||||
position: Some(SettingsTabPosition {
|
||||
relative_to: "general".into(),
|
||||
placement: "after".into(),
|
||||
}),
|
||||
order: 80,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&tab).unwrap();
|
||||
assert_eq!(json["extensionName"], "hello");
|
||||
assert_eq!(json["position"]["relativeTo"], "general");
|
||||
assert_eq!(json["order"], 80);
|
||||
}
|
||||
|
||||
// -- Hub --
|
||||
|
||||
#[test]
|
||||
fn test_hub_extension_status_serde() {
|
||||
let cases = [
|
||||
(HubExtensionStatus::NotInstalled, r#""not_installed""#),
|
||||
(HubExtensionStatus::Installed, r#""installed""#),
|
||||
(HubExtensionStatus::UpdateAvailable, r#""update_available""#),
|
||||
(HubExtensionStatus::Installing, r#""installing""#),
|
||||
(HubExtensionStatus::InstallFailed, r#""install_failed""#),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(json, expected);
|
||||
let parsed: HubExtensionStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hub_extension_with_status_roundtrip() {
|
||||
let ext = HubExtensionWithStatus {
|
||||
name: "cool-ext".into(),
|
||||
version: "1.2.3".into(),
|
||||
display_name: Some("Cool Extension".into()),
|
||||
description: Some("Does cool things".into()),
|
||||
author: Some("Author".into()),
|
||||
icon: None,
|
||||
tags: vec!["productivity".into()],
|
||||
bundled: false,
|
||||
status: HubExtensionStatus::Installed,
|
||||
};
|
||||
let json_str = serde_json::to_string(&ext).unwrap();
|
||||
let parsed: HubExtensionWithStatus = serde_json::from_str(&json_str).unwrap();
|
||||
assert_eq!(parsed, ext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hub_extension_bundled_status() {
|
||||
let ext = HubExtensionWithStatus {
|
||||
name: "builtin-ext".into(),
|
||||
version: "1.0.0".into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
icon: None,
|
||||
tags: vec![],
|
||||
bundled: true,
|
||||
status: HubExtensionStatus::Installed,
|
||||
};
|
||||
let json = serde_json::to_value(&ext).unwrap();
|
||||
assert_eq!(json["bundled"], true);
|
||||
assert_eq!(json["status"], "installed");
|
||||
}
|
||||
|
||||
// -- Loaded extension --
|
||||
|
||||
#[test]
|
||||
fn test_loaded_extension_roundtrip() {
|
||||
let loaded = LoadedExtension {
|
||||
manifest: ExtensionManifest {
|
||||
name: "test".into(),
|
||||
version: "1.0.0".into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
author: None,
|
||||
license: None,
|
||||
homepage: None,
|
||||
icon: None,
|
||||
engine: None,
|
||||
api_version: None,
|
||||
dependencies: HashMap::new(),
|
||||
entry_point: None,
|
||||
permissions: None,
|
||||
contributes: None,
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
},
|
||||
directory: "/path/to/ext".into(),
|
||||
source: ExtensionSource::Env,
|
||||
state: ExtensionState {
|
||||
name: "test".into(),
|
||||
version: "1.0.0".into(),
|
||||
enabled: true,
|
||||
installed_at: None,
|
||||
last_activated_at: None,
|
||||
},
|
||||
};
|
||||
let json_str = serde_json::to_string(&loaded).unwrap();
|
||||
let parsed: LoadedExtension = serde_json::from_str(&json_str).unwrap();
|
||||
assert_eq!(parsed, loaded);
|
||||
}
|
||||
|
||||
// -- I18n config --
|
||||
|
||||
#[test]
|
||||
fn test_i18n_config_default_directory() {
|
||||
let raw = json!({"locales": ["en"]});
|
||||
let config: I18nConfig = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(config.directory, "i18n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_i18n_config_custom_directory() {
|
||||
let raw = json!({"locales": ["en", "zh-CN"], "directory": "lang"});
|
||||
let config: I18nConfig = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(config.directory, "lang");
|
||||
}
|
||||
|
||||
// -- Lifecycle hooks --
|
||||
|
||||
#[test]
|
||||
fn test_lifecycle_hooks_empty() {
|
||||
let hooks = LifecycleHooks::default();
|
||||
let json = serde_json::to_value(&hooks).unwrap();
|
||||
assert_eq!(json, json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lifecycle_hooks_partial() {
|
||||
let raw = json!({"on_install": "scripts/install.sh"});
|
||||
let hooks: LifecycleHooks = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(hooks.on_install.as_deref(), Some("scripts/install.sh"));
|
||||
assert!(hooks.on_activate.is_none());
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use tokio::sync::{Notify, mpsc};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::constants::DEBOUNCE_MS;
|
||||
use crate::registry::ExtensionRegistry;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ExtensionWatcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Watches extension directories for file changes and triggers a debounced
|
||||
/// hot-reload of the [`ExtensionRegistry`].
|
||||
///
|
||||
/// Uses the `notify` crate for cross-platform file system event monitoring
|
||||
/// and a custom debounce mechanism (1000ms) to collapse rapid changes into
|
||||
/// a single reload.
|
||||
pub struct ExtensionWatcher {
|
||||
/// Handle to the notify watcher — kept alive to maintain the watch.
|
||||
_watcher: RecommendedWatcher,
|
||||
/// Signal to request a graceful shutdown of the debounce task.
|
||||
shutdown: Arc<Notify>,
|
||||
}
|
||||
|
||||
impl ExtensionWatcher {
|
||||
/// Start watching the given directories for changes.
|
||||
///
|
||||
/// File change events are debounced by [`DEBOUNCE_MS`] milliseconds
|
||||
/// before triggering `registry.hot_reload()`.
|
||||
///
|
||||
/// Returns `None` if no valid directories are provided or if the watcher
|
||||
/// fails to initialise.
|
||||
pub fn start(directories: Vec<PathBuf>, registry: ExtensionRegistry) -> Option<Self> {
|
||||
if directories.is_empty() {
|
||||
debug!("no directories to watch — skipping extension watcher");
|
||||
return None;
|
||||
}
|
||||
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
|
||||
// Channel for raw FS events → debounce task.
|
||||
let (tx, rx) = mpsc::channel::<()>(16);
|
||||
|
||||
// Spawn the debounce consumer.
|
||||
let shutdown_clone = Arc::clone(&shutdown);
|
||||
tokio::spawn(debounce_loop(rx, registry, shutdown_clone));
|
||||
|
||||
// Create the notify watcher with a callback that feeds the channel.
|
||||
let watcher = create_watcher(tx, &directories);
|
||||
let watcher = match watcher {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
error!(error = %e, "failed to create file watcher");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
info!(dirs = directories.len(), "extension watcher started");
|
||||
|
||||
Some(Self {
|
||||
_watcher: watcher,
|
||||
shutdown,
|
||||
})
|
||||
}
|
||||
|
||||
/// Signal the debounce task to stop.
|
||||
///
|
||||
/// The background task will finish its current cycle (if any) and exit.
|
||||
pub fn stop(&self) {
|
||||
self.shutdown.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ExtensionWatcher {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: watcher creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a `RecommendedWatcher` that sends a unit signal for every relevant
|
||||
/// file-system event.
|
||||
fn create_watcher(tx: mpsc::Sender<()>, directories: &[PathBuf]) -> Result<RecommendedWatcher, notify::Error> {
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |result: Result<Event, notify::Error>| {
|
||||
match result {
|
||||
Ok(event) if is_relevant_event(&event) => {
|
||||
// Best-effort send — if the channel is full we'll coalesce
|
||||
// anyway via debounce.
|
||||
let _ = tx.try_send(());
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "file watcher error");
|
||||
}
|
||||
}
|
||||
},
|
||||
Config::default(),
|
||||
)?;
|
||||
|
||||
for dir in directories {
|
||||
if dir.exists() {
|
||||
if let Err(e) = watcher.watch(dir, RecursiveMode::Recursive) {
|
||||
warn!(
|
||||
dir = %dir.display(),
|
||||
error = %e,
|
||||
"failed to watch directory"
|
||||
);
|
||||
} else {
|
||||
debug!(dir = %dir.display(), "watching directory");
|
||||
}
|
||||
} else {
|
||||
debug!(dir = %dir.display(), "skipping non-existent directory");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
/// Decide whether a file-system event should trigger a reload.
|
||||
///
|
||||
/// We care about creates, all modifications (data, metadata, renames), and
|
||||
/// removes. Only access events and unclassified `Other` events are ignored.
|
||||
///
|
||||
/// Note: `Modify(_)` intentionally matches all modify sub-kinds including
|
||||
/// metadata changes, because some platforms (e.g., macOS/FSEvents) report
|
||||
/// content changes as generic `Modify(Any)` rather than specific sub-kinds.
|
||||
fn is_relevant_event(event: &Event) -> bool {
|
||||
use notify::EventKind;
|
||||
matches!(
|
||||
event.kind,
|
||||
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: debounce loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Consume raw FS event signals, debounce by [`DEBOUNCE_MS`], and trigger
|
||||
/// `registry.hot_reload()`.
|
||||
async fn debounce_loop(mut rx: mpsc::Receiver<()>, registry: ExtensionRegistry, shutdown: Arc<Notify>) {
|
||||
let debounce = Duration::from_millis(DEBOUNCE_MS);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Wait for the first event signal.
|
||||
event = rx.recv() => {
|
||||
if event.is_none() {
|
||||
// Channel closed — sender (watcher) dropped.
|
||||
debug!("watcher channel closed, stopping debounce loop");
|
||||
break;
|
||||
}
|
||||
|
||||
// Drain any additional events that arrived during debounce.
|
||||
tokio::time::sleep(debounce).await;
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
info!("file change detected, triggering hot reload");
|
||||
registry.hot_reload().await;
|
||||
}
|
||||
// Shutdown signal.
|
||||
_ = shutdown.notified() => {
|
||||
debug!("watcher shutdown signal received");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("debounce loop exited");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn relevant_event_create() {
|
||||
let event = Event::new(notify::EventKind::Create(notify::event::CreateKind::File));
|
||||
assert!(is_relevant_event(&event));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relevant_event_modify() {
|
||||
let event = Event::new(notify::EventKind::Modify(notify::event::ModifyKind::Data(
|
||||
notify::event::DataChange::Content,
|
||||
)));
|
||||
assert!(is_relevant_event(&event));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relevant_event_remove() {
|
||||
let event = Event::new(notify::EventKind::Remove(notify::event::RemoveKind::File));
|
||||
assert!(is_relevant_event(&event));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn irrelevant_event_access() {
|
||||
let event = Event::new(notify::EventKind::Access(notify::event::AccessKind::Read));
|
||||
assert!(!is_relevant_event(&event));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn irrelevant_event_other() {
|
||||
let event = Event::new(notify::EventKind::Other);
|
||||
assert!(!is_relevant_event(&event));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user