mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(skills): auto-backup skill files before uninstall
Create a local backup under ~/.cc-switch/skill-backups/ before removing skill directories. The backup includes all skill files and a meta.json with original skill metadata. Old backups are pruned to keep at most 20. The backup path is returned to the frontend and shown in the success toast. Bump version to 3.12.3.
This commit is contained in:
Generated
+1
-1
@@ -684,7 +684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.12.2"
|
||||
version = "3.12.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.12.2"
|
||||
version = "3.12.3"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
|
||||
use crate::error::format_skill_error;
|
||||
use crate::services::skill::{
|
||||
DiscoverableSkill, ImportSkillSelection, Skill, SkillRepo, SkillService,
|
||||
DiscoverableSkill, ImportSkillSelection, Skill, SkillRepo, SkillService, SkillUninstallResult,
|
||||
};
|
||||
use crate::store::AppState;
|
||||
use std::sync::Arc;
|
||||
@@ -58,9 +58,11 @@ pub async fn install_skill_unified(
|
||||
|
||||
/// 卸载 Skill(新版统一卸载)
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill_unified(id: String, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
pub fn uninstall_skill_unified(
|
||||
id: String,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<SkillUninstallResult, String> {
|
||||
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 切换 Skill 的应用启用状态
|
||||
@@ -194,7 +196,10 @@ pub async fn install_skill_for_app(
|
||||
|
||||
/// 卸载技能(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||
pub fn uninstall_skill(
|
||||
directory: String,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<SkillUninstallResult, String> {
|
||||
uninstall_skill_for_app("claude".to_string(), directory, app_state)
|
||||
}
|
||||
|
||||
@@ -204,7 +209,7 @@ pub fn uninstall_skill_for_app(
|
||||
app: String,
|
||||
directory: String,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
) -> Result<SkillUninstallResult, String> {
|
||||
let _ = parse_app_type(&app)?; // 验证参数
|
||||
|
||||
// 通过 directory 找到对应的 skill id
|
||||
@@ -215,9 +220,7 @@ pub fn uninstall_skill_for_app(
|
||||
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
|
||||
.ok_or_else(|| format!("未找到已安装的 Skill: {directory}"))?;
|
||||
|
||||
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ========== 仓库管理命令 ==========
|
||||
|
||||
@@ -152,6 +152,24 @@ impl Default for SkillStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Skill 卸载结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillUninstallResult {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backup_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SkillBackupMetadata {
|
||||
skill: InstalledSkill,
|
||||
backup_created_at: i64,
|
||||
source_path: String,
|
||||
}
|
||||
|
||||
const SKILL_BACKUP_RETAIN_COUNT: usize = 20;
|
||||
|
||||
/// 技能元数据 (从 SKILL.md 解析)
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SkillMetadata {
|
||||
@@ -369,6 +387,13 @@ impl SkillService {
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// 获取 Skill 卸载备份目录(~/.cc-switch/skill-backups/)
|
||||
fn get_backup_dir() -> Result<PathBuf> {
|
||||
let dir = get_app_config_dir().join("skill-backups");
|
||||
fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// 获取应用的 skills 目录
|
||||
pub fn get_app_skills_dir(app: &AppType) -> Result<PathBuf> {
|
||||
// 目录覆盖:优先使用用户在 settings.json 中配置的 override 目录
|
||||
@@ -636,12 +661,15 @@ impl SkillService {
|
||||
/// 1. 从所有应用目录删除
|
||||
/// 2. 从 SSOT 删除
|
||||
/// 3. 从数据库删除
|
||||
pub fn uninstall(db: &Arc<Database>, id: &str) -> Result<()> {
|
||||
pub fn uninstall(db: &Arc<Database>, id: &str) -> Result<SkillUninstallResult> {
|
||||
// 获取 skill 信息
|
||||
let skill = db
|
||||
.get_installed_skill(id)?
|
||||
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
|
||||
|
||||
let backup_path =
|
||||
Self::create_uninstall_backup(&skill)?.map(|path| path.to_string_lossy().to_string());
|
||||
|
||||
// 从所有应用目录删除
|
||||
for app in AppType::all() {
|
||||
let _ = Self::remove_from_app(&skill.directory, &app);
|
||||
@@ -657,9 +685,16 @@ impl SkillService {
|
||||
// 从数据库删除
|
||||
db.delete_skill(id)?;
|
||||
|
||||
log::info!("Skill {} 卸载成功", skill.name);
|
||||
log::info!(
|
||||
"Skill {} 卸载成功{}",
|
||||
skill.name,
|
||||
backup_path
|
||||
.as_deref()
|
||||
.map(|path| format!(", backup: {path}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
Ok(SkillUninstallResult { backup_path })
|
||||
}
|
||||
|
||||
/// 切换应用启用状态
|
||||
@@ -1500,6 +1535,124 @@ impl SkillService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_uninstall_backup_source(skill: &InstalledSkill) -> Result<Option<PathBuf>> {
|
||||
let ssot_path = Self::get_ssot_dir()?.join(&skill.directory);
|
||||
if ssot_path.is_dir() {
|
||||
return Ok(Some(ssot_path));
|
||||
}
|
||||
|
||||
for app in AppType::all() {
|
||||
let app_dir = match Self::get_app_skills_dir(&app) {
|
||||
Ok(dir) => dir,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let candidate = app_dir.join(&skill.directory);
|
||||
if candidate.is_dir() {
|
||||
return Ok(Some(candidate));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn sanitize_backup_segment(segment: &str) -> String {
|
||||
let sanitized = segment
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,
|
||||
_ => '-',
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string();
|
||||
|
||||
if sanitized.is_empty() {
|
||||
"skill".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_old_skill_backups(dir: &Path) -> Result<()> {
|
||||
let mut entries = fs::read_dir(dir)?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter_map(|entry| {
|
||||
let metadata = entry.metadata().ok()?;
|
||||
if !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
Some((entry.path(), metadata.modified().ok()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if entries.len() <= SKILL_BACKUP_RETAIN_COUNT {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
entries.sort_by_key(|(_, modified)| *modified);
|
||||
let remove_count = entries.len().saturating_sub(SKILL_BACKUP_RETAIN_COUNT);
|
||||
|
||||
for (path, _) in entries.into_iter().take(remove_count) {
|
||||
fs::remove_dir_all(&path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_uninstall_backup(skill: &InstalledSkill) -> Result<Option<PathBuf>> {
|
||||
let Some(source_path) = Self::resolve_uninstall_backup_source(skill)? else {
|
||||
log::warn!(
|
||||
"Skill {} 卸载前未找到可备份的目录,将跳过备份",
|
||||
skill.directory
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let backup_root = Self::get_backup_dir()?;
|
||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
|
||||
let slug = Self::sanitize_backup_segment(&skill.directory);
|
||||
let mut backup_path = backup_root.join(format!("{timestamp}_{slug}"));
|
||||
let mut counter = 1;
|
||||
while backup_path.exists() {
|
||||
backup_path = backup_root.join(format!("{timestamp}_{slug}_{counter}"));
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
let write_backup = || -> Result<()> {
|
||||
let skill_backup_dir = backup_path.join("skill");
|
||||
Self::copy_dir_recursive(&source_path, &skill_backup_dir)?;
|
||||
|
||||
let metadata = SkillBackupMetadata {
|
||||
skill: skill.clone(),
|
||||
backup_created_at: Utc::now().timestamp(),
|
||||
source_path: source_path.to_string_lossy().to_string(),
|
||||
};
|
||||
let metadata_path = backup_path.join("meta.json");
|
||||
let metadata_json = serde_json::to_string_pretty(&metadata)
|
||||
.context("failed to serialize skill backup metadata")?;
|
||||
fs::write(&metadata_path, metadata_json)
|
||||
.with_context(|| format!("failed to write {}", metadata_path.display()))?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if let Err(err) = write_backup() {
|
||||
let _ = fs::remove_dir_all(&backup_path);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if let Err(err) = Self::cleanup_old_skill_backups(&backup_root) {
|
||||
log::warn!("清理旧 Skill 备份失败: {err:#}");
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Skill {} 已在卸载前备份到 {}",
|
||||
skill.name,
|
||||
backup_path.display()
|
||||
);
|
||||
|
||||
Ok(Some(backup_path))
|
||||
}
|
||||
|
||||
/// 解析 ZIP 中的符号链接:将目标内容复制到 symlink 位置
|
||||
///
|
||||
/// GitHub ZIP 归档保留了 symlink 元数据,解压时可通过 `is_symlink()` 检测。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.12.2",
|
||||
"version": "3.12.3",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -125,6 +125,74 @@ fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_skill_creates_backup_before_removing_ssot() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
let ssot_skill_dir = home.join(".cc-switch").join("skills").join("backup-skill");
|
||||
write_skill(&ssot_skill_dir, "Backup Skill");
|
||||
fs::write(ssot_skill_dir.join("prompt.md"), "backup me").expect("write prompt.md");
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
state
|
||||
.db
|
||||
.save_skill(&InstalledSkill {
|
||||
id: "local:backup-skill".to_string(),
|
||||
name: "Backup Skill".to_string(),
|
||||
description: Some("Back me up before uninstall".to_string()),
|
||||
directory: "backup-skill".to_string(),
|
||||
repo_owner: None,
|
||||
repo_name: None,
|
||||
repo_branch: None,
|
||||
readme_url: None,
|
||||
apps: SkillApps {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
installed_at: 123,
|
||||
})
|
||||
.expect("save skill");
|
||||
|
||||
let result = SkillService::uninstall(&state.db, "local:backup-skill").expect("uninstall skill");
|
||||
let backup_path = result.backup_path.expect("backup path should be returned");
|
||||
let backup_dir = std::path::PathBuf::from(&backup_path);
|
||||
|
||||
assert!(backup_dir.exists(), "backup directory should exist");
|
||||
assert!(
|
||||
backup_dir.join("skill").join("SKILL.md").exists(),
|
||||
"backup should include SKILL.md"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(backup_dir.join("skill").join("prompt.md"))
|
||||
.expect("read backed up prompt"),
|
||||
"backup me"
|
||||
);
|
||||
|
||||
let metadata: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(backup_dir.join("meta.json")).expect("read backup metadata"),
|
||||
)
|
||||
.expect("parse backup metadata");
|
||||
assert_eq!(metadata["skill"]["directory"], "backup-skill");
|
||||
assert_eq!(metadata["skill"]["name"], "Backup Skill");
|
||||
|
||||
assert!(
|
||||
!ssot_skill_dir.exists(),
|
||||
"SSOT skill directory should be removed after uninstall"
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.db
|
||||
.get_installed_skill("local:backup-skill")
|
||||
.expect("query skill")
|
||||
.is_none(),
|
||||
"database row should be deleted after uninstall"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_snapshot_overrides_multi_source_directory_inference() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
|
||||
Reference in New Issue
Block a user