Introduce a centralized token resolution system that supports both
environment variables and config-file-stored tokens with clear priority
(env var wins). This enables cron-based sync which runs in minimal
shell environments without env vars.
Core changes:
- GitLabConfig gains optional `token` field and `resolve_token()` method
that checks env var first, then config file, returning trimmed values
- `token_source()` returns human-readable provenance ("environment variable"
or "config file") for diagnostics
- `ensure_config_permissions()` enforces 0600 on config files containing
tokens (Unix only, no-op on other platforms)
New CLI commands:
- `lore token set [--token VALUE]` — validates against GitLab API, stores
in config, enforces file permissions. Supports flag, stdin pipe, or
interactive entry.
- `lore token show [--unmask]` — displays masked token with source label
Consumers updated to use resolve_token():
- auth_test: removes manual env var lookup
- doctor: shows token source in health check output
- ingest: uses centralized resolution
Includes 10 unit tests for resolve/source logic and 2 for mask_token.
602 lines
19 KiB
Rust
602 lines
19 KiB
Rust
use crate::cli::render::{Icons, Theme};
|
|
use serde::Serialize;
|
|
|
|
use crate::core::config::Config;
|
|
use crate::core::db::{create_connection, get_schema_version, verify_pragmas};
|
|
use crate::core::error::LoreError;
|
|
use crate::core::paths::{get_config_path, get_db_path, get_log_dir};
|
|
use crate::gitlab::GitLabClient;
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct CheckResult {
|
|
pub status: CheckStatus,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub message: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum CheckStatus {
|
|
Ok,
|
|
Warning,
|
|
Error,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct DoctorResult {
|
|
pub success: bool,
|
|
pub checks: DoctorChecks,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct DoctorChecks {
|
|
pub config: ConfigCheck,
|
|
pub database: DatabaseCheck,
|
|
pub gitlab: GitLabCheck,
|
|
pub projects: ProjectsCheck,
|
|
pub ollama: OllamaCheck,
|
|
pub logging: LoggingCheck,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ConfigCheck {
|
|
#[serde(flatten)]
|
|
pub result: CheckResult,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub path: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct DatabaseCheck {
|
|
#[serde(flatten)]
|
|
pub result: CheckResult,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub path: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub schema_version: Option<i32>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct GitLabCheck {
|
|
#[serde(flatten)]
|
|
pub result: CheckResult,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub username: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ProjectsCheck {
|
|
#[serde(flatten)]
|
|
pub result: CheckResult,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub configured: Option<usize>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub resolved: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct OllamaCheck {
|
|
#[serde(flatten)]
|
|
pub result: CheckResult,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub model: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct LoggingCheck {
|
|
#[serde(flatten)]
|
|
pub result: CheckResult,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub log_dir: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub file_count: Option<usize>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub total_bytes: Option<u64>,
|
|
}
|
|
|
|
pub async fn run_doctor(config_path: Option<&str>) -> DoctorResult {
|
|
let config_path_buf = get_config_path(config_path);
|
|
let config_path_str = config_path_buf.display().to_string();
|
|
|
|
let (config_check, config) = check_config(&config_path_str);
|
|
|
|
let database_check = check_database(config.as_ref());
|
|
|
|
let gitlab_check = check_gitlab(config.as_ref()).await;
|
|
|
|
let projects_check = check_projects(config.as_ref());
|
|
|
|
let ollama_check = check_ollama(config.as_ref()).await;
|
|
|
|
let logging_check = check_logging(config.as_ref());
|
|
|
|
let success = config_check.result.status == CheckStatus::Ok
|
|
&& database_check.result.status == CheckStatus::Ok
|
|
&& gitlab_check.result.status == CheckStatus::Ok
|
|
&& projects_check.result.status == CheckStatus::Ok;
|
|
|
|
DoctorResult {
|
|
success,
|
|
checks: DoctorChecks {
|
|
config: config_check,
|
|
database: database_check,
|
|
gitlab: gitlab_check,
|
|
projects: projects_check,
|
|
ollama: ollama_check,
|
|
logging: logging_check,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn check_config(config_path: &str) -> (ConfigCheck, Option<Config>) {
|
|
match Config::load(Some(config_path)) {
|
|
Ok(config) => (
|
|
ConfigCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Ok,
|
|
message: Some(format!("Loaded from {config_path}")),
|
|
},
|
|
path: Some(config_path.to_string()),
|
|
},
|
|
Some(config),
|
|
),
|
|
Err(LoreError::ConfigNotFound { path }) => (
|
|
ConfigCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Error,
|
|
message: Some(format!("Config not found at {path}")),
|
|
},
|
|
path: Some(path),
|
|
},
|
|
None,
|
|
),
|
|
Err(e) => (
|
|
ConfigCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Error,
|
|
message: Some(e.to_string()),
|
|
},
|
|
path: Some(config_path.to_string()),
|
|
},
|
|
None,
|
|
),
|
|
}
|
|
}
|
|
|
|
fn check_database(config: Option<&Config>) -> DatabaseCheck {
|
|
let Some(config) = config else {
|
|
return DatabaseCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Error,
|
|
message: Some("Config not loaded".to_string()),
|
|
},
|
|
path: None,
|
|
schema_version: None,
|
|
};
|
|
};
|
|
|
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
|
|
|
if !db_path.exists() {
|
|
return DatabaseCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Error,
|
|
message: Some("Database file not found. Run \"lore init\" first.".to_string()),
|
|
},
|
|
path: Some(db_path.display().to_string()),
|
|
schema_version: None,
|
|
};
|
|
}
|
|
|
|
match create_connection(&db_path) {
|
|
Ok(conn) => {
|
|
let schema_version = get_schema_version(&conn);
|
|
let (pragmas_ok, issues) = verify_pragmas(&conn);
|
|
|
|
if !pragmas_ok {
|
|
return DatabaseCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Warning,
|
|
message: Some(format!("Pragma issues: {}", issues.join(", "))),
|
|
},
|
|
path: Some(db_path.display().to_string()),
|
|
schema_version: Some(schema_version),
|
|
};
|
|
}
|
|
|
|
DatabaseCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Ok,
|
|
message: Some(format!("{} (schema v{schema_version})", db_path.display())),
|
|
},
|
|
path: Some(db_path.display().to_string()),
|
|
schema_version: Some(schema_version),
|
|
}
|
|
}
|
|
Err(e) => DatabaseCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Error,
|
|
message: Some(e.to_string()),
|
|
},
|
|
path: Some(db_path.display().to_string()),
|
|
schema_version: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
async fn check_gitlab(config: Option<&Config>) -> GitLabCheck {
|
|
let Some(config) = config else {
|
|
return GitLabCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Error,
|
|
message: Some("Config not loaded".to_string()),
|
|
},
|
|
url: None,
|
|
username: None,
|
|
};
|
|
};
|
|
|
|
let token = match config.gitlab.resolve_token() {
|
|
Ok(t) => t,
|
|
Err(_) => {
|
|
return GitLabCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Error,
|
|
message: Some(format!(
|
|
"Token not set. Run 'lore token set' or export {}.",
|
|
config.gitlab.token_env_var
|
|
)),
|
|
},
|
|
url: Some(config.gitlab.base_url.clone()),
|
|
username: None,
|
|
};
|
|
}
|
|
};
|
|
|
|
let source = config.gitlab.token_source().unwrap_or("unknown");
|
|
|
|
let client = GitLabClient::new(&config.gitlab.base_url, &token, None);
|
|
|
|
match client.get_current_user().await {
|
|
Ok(user) => GitLabCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Ok,
|
|
message: Some(format!(
|
|
"{} (authenticated as @{}, token from {source})",
|
|
config.gitlab.base_url, user.username
|
|
)),
|
|
},
|
|
url: Some(config.gitlab.base_url.clone()),
|
|
username: Some(user.username),
|
|
},
|
|
Err(LoreError::GitLabAuthFailed) => GitLabCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Error,
|
|
message: Some("Authentication failed. Check your token.".to_string()),
|
|
},
|
|
url: Some(config.gitlab.base_url.clone()),
|
|
username: None,
|
|
},
|
|
Err(e) => GitLabCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Error,
|
|
message: Some(e.to_string()),
|
|
},
|
|
url: Some(config.gitlab.base_url.clone()),
|
|
username: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn check_projects(config: Option<&Config>) -> ProjectsCheck {
|
|
let Some(config) = config else {
|
|
return ProjectsCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Error,
|
|
message: Some("Config not loaded".to_string()),
|
|
},
|
|
configured: None,
|
|
resolved: None,
|
|
};
|
|
};
|
|
|
|
let configured = config.projects.len();
|
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
|
|
|
if !db_path.exists() {
|
|
return ProjectsCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Error,
|
|
message: Some("Database not found. Run \"lore init\" first.".to_string()),
|
|
},
|
|
configured: Some(configured),
|
|
resolved: Some(0),
|
|
};
|
|
}
|
|
|
|
match create_connection(&db_path) {
|
|
Ok(conn) => {
|
|
let resolved: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM projects", [], |row| row.get(0))
|
|
.unwrap_or(0);
|
|
|
|
if resolved == 0 {
|
|
return ProjectsCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Error,
|
|
message: Some(format!(
|
|
"{configured} configured, 0 resolved. Run \"lore init\" to resolve projects."
|
|
)),
|
|
},
|
|
configured: Some(configured),
|
|
resolved: Some(resolved),
|
|
};
|
|
}
|
|
|
|
if resolved != configured as i64 {
|
|
return ProjectsCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Warning,
|
|
message: Some(format!(
|
|
"{configured} configured, {resolved} resolved (mismatch)"
|
|
)),
|
|
},
|
|
configured: Some(configured),
|
|
resolved: Some(resolved),
|
|
};
|
|
}
|
|
|
|
ProjectsCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Ok,
|
|
message: Some(format!("{configured} configured, {resolved} resolved")),
|
|
},
|
|
configured: Some(configured),
|
|
resolved: Some(resolved),
|
|
}
|
|
}
|
|
Err(e) => ProjectsCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Error,
|
|
message: Some(e.to_string()),
|
|
},
|
|
configured: Some(configured),
|
|
resolved: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
async fn check_ollama(config: Option<&Config>) -> OllamaCheck {
|
|
let Some(config) = config else {
|
|
return OllamaCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Warning,
|
|
message: Some("Config not loaded".to_string()),
|
|
},
|
|
url: None,
|
|
model: None,
|
|
};
|
|
};
|
|
|
|
let base_url = &config.embedding.base_url;
|
|
let model = &config.embedding.model;
|
|
|
|
let client = match reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(2))
|
|
.build()
|
|
{
|
|
Ok(client) => client,
|
|
Err(e) => {
|
|
return OllamaCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Warning,
|
|
message: Some(format!("Failed to build HTTP client: {e}")),
|
|
},
|
|
url: Some(base_url.clone()),
|
|
model: Some(model.clone()),
|
|
};
|
|
}
|
|
};
|
|
|
|
match client.get(format!("{base_url}/api/tags")).send().await {
|
|
Ok(response) if response.status().is_success() => {
|
|
#[derive(serde::Deserialize)]
|
|
struct TagsResponse {
|
|
models: Option<Vec<ModelInfo>>,
|
|
}
|
|
#[derive(serde::Deserialize)]
|
|
struct ModelInfo {
|
|
name: String,
|
|
}
|
|
|
|
match response.json::<TagsResponse>().await {
|
|
Ok(data) => {
|
|
let models = data.models.unwrap_or_default();
|
|
let model_names: Vec<&str> = models
|
|
.iter()
|
|
.map(|m| m.name.split(':').next().unwrap_or(&m.name))
|
|
.collect();
|
|
|
|
let model_base = model.split(':').next().unwrap_or(model);
|
|
if !model_names.contains(&model_base) {
|
|
return OllamaCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Warning,
|
|
message: Some(format!(
|
|
"Model \"{model}\" not found. Available: {}",
|
|
if model_names.is_empty() {
|
|
"none".to_string()
|
|
} else {
|
|
model_names.join(", ")
|
|
}
|
|
)),
|
|
},
|
|
url: Some(base_url.clone()),
|
|
model: Some(model.clone()),
|
|
};
|
|
}
|
|
|
|
OllamaCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Ok,
|
|
message: Some(format!("{base_url} (model: {model})")),
|
|
},
|
|
url: Some(base_url.clone()),
|
|
model: Some(model.clone()),
|
|
}
|
|
}
|
|
Err(_) => OllamaCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Warning,
|
|
message: Some("Could not parse Ollama response".to_string()),
|
|
},
|
|
url: Some(base_url.clone()),
|
|
model: Some(model.clone()),
|
|
},
|
|
}
|
|
}
|
|
Ok(response) => OllamaCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Warning,
|
|
message: Some(format!("Ollama responded with {}", response.status())),
|
|
},
|
|
url: Some(base_url.clone()),
|
|
model: Some(model.clone()),
|
|
},
|
|
Err(_) => OllamaCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Warning,
|
|
message: Some("Not running (semantic search unavailable)".to_string()),
|
|
},
|
|
url: Some(base_url.clone()),
|
|
model: Some(model.clone()),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn check_logging(config: Option<&Config>) -> LoggingCheck {
|
|
let log_dir = get_log_dir(config.and_then(|c| c.logging.log_dir.as_deref()));
|
|
let log_dir_str = log_dir.display().to_string();
|
|
|
|
if !log_dir.exists() {
|
|
return LoggingCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Ok,
|
|
message: Some(format!("{log_dir_str} (no log files yet)")),
|
|
},
|
|
log_dir: Some(log_dir_str),
|
|
file_count: Some(0),
|
|
total_bytes: Some(0),
|
|
};
|
|
}
|
|
|
|
let mut file_count = 0usize;
|
|
let mut total_bytes = 0u64;
|
|
|
|
if let Ok(entries) = std::fs::read_dir(&log_dir) {
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name();
|
|
let name_str = name.to_string_lossy();
|
|
if name_str.starts_with("lore.") {
|
|
file_count += 1;
|
|
if let Ok(meta) = entry.metadata() {
|
|
total_bytes += meta.len();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let size_display = if total_bytes < 1024 {
|
|
format!("{total_bytes} B")
|
|
} else if total_bytes < 1024 * 1024 {
|
|
format!("{:.1} KB", total_bytes as f64 / 1024.0)
|
|
} else {
|
|
format!("{:.1} MB", total_bytes as f64 / (1024.0 * 1024.0))
|
|
};
|
|
|
|
LoggingCheck {
|
|
result: CheckResult {
|
|
status: CheckStatus::Ok,
|
|
message: Some(format!(
|
|
"{log_dir_str} ({file_count} files, {size_display})"
|
|
)),
|
|
},
|
|
log_dir: Some(log_dir_str),
|
|
file_count: Some(file_count),
|
|
total_bytes: Some(total_bytes),
|
|
}
|
|
}
|
|
|
|
pub fn print_doctor_results(result: &DoctorResult) {
|
|
println!();
|
|
|
|
print_check("Config", &result.checks.config.result);
|
|
print_check("Database", &result.checks.database.result);
|
|
print_check("GitLab", &result.checks.gitlab.result);
|
|
print_check("Projects", &result.checks.projects.result);
|
|
print_check("Ollama", &result.checks.ollama.result);
|
|
print_check("Logging", &result.checks.logging.result);
|
|
|
|
// Count statuses
|
|
let checks = [
|
|
&result.checks.config.result,
|
|
&result.checks.database.result,
|
|
&result.checks.gitlab.result,
|
|
&result.checks.projects.result,
|
|
&result.checks.ollama.result,
|
|
&result.checks.logging.result,
|
|
];
|
|
let passed = checks
|
|
.iter()
|
|
.filter(|c| c.status == CheckStatus::Ok)
|
|
.count();
|
|
let warnings = checks
|
|
.iter()
|
|
.filter(|c| c.status == CheckStatus::Warning)
|
|
.count();
|
|
let failed = checks
|
|
.iter()
|
|
.filter(|c| c.status == CheckStatus::Error)
|
|
.count();
|
|
|
|
println!();
|
|
|
|
let mut summary_parts = Vec::new();
|
|
if result.success {
|
|
summary_parts.push(Theme::success().render("Ready"));
|
|
} else {
|
|
summary_parts.push(Theme::error().render("Not ready"));
|
|
}
|
|
summary_parts.push(format!("{passed} passed"));
|
|
if warnings > 0 {
|
|
summary_parts.push(Theme::warning().render(&format!("{warnings} warning")));
|
|
}
|
|
if failed > 0 {
|
|
summary_parts.push(Theme::error().render(&format!("{failed} failed")));
|
|
}
|
|
println!(" {}", summary_parts.join(" \u{b7} "));
|
|
|
|
println!();
|
|
}
|
|
|
|
fn print_check(name: &str, result: &CheckResult) {
|
|
let icon = match result.status {
|
|
CheckStatus::Ok => Theme::success().render(Icons::success()),
|
|
CheckStatus::Warning => Theme::warning().render(Icons::warning()),
|
|
CheckStatus::Error => Theme::error().render(Icons::error()),
|
|
};
|
|
|
|
let message = result.message.as_deref().unwrap_or("");
|
|
let message_styled = match result.status {
|
|
CheckStatus::Ok => message.to_string(),
|
|
CheckStatus::Warning => Theme::warning().render(message),
|
|
CheckStatus::Error => Theme::error().render(message),
|
|
};
|
|
|
|
println!(" {icon} {:<10} {message_styled}", name);
|
|
}
|