From 555d8d226b74cb8773dc513b66ceb5f29a329722 Mon Sep 17 00:00:00 2001 From: arferreira Date: Sun, 8 Mar 2026 19:38:06 -0400 Subject: [PATCH] Add doctor and setup CLI commands --- Cargo.toml | 3 + README.md | 40 +++++++++++- src/cli/doctor.rs | 143 +++++++++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 19 ++++++ src/cli/setup.rs | 94 ++++++++++++++++++++++++++++ src/github/client.rs | 32 ++++++++++ src/github/types.rs | 6 ++ src/lib.rs | 1 + src/main.rs | 60 +++++++++++------- 9 files changed, 375 insertions(+), 23 deletions(-) create mode 100644 src/cli/doctor.rs create mode 100644 src/cli/mod.rs create mode 100644 src/cli/setup.rs diff --git a/Cargo.toml b/Cargo.toml index a6070d3..81c8dd4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,5 +26,8 @@ hex = "0.4.3" jsonwebtoken = "9" base64 = "0.22" +# CLI +clap = { version = "4", features = ["derive"] } + diff --git a/README.md b/README.md index b5812fc..61f8425 100644 --- a/README.md +++ b/README.md @@ -136,11 +136,49 @@ GITHUB_WEBHOOK_SECRET=your-secret ``` ```bash -cargo run +fila ``` The dashboard is available at `http://localhost:8000/`. +### CLI + +Fila includes built-in commands to help with setup and troubleshooting: + +```bash +fila # start the server (default, same as before) +fila doctor # validate config, database connection, and GitHub auth +fila setup # interactive wizard that creates a .env file +``` + +**`fila doctor`** reads each environment variable individually and reports what's present, what's missing, and what's broken — without starting the server. It validates your private key is valid RSA PEM, tests database connectivity, and verifies GitHub API authentication by hitting `GET /app`. Exit code 0 means everything is good, 1 means something needs attention. + +``` +$ fila doctor + +.env file found +DATABASE_URL set +GITHUB_APP_ID set +GITHUB_PRIVATE_KEY set +GITHUB_WEBHOOK_SECRET set + +SERVER_PORT 8000 +HOST 127.0.0.1 +MERGE_STRATEGY batch +BATCH_SIZE 5 +BATCH_INTERVAL_SECS 10 +CI_TIMEOUT_SECS 1800 +POLL_INTERVAL_SECS 15 + +Private key valid RSA PEM +Database connected (sqlite) +GitHub API authenticated as "my-merge-queue" + +All checks passed. +``` + +**`fila setup`** walks you through creating a `.env` file. It prompts for each value with sensible defaults, reads your private key from a file path, and writes everything properly formatted. Run `fila doctor` after to verify. + ### Deploy **Docker:** diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs new file mode 100644 index 0000000..8bbf71a --- /dev/null +++ b/src/cli/doctor.rs @@ -0,0 +1,143 @@ +use std::env; +use std::path::Path; + +use jsonwebtoken::EncodingKey; +use rapina::database::DatabaseConfig; + +use crate::github::client::GitHubClient; + +fn print_line(label: &str, value: &str) { + println!("{:<24} {}", label, value); +} + +pub async fn run() -> i32 { + let mut issues: Vec = Vec::new(); + + println!("fila doctor\n"); + + // .env file presence + let dotenv_exists = Path::new(".env").exists(); + print_line( + ".env file", + if dotenv_exists { "found" } else { "not found" }, + ); + + // Required env vars — never print values + let required = [ + "DATABASE_URL", + "GITHUB_APP_ID", + "GITHUB_PRIVATE_KEY", + "GITHUB_WEBHOOK_SECRET", + ]; + + for var in &required { + match env::var(var) { + Ok(_) => print_line(var, "set"), + Err(_) => { + print_line(var, "MISSING"); + issues.push(format!("{var} is not set")); + } + } + } + + println!(); + + // Optional vars with defaults + let optionals: &[(&str, &str)] = &[ + ("SERVER_PORT", "8000"), + ("HOST", "127.0.0.1"), + ("MERGE_STRATEGY", "batch"), + ("BATCH_SIZE", "5"), + ("BATCH_INTERVAL_SECS", "10"), + ("CI_TIMEOUT_SECS", "1800"), + ("POLL_INTERVAL_SECS", "15"), + ]; + + for (var, default) in optionals { + let value = env::var(var).unwrap_or_else(|_| default.to_string()); + print_line(var, &value); + } + + // Validate MERGE_STRATEGY + let strategy = env::var("MERGE_STRATEGY").unwrap_or_else(|_| "batch".to_string()); + if strategy != "batch" && strategy != "sequential" { + issues.push(format!( + "MERGE_STRATEGY is \"{strategy}\", expected \"batch\" or \"sequential\"" + )); + } + + println!(); + + // Private key validation + let key_result = env::var("GITHUB_PRIVATE_KEY"); + match &key_result { + Ok(key) => match EncodingKey::from_rsa_pem(key.as_bytes()) { + Ok(_) => print_line("Private key", "valid RSA PEM"), + Err(e) => { + print_line("Private key", &format!("INVALID ({e})")); + issues.push(format!("GITHUB_PRIVATE_KEY is not valid RSA PEM: {e}")); + } + }, + Err(_) => print_line("Private key", "skipped (not set)"), + } + + // Database connectivity + let db_result = env::var("DATABASE_URL"); + match &db_result { + Ok(url) => { + let db_config = DatabaseConfig::new(url); + match db_config.connect().await { + Ok(_) => { + let db_type = if url.starts_with("sqlite") { + "sqlite" + } else if url.starts_with("postgres") { + "postgres" + } else { + "unknown" + }; + print_line("Database", &format!("connected ({db_type})")); + } + Err(e) => { + print_line("Database", &format!("FAILED ({e})")); + issues.push(format!("Database connection failed: {e}")); + } + } + } + Err(_) => print_line("Database", "skipped (DATABASE_URL not set)"), + } + + // GitHub API auth + let app_id = env::var("GITHUB_APP_ID"); + match (&app_id, &key_result) { + (Ok(id), Ok(key)) => { + let client = GitHubClient::new(id.clone(), key.clone()); + match client.get_app_info().await { + Ok(info) => { + print_line("GitHub API", &format!("authenticated as \"{}\"", info.name)); + } + Err(e) => { + print_line("GitHub API", &format!("FAILED ({e})")); + issues.push(format!("GitHub API authentication failed: {e}")); + } + } + } + _ => print_line("GitHub API", "skipped (credentials not set)"), + } + + // Summary + println!(); + if issues.is_empty() { + println!("All checks passed."); + 0 + } else { + println!( + "{} {} found:", + issues.len(), + if issues.len() == 1 { "issue" } else { "issues" } + ); + for issue in &issues { + println!(" - {issue}"); + } + 1 + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..772f179 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,19 @@ +pub mod doctor; +pub mod setup; + +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "fila", version, about = "GitHub merge queue")] +pub struct Cli { + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand)] +pub enum Command { + /// Validate config, database, and GitHub auth + Doctor, + /// Interactive wizard to create a .env file + Setup, +} diff --git a/src/cli/setup.rs b/src/cli/setup.rs new file mode 100644 index 0000000..d4f6195 --- /dev/null +++ b/src/cli/setup.rs @@ -0,0 +1,94 @@ +use std::fs; +use std::io::{self, Write}; +use std::path::Path; + +fn prompt(label: &str, default: &str) -> String { + if default.is_empty() { + print!("{label}: "); + } else { + print!("{label} [{default}]: "); + } + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + let trimmed = input.trim(); + + if trimmed.is_empty() { + default.to_string() + } else { + trimmed.to_string() + } +} + +pub fn run() -> i32 { + println!("fila setup\n"); + + if Path::new(".env").exists() { + print!(".env already exists. Overwrite? [y/N]: "); + io::stdout().flush().unwrap(); + let mut answer = String::new(); + io::stdin().read_line(&mut answer).unwrap(); + if !answer.trim().eq_ignore_ascii_case("y") { + println!("Aborted."); + return 0; + } + println!(); + } + + let app_id = prompt("GitHub App ID", ""); + let key_path = prompt("Path to private key file (PEM)", ""); + let webhook_secret = prompt("GitHub Webhook Secret", ""); + let database_url = prompt("Database URL", "sqlite://fila.db?mode=rwc"); + let merge_strategy = prompt("Merge strategy (batch/sequential)", "batch"); + let batch_size = prompt("Batch size", "5"); + let batch_interval = prompt("Batch interval (seconds)", "10"); + let ci_timeout = prompt("CI timeout (seconds)", "1800"); + let poll_interval = prompt("Poll interval (seconds)", "15"); + let server_port = prompt("Server port", "8000"); + let host = prompt("Host", "127.0.0.1"); + + // Read private key from file + let private_key = if key_path.is_empty() { + String::new() + } else { + match fs::read_to_string(&key_path) { + Ok(contents) => contents, + Err(e) => { + println!("Failed to read private key file: {e}"); + return 1; + } + } + }; + + let mut env_content = String::new(); + + env_content.push_str(&format!("DATABASE_URL={database_url}\n")); + env_content.push_str(&format!("GITHUB_APP_ID={app_id}\n")); + + if !private_key.is_empty() { + // Wrap in double quotes so dotenvy handles embedded newlines + env_content.push_str(&format!("GITHUB_PRIVATE_KEY=\"{}\"\n", private_key.trim())); + } + + env_content.push_str(&format!("GITHUB_WEBHOOK_SECRET={webhook_secret}\n")); + env_content.push_str(&format!("MERGE_STRATEGY={merge_strategy}\n")); + env_content.push_str(&format!("BATCH_SIZE={batch_size}\n")); + env_content.push_str(&format!("BATCH_INTERVAL_SECS={batch_interval}\n")); + env_content.push_str(&format!("CI_TIMEOUT_SECS={ci_timeout}\n")); + env_content.push_str(&format!("POLL_INTERVAL_SECS={poll_interval}\n")); + env_content.push_str(&format!("SERVER_PORT={server_port}\n")); + env_content.push_str(&format!("HOST={host}\n")); + + match fs::write(".env", &env_content) { + Ok(()) => { + println!("\n.env written successfully."); + println!("Run `fila doctor` to verify your configuration."); + 0 + } + Err(e) => { + println!("Failed to write .env: {e}"); + 1 + } + } +} diff --git a/src/github/client.rs b/src/github/client.rs index 3869fc2..6467009 100644 --- a/src/github/client.rs +++ b/src/github/client.rs @@ -475,6 +475,38 @@ impl GitHubClient { Ok(()) } + /// Validate that the private key can produce a JWT. + pub fn validate_credentials(&self) -> Result<(), GitHubClientError> { + let _ = self.generate_jwt()?; + Ok(()) + } + + /// Fetch app info from GitHub using JWT auth (no installation token needed). + pub async fn get_app_info(&self) -> Result { + let jwt = self.generate_jwt()?; + + let resp = self + .client + .get(format!("{GITHUB_API}/app")) + .header(AUTHORIZATION, format!("Bearer {jwt}")) + .header(ACCEPT, "application/vnd.github+json") + .send() + .await + .map_err(|e| GitHubClientError::Http(e.to_string()))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(GitHubClientError::Api(format!( + "GET /app failed: {status} {body}" + ))); + } + + resp.json() + .await + .map_err(|e| GitHubClientError::Http(e.to_string())) + } + /// Check if all check runs for a commit have passed. pub async fn all_checks_passed( &self, diff --git a/src/github/types.rs b/src/github/types.rs index 6b3aed8..0c03c08 100644 --- a/src/github/types.rs +++ b/src/github/types.rs @@ -76,6 +76,12 @@ pub struct GhMergeCommit { pub sha: String, } +#[derive(Debug, Deserialize)] +pub struct GhAppInfo { + pub name: String, + pub slug: String, +} + pub enum MergeResult { Created(String), AlreadyMerged, diff --git a/src/lib.rs b/src/lib.rs index e003860..eff975d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ mod batches; +pub mod cli; pub mod config; pub mod dashboard; pub mod entity; diff --git a/src/main.rs b/src/main.rs index 102671a..4977229 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,8 @@ use std::sync::Arc; +use clap::Parser; + +use fila::cli::{Cli, Command}; use fila::config::app::AppConfig; use fila::github::client::GitHubClient; use fila::queue; @@ -9,26 +12,39 @@ use rapina::database::DatabaseConfig; async fn main() -> std::io::Result<()> { dotenvy::dotenv().ok(); - let config = AppConfig::from_env().expect("Missing initial config"); - - let addr = format!("{}:{}", config.host, config.server_port); - - // Create a separate DB connection for the batch runner - let runner_db = DatabaseConfig::new(&config.database_url) - .connect() - .await - .expect("Failed to connect database for batch runner"); - - let github = Arc::new(GitHubClient::new( - config.github_app_id.clone(), - config.github_private_key.clone(), - )); - - // Spawn the batch runner before starting the server - queue::runner::spawn(runner_db, github.clone(), config.clone()); - - fila::build_app(config, github, true) - .await - .listen(&addr) - .await + let cli = Cli::parse(); + + match cli.command { + Some(Command::Doctor) => { + let code = fila::cli::doctor::run().await; + std::process::exit(code); + } + Some(Command::Setup) => { + let code = fila::cli::setup::run(); + std::process::exit(code); + } + None => { + let config = AppConfig::from_env().expect("Missing initial config"); + let addr = format!("{}:{}", config.host, config.server_port); + + // Create a separate DB connection for the batch runner + let runner_db = DatabaseConfig::new(&config.database_url) + .connect() + .await + .expect("Failed to connect database for batch runner"); + + let github = Arc::new(GitHubClient::new( + config.github_app_id.clone(), + config.github_private_key.clone(), + )); + + // Spawn the batch runner before starting the server + queue::runner::spawn(runner_db, github.clone(), config.clone()); + + fila::build_app(config, github, true) + .await + .listen(&addr) + .await + } + } }