Files
gitlore/src/cli/commands/init.rs
teernisse 30ed02c694 feat(token): add stored token support with resolve_token and token_source
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.
2026-02-18 16:27:48 -05:00

314 lines
9.4 KiB
Rust

use std::fs;
use std::io::{IsTerminal, Read};
use crate::core::config::{Config, MinimalConfig, MinimalGitLabConfig, ProjectConfig};
use crate::core::db::{create_connection, run_migrations};
use crate::core::error::{LoreError, Result};
use crate::core::paths::{ensure_config_permissions, get_config_path, get_data_dir};
use crate::gitlab::{GitLabClient, GitLabProject};
pub struct InitInputs {
pub gitlab_url: String,
pub token_env_var: String,
pub project_paths: Vec<String>,
pub default_project: Option<String>,
}
pub struct InitOptions {
pub config_path: Option<String>,
pub force: bool,
pub non_interactive: bool,
}
pub struct InitResult {
pub config_path: String,
pub data_dir: String,
pub user: UserInfo,
pub projects: Vec<ProjectInfo>,
pub default_project: Option<String>,
}
pub struct UserInfo {
pub username: String,
pub name: String,
}
pub struct ProjectInfo {
pub path: String,
pub name: String,
}
pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result<InitResult> {
let config_path = get_config_path(options.config_path.as_deref());
let data_dir = get_data_dir();
if config_path.exists() && !options.force {
if options.non_interactive {
return Err(LoreError::Other(format!(
"Config file exists at {}. Use --force to overwrite.",
config_path.display()
)));
}
return Err(LoreError::Other(
"User cancelled config overwrite.".to_string(),
));
}
if url::Url::parse(&inputs.gitlab_url).is_err() {
return Err(LoreError::Other(format!(
"Invalid GitLab URL: {}",
inputs.gitlab_url
)));
}
let token = std::env::var(&inputs.token_env_var).map_err(|_| LoreError::TokenNotSet {
env_var: inputs.token_env_var.clone(),
})?;
let client = GitLabClient::new(&inputs.gitlab_url, &token, None);
let gitlab_user = client.get_current_user().await.map_err(|e| {
if matches!(e, LoreError::GitLabAuthFailed) {
LoreError::Other(format!("Authentication failed for {}", inputs.gitlab_url))
} else {
e
}
})?;
let user = UserInfo {
username: gitlab_user.username,
name: gitlab_user.name,
};
let mut validated_projects: Vec<(ProjectInfo, GitLabProject)> = Vec::new();
for project_path in &inputs.project_paths {
let project = client.get_project(project_path).await.map_err(|e| {
if matches!(e, LoreError::GitLabNotFound { .. }) {
LoreError::Other(format!("Project not found: {project_path}"))
} else {
e
}
})?;
validated_projects.push((
ProjectInfo {
path: project_path.clone(),
name: project.name.clone().unwrap_or_else(|| {
project_path
.split('/')
.next_back()
.unwrap_or(project_path)
.to_string()
}),
},
project,
));
}
// Validate default_project matches one of the configured project paths
if let Some(ref dp) = inputs.default_project {
let matched = inputs.project_paths.iter().any(|p| {
p.eq_ignore_ascii_case(dp)
|| p.to_ascii_lowercase()
.ends_with(&format!("/{}", dp.to_ascii_lowercase()))
});
if !matched {
return Err(LoreError::Other(format!(
"defaultProject '{dp}' does not match any configured project path"
)));
}
}
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
let config = MinimalConfig {
gitlab: MinimalGitLabConfig {
base_url: inputs.gitlab_url,
token_env_var: inputs.token_env_var,
},
projects: inputs
.project_paths
.iter()
.map(|p| ProjectConfig { path: p.clone() })
.collect(),
default_project: inputs.default_project.clone(),
};
let config_json = serde_json::to_string_pretty(&config)?;
fs::write(&config_path, format!("{config_json}\n"))?;
fs::create_dir_all(&data_dir)?;
let db_path = data_dir.join("lore.db");
let conn = create_connection(&db_path)?;
run_migrations(&conn)?;
for (_, gitlab_project) in &validated_projects {
conn.execute(
"INSERT INTO projects (gitlab_project_id, path_with_namespace, default_branch, web_url)
VALUES (?, ?, ?, ?)
ON CONFLICT(gitlab_project_id) DO UPDATE SET
path_with_namespace = excluded.path_with_namespace,
default_branch = excluded.default_branch,
web_url = excluded.web_url",
(
gitlab_project.id,
&gitlab_project.path_with_namespace,
&gitlab_project.default_branch,
&gitlab_project.web_url,
),
)?;
}
Ok(InitResult {
config_path: config_path.display().to_string(),
data_dir: data_dir.display().to_string(),
user,
projects: validated_projects.into_iter().map(|(p, _)| p).collect(),
default_project: inputs.default_project,
})
}
// ── token set / show ──
pub struct TokenSetResult {
pub username: String,
pub config_path: String,
}
pub struct TokenShowResult {
pub token: String,
pub source: &'static str,
}
/// Read token from --token flag or stdin, validate against GitLab, store in config.
pub async fn run_token_set(
config_path_override: Option<&str>,
token_arg: Option<String>,
) -> Result<TokenSetResult> {
let config_path = get_config_path(config_path_override);
if !config_path.exists() {
return Err(LoreError::ConfigNotFound {
path: config_path.display().to_string(),
});
}
// Resolve token value: flag > stdin > error
let token = if let Some(t) = token_arg {
t.trim().to_string()
} else if !std::io::stdin().is_terminal() {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| LoreError::Other(format!("Failed to read token from stdin: {e}")))?;
buf.trim().to_string()
} else {
return Err(LoreError::Other(
"No token provided. Use --token or pipe to stdin.".to_string(),
));
};
if token.is_empty() {
return Err(LoreError::Other("Token cannot be empty.".to_string()));
}
// Load config to get the base URL for validation
let config = Config::load(config_path_override)?;
// Validate token against GitLab
let client = GitLabClient::new(&config.gitlab.base_url, &token, None);
let user = client.get_current_user().await.map_err(|e| {
if matches!(e, LoreError::GitLabAuthFailed) {
LoreError::Other("Token validation failed: authentication rejected by GitLab.".into())
} else {
e
}
})?;
// Read config as raw JSON, insert token, write back
let content = fs::read_to_string(&config_path)
.map_err(|e| LoreError::Other(format!("Failed to read config file: {e}")))?;
let mut json: serde_json::Value =
serde_json::from_str(&content).map_err(|e| LoreError::ConfigInvalid {
details: format!("Invalid JSON in config file: {e}"),
})?;
json["gitlab"]["token"] = serde_json::Value::String(token);
let output = serde_json::to_string_pretty(&json)
.map_err(|e| LoreError::Other(format!("Failed to serialize config: {e}")))?;
fs::write(&config_path, format!("{output}\n"))?;
// Enforce permissions
ensure_config_permissions(&config_path);
Ok(TokenSetResult {
username: user.username,
config_path: config_path.display().to_string(),
})
}
/// Show the current token (masked or unmasked) and its source.
pub fn run_token_show(config_path_override: Option<&str>, unmask: bool) -> Result<TokenShowResult> {
let config = Config::load(config_path_override)?;
let source = config
.gitlab
.token_source()
.ok_or_else(|| LoreError::TokenNotSet {
env_var: config.gitlab.token_env_var.clone(),
})?;
let token = config.gitlab.resolve_token()?;
let display_token = if unmask { token } else { mask_token(&token) };
Ok(TokenShowResult {
token: display_token,
source,
})
}
fn mask_token(token: &str) -> String {
let len = token.len();
if len <= 8 {
"*".repeat(len)
} else {
let visible = &token[..4];
format!("{visible}{}", "*".repeat(len - 4))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mask_token_hides_short_tokens_completely() {
assert_eq!(mask_token(""), "");
assert_eq!(mask_token("a"), "*");
assert_eq!(mask_token("abcd"), "****");
assert_eq!(mask_token("abcdefgh"), "********");
}
#[test]
fn mask_token_reveals_first_four_chars_for_long_tokens() {
assert_eq!(mask_token("abcdefghi"), "abcd*****");
assert_eq!(mask_token("glpat-xyzABC123456"), "glpa**************");
}
#[test]
fn mask_token_boundary_at_nine_chars() {
// 8 chars → fully masked, 9 chars → first 4 visible
assert_eq!(mask_token("12345678"), "********");
assert_eq!(mask_token("123456789"), "1234*****");
}
}