Skip to content

Commit 7c3299c

Browse files
authored
Fila: testing #30 — Add doctor and setup CLI commands
2 parents b83b9ab + 555d8d2 commit 7c3299c

File tree

9 files changed

+375
-23
lines changed

9 files changed

+375
-23
lines changed

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,8 @@ hex = "0.4.3"
2626
jsonwebtoken = "9"
2727
base64 = "0.22"
2828

29+
# CLI
30+
clap = { version = "4", features = ["derive"] }
31+
2932

3033

README.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,49 @@ GITHUB_WEBHOOK_SECRET=your-secret
136136
```
137137

138138
```bash
139-
cargo run
139+
fila
140140
```
141141

142142
The dashboard is available at `http://localhost:8000/`.
143143

144+
### CLI
145+
146+
Fila includes built-in commands to help with setup and troubleshooting:
147+
148+
```bash
149+
fila # start the server (default, same as before)
150+
fila doctor # validate config, database connection, and GitHub auth
151+
fila setup # interactive wizard that creates a .env file
152+
```
153+
154+
**`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.
155+
156+
```
157+
$ fila doctor
158+
159+
.env file found
160+
DATABASE_URL set
161+
GITHUB_APP_ID set
162+
GITHUB_PRIVATE_KEY set
163+
GITHUB_WEBHOOK_SECRET set
164+
165+
SERVER_PORT 8000
166+
HOST 127.0.0.1
167+
MERGE_STRATEGY batch
168+
BATCH_SIZE 5
169+
BATCH_INTERVAL_SECS 10
170+
CI_TIMEOUT_SECS 1800
171+
POLL_INTERVAL_SECS 15
172+
173+
Private key valid RSA PEM
174+
Database connected (sqlite)
175+
GitHub API authenticated as "my-merge-queue"
176+
177+
All checks passed.
178+
```
179+
180+
**`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.
181+
144182
### Deploy
145183
146184
**Docker:**

src/cli/doctor.rs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
use std::env;
2+
use std::path::Path;
3+
4+
use jsonwebtoken::EncodingKey;
5+
use rapina::database::DatabaseConfig;
6+
7+
use crate::github::client::GitHubClient;
8+
9+
fn print_line(label: &str, value: &str) {
10+
println!("{:<24} {}", label, value);
11+
}
12+
13+
pub async fn run() -> i32 {
14+
let mut issues: Vec<String> = Vec::new();
15+
16+
println!("fila doctor\n");
17+
18+
// .env file presence
19+
let dotenv_exists = Path::new(".env").exists();
20+
print_line(
21+
".env file",
22+
if dotenv_exists { "found" } else { "not found" },
23+
);
24+
25+
// Required env vars — never print values
26+
let required = [
27+
"DATABASE_URL",
28+
"GITHUB_APP_ID",
29+
"GITHUB_PRIVATE_KEY",
30+
"GITHUB_WEBHOOK_SECRET",
31+
];
32+
33+
for var in &required {
34+
match env::var(var) {
35+
Ok(_) => print_line(var, "set"),
36+
Err(_) => {
37+
print_line(var, "MISSING");
38+
issues.push(format!("{var} is not set"));
39+
}
40+
}
41+
}
42+
43+
println!();
44+
45+
// Optional vars with defaults
46+
let optionals: &[(&str, &str)] = &[
47+
("SERVER_PORT", "8000"),
48+
("HOST", "127.0.0.1"),
49+
("MERGE_STRATEGY", "batch"),
50+
("BATCH_SIZE", "5"),
51+
("BATCH_INTERVAL_SECS", "10"),
52+
("CI_TIMEOUT_SECS", "1800"),
53+
("POLL_INTERVAL_SECS", "15"),
54+
];
55+
56+
for (var, default) in optionals {
57+
let value = env::var(var).unwrap_or_else(|_| default.to_string());
58+
print_line(var, &value);
59+
}
60+
61+
// Validate MERGE_STRATEGY
62+
let strategy = env::var("MERGE_STRATEGY").unwrap_or_else(|_| "batch".to_string());
63+
if strategy != "batch" && strategy != "sequential" {
64+
issues.push(format!(
65+
"MERGE_STRATEGY is \"{strategy}\", expected \"batch\" or \"sequential\""
66+
));
67+
}
68+
69+
println!();
70+
71+
// Private key validation
72+
let key_result = env::var("GITHUB_PRIVATE_KEY");
73+
match &key_result {
74+
Ok(key) => match EncodingKey::from_rsa_pem(key.as_bytes()) {
75+
Ok(_) => print_line("Private key", "valid RSA PEM"),
76+
Err(e) => {
77+
print_line("Private key", &format!("INVALID ({e})"));
78+
issues.push(format!("GITHUB_PRIVATE_KEY is not valid RSA PEM: {e}"));
79+
}
80+
},
81+
Err(_) => print_line("Private key", "skipped (not set)"),
82+
}
83+
84+
// Database connectivity
85+
let db_result = env::var("DATABASE_URL");
86+
match &db_result {
87+
Ok(url) => {
88+
let db_config = DatabaseConfig::new(url);
89+
match db_config.connect().await {
90+
Ok(_) => {
91+
let db_type = if url.starts_with("sqlite") {
92+
"sqlite"
93+
} else if url.starts_with("postgres") {
94+
"postgres"
95+
} else {
96+
"unknown"
97+
};
98+
print_line("Database", &format!("connected ({db_type})"));
99+
}
100+
Err(e) => {
101+
print_line("Database", &format!("FAILED ({e})"));
102+
issues.push(format!("Database connection failed: {e}"));
103+
}
104+
}
105+
}
106+
Err(_) => print_line("Database", "skipped (DATABASE_URL not set)"),
107+
}
108+
109+
// GitHub API auth
110+
let app_id = env::var("GITHUB_APP_ID");
111+
match (&app_id, &key_result) {
112+
(Ok(id), Ok(key)) => {
113+
let client = GitHubClient::new(id.clone(), key.clone());
114+
match client.get_app_info().await {
115+
Ok(info) => {
116+
print_line("GitHub API", &format!("authenticated as \"{}\"", info.name));
117+
}
118+
Err(e) => {
119+
print_line("GitHub API", &format!("FAILED ({e})"));
120+
issues.push(format!("GitHub API authentication failed: {e}"));
121+
}
122+
}
123+
}
124+
_ => print_line("GitHub API", "skipped (credentials not set)"),
125+
}
126+
127+
// Summary
128+
println!();
129+
if issues.is_empty() {
130+
println!("All checks passed.");
131+
0
132+
} else {
133+
println!(
134+
"{} {} found:",
135+
issues.len(),
136+
if issues.len() == 1 { "issue" } else { "issues" }
137+
);
138+
for issue in &issues {
139+
println!(" - {issue}");
140+
}
141+
1
142+
}
143+
}

