e245600235
- archive_search: match_expr in search() aufgelöst. sanitize_query lief zweimal
pro Anfrage, und die Leer-Entscheidung stand an zwei Stellen mit leicht
abweichender Bedingung.
- Der Tag-Filter geht jetzt durch dieselbe Quoting-Regel wie der Volltext. Ein
leerer Tag ergab tags:"" und damit einen Syntaxfehler — erreichbar durch das
angetippte #, den ersten Tastendruck jeder Tag-Suche.
- sanitize_query von 26 auf 12 Zeilen: split('"') und split_whitespace statt
handgeschriebenem Tokenizer. Gleiches Ergebnis fuer alle Testeingaben.
- TerminalConfig bekommt ebenfalls serde flatten. Der Block wird als Ganzes
mitgeschrieben, ein terminal.fontSize waere also weiterhin verloren gegangen;
set_terminal_config_in traegt die unbekannten Keys jetzt herueber.
- markdown.test.ts enthielt ein literales NUL-Byte, wodurch git die Datei als
binaer behandelte. Der Test erzeugt das Zeichen jetzt selbst.
- Kleineres: commands_delete-Bedingung als direkter Vergleich, eine
Verschachtelungsebene in pool.rs, zwei redundante Kommentarbloecke.
commands_delete benennt sich in der Doku als Provisorium.
838 lines
30 KiB
Rust
838 lines
30 KiB
Rust
//! Pools: benannte Credential-Sets unter ~/.config/ai-control/pools/<id>.
|
|
//! Jeder Pool-Ordner ist ein vollständiges CLAUDE_CONFIG_DIR (settings.json,
|
|
//! CLAUDE.md, Panel-Skill, MCP-Registrierung); oauth-Credentials verwaltet
|
|
//! claude selbst (Keychain), die App speichert keine Tokens.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::domain::check_name;
|
|
use crate::domain::credentials::{ApikeyStore, KEYCHAIN_UNAVAILABLE};
|
|
use crate::domain::paths::Paths;
|
|
use crate::domain::project::{is_running, projects_using_pool, unassign_pool_in};
|
|
use crate::domain::settings::pool_sync_dir;
|
|
|
|
/// Feste Dateinamen im Pool-Ordner.
|
|
pub(crate) const APIKEY_FILE: &str = "apikey";
|
|
pub(crate) const POOL_FILE: &str = "pool.json";
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub(crate) struct Pool {
|
|
pub(crate) name: String,
|
|
#[serde(rename = "credentialType")]
|
|
pub(crate) credential_type: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub(crate) struct PoolInfo {
|
|
/// Ordnername unter pools/ (bei Neuanlagen eine UUID) — stabile ID, an der
|
|
/// Keychain-Suffix, Symlinks und Projekt-Zuordnungen hängen.
|
|
pub(crate) id: String,
|
|
/// Anzeigename aus pool.json, frei umbenennbar.
|
|
pub(crate) name: String,
|
|
#[serde(rename = "credentialType")]
|
|
pub(crate) credential_type: String,
|
|
pub(crate) projects: Vec<String>,
|
|
/// Teilmenge von `projects`, die gerade läuft (dasselbe `is_running` wie die
|
|
/// Projektliste). Der Löschen-Dialog sperrt darauf.
|
|
pub(crate) running: Vec<String>,
|
|
#[serde(rename = "hasCredentials")]
|
|
pub(crate) has_credentials: bool,
|
|
}
|
|
|
|
pub(crate) fn read_pool(paths: &Paths, pool: &str) -> Result<Pool, String> {
|
|
let cfg_path = paths.pool_dir(pool).join(POOL_FILE);
|
|
let raw = fs::read_to_string(&cfg_path)
|
|
.map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
|
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
|
|
}
|
|
|
|
pub(crate) fn list_pools_in(
|
|
paths: &Paths,
|
|
store: &dyn ApikeyStore,
|
|
) -> Result<Vec<PoolInfo>, String> {
|
|
let mut pools = Vec::new();
|
|
if !paths.pools_dir().is_dir() {
|
|
return Ok(pools);
|
|
}
|
|
let entries =
|
|
fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
|
|
for entry in entries {
|
|
let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
|
|
let cfg_path = entry.path().join(POOL_FILE);
|
|
if !cfg_path.is_file() {
|
|
continue;
|
|
}
|
|
let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
|
let pool: Pool =
|
|
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
|
let id = entry.file_name().to_string_lossy().into_owned();
|
|
// oauth: Credentials liegen in claudes eigenem Keychain-Eintrag, dessen
|
|
// Prüfung wäre ein security-Aufruf pro Pool im 3-s-Polling — immer true.
|
|
// apikey: Store-Eintrag (nativer API-Call) oder Fallback-Datei.
|
|
let has_credentials = match pool.credential_type.as_str() {
|
|
"apikey" => {
|
|
store.has(&id)?
|
|
|| fs::read_to_string(entry.path().join(APIKEY_FILE))
|
|
.map(|s| !s.trim().is_empty())
|
|
.unwrap_or(false)
|
|
}
|
|
_ => true,
|
|
};
|
|
let projects = projects_using_pool(paths, &id)?;
|
|
let running = projects.iter().filter(|p| is_running(p)).cloned().collect();
|
|
pools.push(PoolInfo {
|
|
id,
|
|
projects,
|
|
running,
|
|
name: pool.name,
|
|
credential_type: pool.credential_type,
|
|
has_credentials,
|
|
});
|
|
}
|
|
pools.sort_by(|a, b| a.name.cmp(&b.name));
|
|
Ok(pools)
|
|
}
|
|
|
|
/// (ID, Anzeigename) aller Pools. Beim ersten Pool existiert pools/ noch
|
|
/// nicht — dann ist die Liste leer.
|
|
pub(crate) fn pool_names(paths: &Paths) -> Result<Vec<(String, String)>, String> {
|
|
let mut out = Vec::new();
|
|
if !paths.pools_dir().is_dir() {
|
|
return Ok(out);
|
|
}
|
|
for entry in
|
|
fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?
|
|
{
|
|
let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
|
|
let cfg_path = entry.path().join(POOL_FILE);
|
|
if !cfg_path.is_file() {
|
|
continue;
|
|
}
|
|
let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
|
let pool: Pool =
|
|
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
|
out.push((entry.file_name().to_string_lossy().into_owned(), pool.name));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Prüft den Anzeigenamen (gültig + noch nicht vergeben) und liefert den
|
|
/// Ordner für einen neuen Pool: pools/<UUID v4>.
|
|
fn check_new_pool(paths: &Paths, name: &str) -> Result<PathBuf, String> {
|
|
check_name(name)?;
|
|
if pool_names(paths)?.iter().any(|(_, n)| n == name) {
|
|
return Err(format!("Pool existiert bereits: {name}"));
|
|
}
|
|
Ok(paths.pool_dir(&uuid::Uuid::new_v4().to_string()))
|
|
}
|
|
|
|
fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<(), String> {
|
|
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
|
let pool = Pool {
|
|
name: name.to_string(),
|
|
credential_type: credential_type.to_string(),
|
|
};
|
|
let raw = serde_json::to_string_pretty(&pool).map_err(|e| e.to_string())?;
|
|
fs::write(dir.join(POOL_FILE), raw + "\n")
|
|
.map_err(|e| format!("{}: {e}", dir.join(POOL_FILE).display()))
|
|
}
|
|
|
|
/// Grundausstattung eines Pool-Ordners (= CLAUDE_CONFIG_DIR): settings.json
|
|
/// (aufgeräumte UI-Defaults + `extra`) und eine CLAUDE.md, die claude als
|
|
/// User-Scope liest. CLAUDE.md wird nur angelegt, wenn sie fehlt.
|
|
/// Die Prompt-Vorschläge/Rückkehr-Zusammenfassung werden abgeschaltet — sonst
|
|
/// erscheint oben die Vorschlagstabelle.
|
|
pub(crate) fn init_pool_config(
|
|
dir: &PathBuf,
|
|
extra: serde_json::Value,
|
|
) -> Result<(), String> {
|
|
let mut settings = serde_json::json!({
|
|
"promptSuggestionEnabled": false,
|
|
"awaySummaryEnabled": false,
|
|
"permissions": { "allow": PANEL_PERMISSIONS },
|
|
});
|
|
let base = settings.as_object_mut().unwrap();
|
|
if let Some(obj) = extra.as_object() {
|
|
for (k, v) in obj {
|
|
base.insert(k.clone(), v.clone());
|
|
}
|
|
}
|
|
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
|
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
|
|
fs::write(dir.join("settings.json"), raw + "\n")
|
|
.map_err(|e| format!("{}: {e}", dir.join("settings.json").display()))?;
|
|
let claude_md = dir.join("CLAUDE.md");
|
|
if !claude_md.is_file() {
|
|
fs::write(&claude_md, "").map_err(|e| format!("{}: {e}", claude_md.display()))?;
|
|
}
|
|
install_panel_skill(dir);
|
|
register_mcp_server(dir);
|
|
Ok(())
|
|
}
|
|
|
|
/// Skill, der das MCP-Tool `write_panel` für Entwürfe anweist. Liegt im
|
|
/// Pool-Ordner (= CLAUDE_CONFIG_DIR), damit claude ihn als User-Skill findet.
|
|
const PANEL_SKILL: &str = include_str!("../../resources/panel-skill.md");
|
|
|
|
/// MCP-Server-Key — zugleich das Label, das claude im Tool-Call anzeigt
|
|
/// („text panel"). Bestimmt den Tool-Namespace `mcp__<key>__<tool>`.
|
|
const PANEL_MCP_SERVER: &str = "text-panel";
|
|
|
|
/// Freigaben für die Panel-MCP-Tools — damit die Aufrufe ohne Rückfrage
|
|
/// laufen. Namensschema: `mcp__<server>__<tool>`.
|
|
const PANEL_PERMISSIONS: [&str; 2] = [
|
|
"mcp__text-panel__write_panel",
|
|
"mcp__text-panel__write_commands",
|
|
];
|
|
|
|
/// Schreibt/aktualisiert die Panel-Skill-Datei in einem Pool (überschreibt eine
|
|
/// evtl. ältere, tee-basierte Fassung).
|
|
fn install_panel_skill(pool_dir: &std::path::Path) {
|
|
let skill_dir = pool_dir.join("skills").join("panel");
|
|
if fs::create_dir_all(&skill_dir).is_ok() {
|
|
let _ = fs::write(skill_dir.join("SKILL.md"), PANEL_SKILL);
|
|
}
|
|
}
|
|
|
|
/// Frühere Panel-Freigaben (Bash- und alter MCP-Key), die aus den Pool-Settings
|
|
/// entfernt werden.
|
|
const STALE_PANEL_PERMISSIONS: [&str; 3] =
|
|
["Bash(tee:*)", "Bash(cat:*)", "mcp__aicontrol__write_panel"];
|
|
|
|
/// Trägt die MCP-Freigabe in die settings.json eines Pools ein und entfernt
|
|
/// die alten Bash-Freigaben — idempotent, ohne sonstige Einträge zu verändern.
|
|
fn ensure_panel_permission(pool_dir: &std::path::Path) {
|
|
let sp = pool_dir.join("settings.json");
|
|
let Ok(raw) = fs::read_to_string(&sp) else {
|
|
return;
|
|
};
|
|
let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&raw) else {
|
|
return;
|
|
};
|
|
let Some(obj) = v.as_object_mut() else { return };
|
|
let Some(perms) = obj
|
|
.entry("permissions")
|
|
.or_insert_with(|| serde_json::json!({}))
|
|
.as_object_mut()
|
|
else {
|
|
return;
|
|
};
|
|
let Some(allow) = perms
|
|
.entry("allow")
|
|
.or_insert_with(|| serde_json::json!([]))
|
|
.as_array_mut()
|
|
else {
|
|
return;
|
|
};
|
|
let before = allow.clone();
|
|
allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s)));
|
|
for perm in PANEL_PERMISSIONS {
|
|
if !allow.iter().any(|e| e.as_str() == Some(perm)) {
|
|
allow.push(serde_json::json!(perm));
|
|
}
|
|
}
|
|
if *allow == before {
|
|
return; // nichts geändert
|
|
}
|
|
if let Ok(out) = serde_json::to_string_pretty(&v) {
|
|
let _ = fs::write(&sp, out + "\n");
|
|
}
|
|
}
|
|
|
|
/// Registriert den MCP-Server (dieses Binary mit `--mcp-panel`) in der
|
|
/// `.claude.json` des Pools (= CLAUDE_CONFIG_DIR, User-Scope für alle
|
|
/// Projekte des Pools). Vorhandene .claude.json wird gemergt, nicht ersetzt.
|
|
fn register_mcp_server(pool_dir: &std::path::Path) {
|
|
let Ok(exe) = std::env::current_exe() else {
|
|
return;
|
|
};
|
|
// Tool nicht deferren, sonst sieht das Modell write_panel nicht und schreibt
|
|
// den Entwurf in den Chat statt ins Panel.
|
|
let desired = serde_json::json!({
|
|
"type": "stdio",
|
|
"command": exe.to_string_lossy(),
|
|
"args": ["--mcp-panel"],
|
|
"alwaysLoad": true,
|
|
});
|
|
let cfg = pool_dir.join(".claude.json");
|
|
// Nur eine fehlende Datei rechtfertigt ein frisches Objekt. Ist sie da, aber
|
|
// unlesbar oder kaputtes JSON (abgebrochener Schreibvorgang, volle Platte),
|
|
// wird sie in Ruhe gelassen: Sie ist claudes Live-State mit Projekt-Zustand
|
|
// und MCP-Einträgen Dritter. Sie hier zu ersetzen macht aus einem
|
|
// reparierbaren Schaden einen endgültigen.
|
|
let mut v = match fs::read_to_string(&cfg) {
|
|
Ok(s) => {
|
|
let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) else { return };
|
|
v
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
|
|
Err(_) => return,
|
|
};
|
|
let Some(obj) = v.as_object_mut() else { return };
|
|
let Some(servers) = obj
|
|
.entry("mcpServers")
|
|
.or_insert_with(|| serde_json::json!({}))
|
|
.as_object_mut()
|
|
else {
|
|
return;
|
|
};
|
|
// Schon korrekt und kein Altkey -> nichts schreiben. `.claude.json` ist
|
|
// claudes Live-State; wir fassen sie nur an, wenn der Eintrag fehlt/abweicht.
|
|
if servers.get("aicontrol").is_none() && servers.get(PANEL_MCP_SERVER) == Some(&desired) {
|
|
return;
|
|
}
|
|
servers.remove("aicontrol"); // alter Key vor Umbenennung
|
|
servers.insert(PANEL_MCP_SERVER.into(), desired);
|
|
if let Ok(out) = serde_json::to_string_pretty(&v) {
|
|
let _ = fs::write(&cfg, out + "\n");
|
|
}
|
|
}
|
|
|
|
/// Panel-Skill, MCP-Server-Registrierung und Tool-Freigabe in einen Pool
|
|
/// bringen. install_panel_skill überschreibt eine evtl. alte tee-Fassung.
|
|
fn provision_pool(pool_dir: &std::path::Path) {
|
|
install_panel_skill(pool_dir);
|
|
register_mcp_server(pool_dir);
|
|
ensure_panel_permission(pool_dir);
|
|
}
|
|
|
|
/// Panel-MCP in alle vorhandenen Pools bringen (Migration beim App-Start).
|
|
pub(crate) fn provision_pools_for_panel(paths: &Paths) {
|
|
if let Ok(pools) = pool_names(paths) {
|
|
for (id, _) in pools {
|
|
provision_pool(&paths.pool_dir(&id));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Runtime, die pro Pool ins synced Repo gelinkt wird: Transkripte, Todos,
|
|
/// Prompt-Historie. (name, ist_ordner)
|
|
pub(crate) const SYNCED_RUNTIME: [(&str, bool); 3] =
|
|
[("projects", true), ("todos", true), ("history.jsonl", false)];
|
|
|
|
/// Zielort der synced Runtime-Daten eines Pools unterhalb von poolSyncDir.
|
|
pub(crate) fn pool_data_dir(paths: &Paths, pool: &str) -> Result<PathBuf, String> {
|
|
pool_sync_dir(paths)
|
|
.map(|d| d.join(pool))
|
|
.ok_or_else(|| "poolSyncDir ist nicht konfiguriert".to_string())
|
|
}
|
|
|
|
/// Ersetzt im Pool-Ordner projects/todos/history.jsonl durch Symlinks auf den
|
|
/// konfigurierten Sync-Ordner. Die Symlinks sind maschinenlokal, die Daten
|
|
/// reisen über den Sync des Zielordners (z. B. git).
|
|
/// Vorhandene echte Inhalte werden verworfen (kein History-Erhalt — bewusst).
|
|
pub(crate) fn link_pool_runtime_in(paths: &Paths, pool: &str) -> Result<(), String> {
|
|
check_name(pool)?;
|
|
let src = paths.pool_dir(pool);
|
|
let data = pool_data_dir(paths, pool)?;
|
|
for (name, is_dir) in SYNCED_RUNTIME {
|
|
let target = data.join(name);
|
|
if is_dir {
|
|
fs::create_dir_all(&target).map_err(|e| format!("{}: {e}", target.display()))?;
|
|
} else {
|
|
fs::create_dir_all(&data).map_err(|e| format!("{}: {e}", data.display()))?;
|
|
if !target.exists() {
|
|
fs::write(&target, "").map_err(|e| format!("{}: {e}", target.display()))?;
|
|
}
|
|
}
|
|
let link = src.join(name);
|
|
if link.is_dir() && !link.is_symlink() {
|
|
fs::remove_dir_all(&link).map_err(|e| format!("{}: {e}", link.display()))?;
|
|
} else if link.is_symlink() || link.exists() {
|
|
fs::remove_file(&link).map_err(|e| format!("{}: {e}", link.display()))?;
|
|
}
|
|
crate::platform::symlink(&target, &link)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Legt einen apikey-Pool an: Key in den Keychain/Keyring (Datei 0600 nur mit
|
|
/// allow_file), settings.json mit apiKeyHelper-Kette, CLAUDE.md, pool.json.
|
|
/// Ohne Store und ohne allow_file bricht die Anlage ab, bevor etwas entsteht.
|
|
/// Liefert die Pool-ID.
|
|
pub(crate) fn create_apikey_pool_in(
|
|
paths: &Paths,
|
|
store: &dyn ApikeyStore,
|
|
name: &str,
|
|
key: &str,
|
|
allow_file: bool,
|
|
) -> Result<String, String> {
|
|
let dir = check_new_pool(paths, name)?;
|
|
let key = key.trim();
|
|
if key.is_empty() {
|
|
return Err("leerer API-Key".into());
|
|
}
|
|
let id = dir.file_name().unwrap().to_string_lossy().into_owned();
|
|
if store.set(&id, key).is_err() {
|
|
if !allow_file {
|
|
return Err(KEYCHAIN_UNAVAILABLE.into());
|
|
}
|
|
crate::platform::write_secret_file(&dir.join(APIKEY_FILE), &format!("{key}\n"))?;
|
|
}
|
|
init_pool_config(
|
|
&dir,
|
|
serde_json::json!({ "apiKeyHelper": crate::platform::apikey_helper_command(&dir, &id) }),
|
|
)?;
|
|
write_pool_json(&dir, name, "apikey")?;
|
|
if pool_sync_dir(paths).is_some() {
|
|
link_pool_runtime_in(paths, &id)?;
|
|
}
|
|
Ok(id)
|
|
}
|
|
|
|
/// Legt einen oauth-Pool an: Grundausstattung (leere settings.json + CLAUDE.md)
|
|
/// + pool.json. Die Anmeldung macht claude selbst beim ersten Start des Pools
|
|
/// (`/login`) und legt den Keychain-Eintrag an — die App speichert keine Tokens.
|
|
/// Liefert die Pool-ID.
|
|
pub(crate) fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<String, String> {
|
|
let dir = check_new_pool(paths, name)?;
|
|
init_pool_config(&dir, serde_json::json!({}))?;
|
|
write_pool_json(&dir, name, "oauth")?;
|
|
let id = dir.file_name().unwrap().to_string_lossy().into_owned();
|
|
if pool_sync_dir(paths).is_some() {
|
|
link_pool_runtime_in(paths, &id)?;
|
|
}
|
|
Ok(id)
|
|
}
|
|
|
|
/// Setzt den Anzeigenamen eines Pools — reines pool.json-Update, ID/Ordner
|
|
/// (und damit Keychain-Suffix, Symlinks, Zuordnungen) bleiben unverändert.
|
|
pub(crate) fn rename_pool_in(paths: &Paths, pool: &str, name: &str) -> Result<(), String> {
|
|
check_name(name)?;
|
|
let current = read_pool(paths, pool)?;
|
|
if pool_names(paths)?.iter().any(|(id, n)| id != pool && n == name) {
|
|
return Err(format!("Pool existiert bereits: {name}"));
|
|
}
|
|
write_pool_json(&paths.pool_dir(pool), name, ¤t.credential_type)
|
|
}
|
|
|
|
/// Löscht einen Pool samt Ordner (inkl. Credentials, bei apikey auch den
|
|
/// Keychain-Eintrag). Zugeordnete Projekte verlieren die Zuordnung
|
|
/// (Terminal-Einstellungen bleiben erhalten). Den Schutz gegen laufende
|
|
/// Sessions setzt der delete_pool-Command davor.
|
|
pub(crate) fn delete_pool_in(
|
|
paths: &Paths,
|
|
store: &dyn ApikeyStore,
|
|
name: &str,
|
|
) -> Result<(), String> {
|
|
let dir = paths.pool_dir(name);
|
|
if !dir.join(POOL_FILE).is_file() {
|
|
return Err(format!("Pool nicht gefunden: {name}"));
|
|
}
|
|
if read_pool(paths, name)?.credential_type == "apikey" {
|
|
store.delete(name)?;
|
|
}
|
|
for project in projects_using_pool(paths, name)? {
|
|
unassign_pool_in(paths, &project)?;
|
|
}
|
|
fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))
|
|
}
|
|
|
|
/// Schreibt den API-Key eines apikey-Pools neu: in den Keychain/Keyring, die
|
|
/// Fallback-Datei wird dabei entfernt (migriert Datei-Pools beim Key-Ändern).
|
|
/// Ohne verfügbaren Store: mit allow_file in die Datei (0600), sonst Abbruch
|
|
/// ohne Änderung. Der apiKeyHelper wird auf die aktuelle Kette gehoben.
|
|
pub(crate) fn set_apikey_in(
|
|
paths: &Paths,
|
|
store: &dyn ApikeyStore,
|
|
pool: &str,
|
|
key: &str,
|
|
allow_file: bool,
|
|
) -> Result<(), String> {
|
|
let p = read_pool(paths, pool)?;
|
|
if p.credential_type != "apikey" {
|
|
return Err(format!("Pool {pool} ist kein apikey-Pool"));
|
|
}
|
|
let key = key.trim();
|
|
if key.is_empty() {
|
|
return Err("leerer API-Key".into());
|
|
}
|
|
let dir = paths.pool_dir(pool);
|
|
let key_path = dir.join(APIKEY_FILE);
|
|
if store.set(pool, key).is_ok() {
|
|
if key_path.is_file() {
|
|
fs::remove_file(&key_path).map_err(|e| format!("{}: {e}", key_path.display()))?;
|
|
}
|
|
} else {
|
|
if !allow_file {
|
|
return Err(KEYCHAIN_UNAVAILABLE.into());
|
|
}
|
|
crate::platform::write_secret_file(&key_path, &format!("{key}\n"))?;
|
|
}
|
|
let settings_path = dir.join("settings.json");
|
|
let raw = fs::read_to_string(&settings_path)
|
|
.map_err(|e| format!("{}: {e}", settings_path.display()))?;
|
|
let mut settings: serde_json::Value = serde_json::from_str(&raw)
|
|
.map_err(|e| format!("{}: {e}", settings_path.display()))?;
|
|
settings["apiKeyHelper"] =
|
|
serde_json::json!(crate::platform::apikey_helper_command(&dir, pool));
|
|
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
|
|
fs::write(&settings_path, raw + "\n").map_err(|e| format!("{}: {e}", settings_path.display()))
|
|
}
|
|
|
|
pub(crate) fn ensure_oauth_pool(paths: &Paths, pool: &str) -> Result<(), String> {
|
|
let p = read_pool(paths, pool)?;
|
|
if p.credential_type != "oauth" {
|
|
return Err(format!("Pool {pool} ist kein oauth-Pool"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::domain::project::{
|
|
assign_pool_in, read_project_config_in, set_terminal_config_in, TerminalConfig,
|
|
};
|
|
use crate::domain::settings::APP_SETTINGS_FILE;
|
|
use crate::domain::testutil::{
|
|
create_project, make_apikey_pool, make_oauth_pool, map_store, mode, tmp_paths, FailStore,
|
|
};
|
|
|
|
#[test]
|
|
fn apikey_pool_anlegen() {
|
|
let p = tmp_paths();
|
|
let store = map_store();
|
|
let id = make_apikey_pool(&p, &store, "kunde", "sk-test-123");
|
|
// Ordnername ist eine UUID, der Anzeigename steht nur in pool.json.
|
|
assert!(uuid::Uuid::parse_str(&id).is_ok());
|
|
let dir = p.pool_dir(&id);
|
|
|
|
let pool: Pool =
|
|
serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap();
|
|
assert_eq!(pool.name, "kunde");
|
|
assert_eq!(pool.credential_type, "apikey");
|
|
|
|
// Key liegt im Store, keine Datei im Pool-Ordner.
|
|
assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-test-123");
|
|
assert!(!dir.join(APIKEY_FILE).exists());
|
|
|
|
let settings: serde_json::Value =
|
|
serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap())
|
|
.unwrap();
|
|
assert_eq!(
|
|
settings["apiKeyHelper"].as_str().unwrap(),
|
|
crate::platform::apikey_helper_command(&dir, &id)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn apikey_pool_anlegen_ohne_store_faellt_auf_datei_zurueck() {
|
|
let p = tmp_paths();
|
|
let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-test-123");
|
|
let dir = p.pool_dir(&id);
|
|
|
|
let key_path = dir.join(APIKEY_FILE);
|
|
assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-test-123\n");
|
|
assert_eq!(mode(&key_path), 0o600);
|
|
|
|
// Der Helper ist dieselbe Kette — der Store-Teil findet nichts,
|
|
// cat liefert die Datei.
|
|
let settings: serde_json::Value =
|
|
serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap())
|
|
.unwrap();
|
|
assert_eq!(
|
|
settings["apiKeyHelper"].as_str().unwrap(),
|
|
crate::platform::apikey_helper_command(&dir, &id)
|
|
);
|
|
}
|
|
|
|
/// Ohne Store und ohne allow_file bricht die Anlage ab — es entsteht nichts.
|
|
#[test]
|
|
fn apikey_anlegen_abbruch_ohne_store() {
|
|
let p = tmp_paths();
|
|
let err = create_apikey_pool_in(&p, &FailStore, "kunde", "sk-1", false).unwrap_err();
|
|
assert_eq!(err, KEYCHAIN_UNAVAILABLE);
|
|
assert!(pool_names(&p).unwrap().is_empty());
|
|
assert!(!p.pools_dir().exists());
|
|
}
|
|
|
|
/// Key-Ändern ohne Store und ohne allow_file lässt alles unangetastet.
|
|
#[test]
|
|
fn apikey_aendern_abbruch_ohne_store() {
|
|
let p = tmp_paths();
|
|
let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt");
|
|
let dir = p.pool_dir(&id);
|
|
let settings_before = fs::read_to_string(dir.join("settings.json")).unwrap();
|
|
|
|
let err = set_apikey_in(&p, &FailStore, &id, "sk-neu", false).unwrap_err();
|
|
assert_eq!(err, KEYCHAIN_UNAVAILABLE);
|
|
assert_eq!(fs::read_to_string(dir.join(APIKEY_FILE)).unwrap(), "sk-alt\n");
|
|
assert_eq!(fs::read_to_string(dir.join("settings.json")).unwrap(), settings_before);
|
|
}
|
|
|
|
#[test]
|
|
fn oauth_pool_anlegen() {
|
|
let p = tmp_paths();
|
|
let id = make_oauth_pool(&p, "privat");
|
|
let dir = p.pool_dir(&id);
|
|
|
|
let pool: Pool =
|
|
serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap();
|
|
assert_eq!(pool.credential_type, "oauth");
|
|
|
|
// Kein Credentials-File — den Login macht claude selbst beim ersten Start.
|
|
assert!(!dir.join(".credentials.json").exists());
|
|
// Grundausstattung: settings.json mit aufgeräumten UI-Defaults + CLAUDE.md,
|
|
// damit claude nicht ins Onboarding fällt und keine Vorschlagstabelle zeigt.
|
|
let settings: serde_json::Value =
|
|
serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap())
|
|
.unwrap();
|
|
assert_eq!(settings["promptSuggestionEnabled"], serde_json::json!(false));
|
|
assert_eq!(settings["awaySummaryEnabled"], serde_json::json!(false));
|
|
assert!(dir.join("CLAUDE.md").is_file());
|
|
}
|
|
|
|
#[test]
|
|
fn pool_init_erhaelt_bestehende_claude_md() {
|
|
let p = tmp_paths();
|
|
let dir = p.pool_dir("privat");
|
|
fs::create_dir_all(&dir).unwrap();
|
|
fs::write(dir.join("CLAUDE.md"), "meine Vorgaben\n").unwrap();
|
|
// Anlegen darf die vorhandene CLAUDE.md nicht überschreiben.
|
|
// (check_new_pool erlaubt keinen bestehenden Ordner → direkt init testen)
|
|
init_pool_config(&dir, serde_json::json!({})).unwrap();
|
|
assert_eq!(
|
|
fs::read_to_string(dir.join("CLAUDE.md")).unwrap(),
|
|
"meine Vorgaben\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_doppelt_anlegen_scheitert() {
|
|
let p = tmp_paths();
|
|
let store = map_store();
|
|
make_apikey_pool(&p, &store, "kunde", "sk-1");
|
|
let err = create_apikey_pool_in(&p, &store, "kunde", "sk-2", true).unwrap_err();
|
|
assert!(err.contains("existiert bereits"));
|
|
}
|
|
|
|
// -- Pool umbenennen --
|
|
|
|
#[test]
|
|
fn pool_umbenennen() {
|
|
let p = tmp_paths();
|
|
let id = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
|
create_project(&p, "proj").unwrap();
|
|
assign_pool_in(&p, "proj", &id).unwrap();
|
|
|
|
rename_pool_in(&p, &id, "kunde-neu").unwrap();
|
|
|
|
// Nur der Anzeigename ändert sich — Ordner, Typ und Zuordnung bleiben.
|
|
let pool = read_pool(&p, &id).unwrap();
|
|
assert_eq!(pool.name, "kunde-neu");
|
|
assert_eq!(pool.credential_type, "apikey");
|
|
assert!(p.pool_dir(&id).is_dir());
|
|
let cfg = read_project_config_in(&p, "proj").unwrap();
|
|
assert_eq!(cfg.pool.as_deref(), Some(id.as_str()));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_umbenennen_auf_vergebenen_namen_scheitert() {
|
|
let p = tmp_paths();
|
|
let id = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
|
make_oauth_pool(&p, "privat");
|
|
let err = rename_pool_in(&p, &id, "privat").unwrap_err();
|
|
assert!(err.contains("existiert bereits"));
|
|
// Umbenennen auf den eigenen Namen ist erlaubt (No-op).
|
|
rename_pool_in(&p, &id, "kunde").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn pool_leerer_key_scheitert() {
|
|
let p = tmp_paths();
|
|
assert!(create_apikey_pool_in(&p, &map_store(), "kunde", " ", true).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn pool_name_mit_slash_scheitert() {
|
|
let p = tmp_paths();
|
|
assert!(create_apikey_pool_in(&p, &map_store(), "a/b", "sk-1", true).is_err());
|
|
}
|
|
|
|
// -- Pool ändern --
|
|
|
|
#[test]
|
|
fn apikey_aendern() {
|
|
let p = tmp_paths();
|
|
let store = map_store();
|
|
let id = make_apikey_pool(&p, &store, "kunde", "sk-alt");
|
|
set_apikey_in(&p, &store, &id, "sk-neu", false).unwrap();
|
|
assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-neu");
|
|
assert!(!p.pool_dir(&id).join(APIKEY_FILE).exists());
|
|
}
|
|
|
|
/// Datei-Pool (Anlage ohne Store, z. B. Bestand vor der Keychain-Ablage):
|
|
/// Key-Ändern hebt ihn in den Store, Datei und alter cat-Helper verschwinden.
|
|
#[test]
|
|
fn apikey_aendern_migriert_datei_in_store() {
|
|
let p = tmp_paths();
|
|
let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt");
|
|
let dir = p.pool_dir(&id);
|
|
// Bestand simulieren: Helper wie vor der Keychain-Ablage.
|
|
let old = serde_json::json!({ "apiKeyHelper": format!("cat '{}'", dir.join(APIKEY_FILE).display()) });
|
|
fs::write(dir.join("settings.json"), old.to_string()).unwrap();
|
|
|
|
let store = map_store();
|
|
set_apikey_in(&p, &store, &id, "sk-neu", false).unwrap();
|
|
|
|
assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-neu");
|
|
assert!(!dir.join(APIKEY_FILE).exists());
|
|
let settings: serde_json::Value =
|
|
serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap())
|
|
.unwrap();
|
|
assert_eq!(
|
|
settings["apiKeyHelper"].as_str().unwrap(),
|
|
crate::platform::apikey_helper_command(&dir, &id)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn apikey_aendern_ohne_store_schreibt_datei() {
|
|
let p = tmp_paths();
|
|
let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt");
|
|
set_apikey_in(&p, &FailStore, &id, "sk-neu", true).unwrap();
|
|
let key_path = p.pool_dir(&id).join(APIKEY_FILE);
|
|
assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-neu\n");
|
|
assert_eq!(mode(&key_path), 0o600);
|
|
}
|
|
|
|
#[test]
|
|
fn apikey_aendern_auf_oauth_pool_scheitert() {
|
|
let p = tmp_paths();
|
|
let id = make_oauth_pool(&p, "privat");
|
|
assert!(set_apikey_in(&p, &map_store(), &id, "sk-1", true).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn ensure_oauth_pool_auf_apikey_scheitert() {
|
|
let p = tmp_paths();
|
|
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
|
assert!(ensure_oauth_pool(&p, &kunde).is_err());
|
|
let privat = make_oauth_pool(&p, "privat");
|
|
assert!(ensure_oauth_pool(&p, &privat).is_ok());
|
|
}
|
|
|
|
// -- Pool löschen --
|
|
|
|
#[test]
|
|
fn pool_loeschen() {
|
|
let p = tmp_paths();
|
|
let store = map_store();
|
|
let id = make_apikey_pool(&p, &store, "kunde", "sk-1");
|
|
delete_pool_in(&p, &store, &id).unwrap();
|
|
assert!(!p.pool_dir(&id).exists());
|
|
// Der Keychain-Eintrag geht mit.
|
|
assert!(!store.has(&id).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn pool_key_status() {
|
|
let p = tmp_paths();
|
|
// Datei-Pool (ohne Store angelegt): Datei entscheidet.
|
|
let kunde = make_apikey_pool(&p, &FailStore, "kunde", "sk-1");
|
|
make_oauth_pool(&p, "privat");
|
|
|
|
let pools = list_pools_in(&p, &FailStore).unwrap();
|
|
assert!(pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
|
|
// oauth: Keychain wird im Listing bewusst nicht geprüft → immer true.
|
|
assert!(pools.iter().find(|x| x.name == "privat").unwrap().has_credentials);
|
|
|
|
fs::write(p.pool_dir(&kunde).join(APIKEY_FILE), "\n").unwrap();
|
|
let pools = list_pools_in(&p, &FailStore).unwrap();
|
|
assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
|
|
|
|
fs::remove_file(p.pool_dir(&kunde).join(APIKEY_FILE)).unwrap();
|
|
let pools = list_pools_in(&p, &FailStore).unwrap();
|
|
assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
|
|
}
|
|
|
|
/// Store-Pool: hasCredentials kommt aus dem Keychain-Eintrag, ohne Datei.
|
|
#[test]
|
|
fn pool_key_status_aus_store() {
|
|
let p = tmp_paths();
|
|
let store = map_store();
|
|
let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1");
|
|
assert!(!p.pool_dir(&kunde).join(APIKEY_FILE).exists());
|
|
let pools = list_pools_in(&p, &store).unwrap();
|
|
assert!(pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
|
|
store.delete(&kunde).unwrap();
|
|
let pools = list_pools_in(&p, &store).unwrap();
|
|
assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_loeschen_loest_zuordnungen() {
|
|
let p = tmp_paths();
|
|
let store = map_store();
|
|
let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1");
|
|
create_project(&p, "proj").unwrap();
|
|
assign_pool_in(&p, "proj", &kunde).unwrap();
|
|
delete_pool_in(&p, &store, &kunde).unwrap();
|
|
assert!(!p.pool_dir(&kunde).exists());
|
|
assert!(!crate::domain::project::project_config_path(&p, "proj").unwrap().exists());
|
|
}
|
|
|
|
#[test]
|
|
fn pool_anlegen_verlinkt_synced_runtime() {
|
|
let p = tmp_paths();
|
|
fs::create_dir_all(p.config_dir()).unwrap();
|
|
fs::write(
|
|
p.config_dir().join(APP_SETTINGS_FILE),
|
|
"{ \"poolSyncDir\": \"~/claude-projects/pool\" }\n",
|
|
)
|
|
.unwrap();
|
|
let id = make_oauth_pool(&p, "privat");
|
|
let pooldir = p.pool_dir(&id);
|
|
for (name, is_dir) in SYNCED_RUNTIME {
|
|
let link = pooldir.join(name);
|
|
assert!(link.is_symlink(), "{name} sollte Symlink sein");
|
|
assert_eq!(fs::read_link(&link).unwrap(), pool_data_dir(&p, &id).unwrap().join(name));
|
|
let target = pool_data_dir(&p, &id).unwrap().join(name);
|
|
assert_eq!(target.is_dir(), is_dir);
|
|
assert!(target.exists());
|
|
}
|
|
// Zieldaten liegen unter dem konfigurierten poolSyncDir/<id>/
|
|
assert!(p.home.join("claude-projects/pool").join(&id).is_dir());
|
|
}
|
|
|
|
/// Ohne poolSyncDir bleibt alles lokal: keine Symlinks, Verlinken scheitert laut.
|
|
#[test]
|
|
fn pool_anlegen_ohne_sync_dir_bleibt_lokal() {
|
|
let p = tmp_paths();
|
|
let id = make_oauth_pool(&p, "privat");
|
|
for (name, _) in SYNCED_RUNTIME {
|
|
assert!(!p.pool_dir(&id).join(name).is_symlink(), "{name} darf kein Symlink sein");
|
|
}
|
|
let err = link_pool_runtime_in(&p, &id).unwrap_err();
|
|
assert!(err.contains("poolSyncDir"));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_loeschen_erhaelt_terminal_config() {
|
|
let p = tmp_paths();
|
|
let store = map_store();
|
|
let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1");
|
|
create_project(&p, "proj").unwrap();
|
|
assign_pool_in(&p, "proj", &kunde).unwrap();
|
|
set_terminal_config_in(
|
|
&p,
|
|
"proj",
|
|
TerminalConfig { theme: Some("dracula".into()), ..Default::default() },
|
|
)
|
|
.unwrap();
|
|
delete_pool_in(&p, &store, &kunde).unwrap();
|
|
let cfg = read_project_config_in(&p, "proj").unwrap();
|
|
assert_eq!(cfg.pool, None);
|
|
assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula"));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_loeschen_unbekannt_scheitert() {
|
|
let p = tmp_paths();
|
|
assert!(delete_pool_in(&p, &map_store(), "gibtsnicht").is_err());
|
|
}
|
|
}
|