src/cli/mod.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
pub mod doctor;
2+
pub mod setup;
3+
4+
use clap::{Parser, Subcommand};
5+
6+
#[derive(Parser)]
7+
#[command(name = "fila", version, about = "GitHub merge queue")]
8+
pub struct Cli {
9+
#[command(subcommand)]
10+
pub command: Option<Command>,
11+
}
12+
13+
#[derive(Subcommand)]
14+
pub enum Command {
15+
/// Validate config, database, and GitHub auth
16+
Doctor,
17+
/// Interactive wizard to create a .env file
18+
Setup,
19+
}

src/cli/setup.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
use std::fs;
2+
use std::io::{self, Write};
3+
use std::path::Path;
4+
5+
fn prompt(label: &str, default: &str) -> String {
6+
if default.is_empty() {
7+
print!("{label}: ");
8+
} else {
9+
print!("{label} [{default}]: ");
10+
}
11+
io::stdout().flush().unwrap();
12+
13+
let mut input = String::new();
14+
io::stdin().read_line(&mut input).unwrap();
15+
let trimmed = input.trim();
16+
17+
if trimmed.is_empty() {
18+
default.to_string()
19+
} else {
20+
trimmed.to_string()
21+
}
22+
}
23+
24+
pub fn run() -> i32 {
25+
println!("fila setup\n");
26+
27+
if Path::new(".env").exists() {
28+
print!(".env already exists. Overwrite? [y/N]: ");
29+
io::stdout().flush().unwrap();
30+
let mut answer = String::new();
31+
io::stdin().read_line(&mut answer).unwrap();
32+
if !answer.trim().eq_ignore_ascii_case("y") {
33+
println!("Aborted.");
34+
return 0;
35+
}
36+
println!();
37+
}
38+
39+
let app_id = prompt("GitHub App ID", "");
40+
let key_path = prompt("Path to private key file (PEM)", "");
41+
let webhook_secret = prompt("GitHub Webhook Secret", "");
42+
let database_url = prompt("Database URL", "sqlite://fila.db?mode=rwc");
43+
let merge_strategy = prompt("Merge strategy (batch/sequential)", "batch");
44+
let batch_size = prompt("Batch size", "5");
45+
let batch_interval = prompt("Batch interval (seconds)", "10");
46+
let ci_timeout = prompt("CI timeout (seconds)", "1800");
47+
let poll_interval = prompt("Poll interval (seconds)", "15");
48+
let server_port = prompt("Server port", "8000");
49+
let host = prompt("Host", "127.0.0.1");
50+
51+
// Read private key from file
52+
let private_key = if key_path.is_empty() {
53+
String::new()
54+
} else {
55+
match fs::read_to_string(&key_path) {
56+
Ok(contents) => contents,
57+
Err(e) => {
58+
println!("Failed to read private key file: {e}");
59+
return 1;
60+
}
61+
}
62+
};
63+
64+
let mut env_content = String::new();
65+
66+
env_content.push_str(&format!("DATABASE_URL={database_url}\n"));
67+
env_content.push_str(&format!("GITHUB_APP_ID={app_id}\n"));
68+
69+
if !private_key.is_empty() {
70+
// Wrap in double quotes so dotenvy handles embedded newlines
71+
env_content.push_str(&format!("GITHUB_PRIVATE_KEY=\"{}\"\n", private_key.trim()));
72+
}
73+
74+
env_content.push_str(&format!("GITHUB_WEBHOOK_SECRET={webhook_secret}\n"));
75+
env_content.push_str(&format!("MERGE_STRATEGY={merge_strategy}\n"));
76+
env_content.push_str(&format!("BATCH_SIZE={batch_size}\n"));
77+
env_content.push_str(&format!("BATCH_INTERVAL_SECS={batch_interval}\n"));
78+
env_content.push_str(&format!("CI_TIMEOUT_SECS={ci_timeout}\n"));
79+
env_content.push_str(&format!("POLL_INTERVAL_SECS={poll_interval}\n"));
80+
env_content.push_str(&format!("SERVER_PORT={server_port}\n"));
81+
env_content.push_str(&format!("HOST={host}\n"));
82+
83+
match fs::write(".env", &env_content) {
84+
Ok(()) => {
85+
println!("\n.env written successfully.");
86+
println!("Run `fila doctor` to verify your configuration.");
87+
0
88+
}
89+
Err(e) => {
90+
println!("Failed to write .env: {e}");
91+
1
92+
}
93+
}
94+
}

src/github/client.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,38 @@ impl GitHubClient {
475475
Ok(())
476476
}
477477

478+
/// Validate that the private key can produce a JWT.
479+
pub fn validate_credentials(&self) -> Result<(), GitHubClientError> {
480+
let _ = self.generate_jwt()?;
481+
Ok(())
482+
}
483+
484+
/// Fetch app info from GitHub using JWT auth (no installation token needed).
485+
pub async fn get_app_info(&self) -> Result<GhAppInfo, GitHubClientError> {
486+
let jwt = self.generate_jwt()?;
487+
488+
let resp = self
489+
.client
490+
.get(format!("{GITHUB_API}/app"))
491+
.header(AUTHORIZATION, format!("Bearer {jwt}"))
492+
.header(ACCEPT, "application/vnd.github+json")
493+
.send()
494+
.await
495+
.map_err(|e| GitHubClientError::Http(e.to_string()))?;
496+
497+
if !resp.status().is_success() {
498+
let status = resp.status();
499+
let body = resp.text().await.unwrap_or_default();
500+
return Err(GitHubClientError::Api(format!(
501+
"GET /app failed: {status} {body}"
502+
)));
503+
}
504+
505+
resp.json()
506+
.await
507+
.map_err(|e| GitHubClientError::Http(e.to_string()))
508+
}
509+
478510
/// Check if all check runs for a commit have passed.
479511
pub async fn all_checks_passed(
480512
&self,

src/github/types.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ pub struct GhMergeCommit {
7676
pub sha: String,
7777
}
7878

79+
#[derive(Debug, Deserialize)]
80+
pub struct GhAppInfo {
81+
pub name: String,
82+
pub slug: String,
83+
}
84+
7985
pub enum MergeResult {
8086
Created(String),
8187
AlreadyMerged,

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod batches;
2+
pub mod cli;
23
pub mod config;
34
pub mod dashboard;
45
pub mod entity;

0 commit comments

Comments
 (0)