fabro/lib/crates/fabro-cli/src/install.rs
Bryan Helmkamp 25ec842a7f
Fix detect_binary test to work on Ubuntu (dash) sandboxes
`sh --version` exits non-zero on dash (Ubuntu default), so the test
only passed on macOS where sh is bash. Use `git` instead which
reliably supports --version on all platforms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 15:31:27 -04:00

1229 lines
41 KiB
Rust

#[cfg(feature = "server")]
use std::io::Write as _;
use std::net::SocketAddr;
use std::path::Path;
use std::process::{Command, Stdio};
use anyhow::{bail, Context, Result};
use axum::extract::Query;
use axum::response::Html;
use axum::routing::get;
use dialoguer::{Confirm, MultiSelect, Password, Select};
use fabro_llm::provider::Provider;
use fabro_util::terminal::Styles;
use rand::Rng;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use crate::doctor;
// ---------------------------------------------------------------------------
// OpenSSL helpers (server mode only)
// ---------------------------------------------------------------------------
/// Run an openssl subcommand and return stdout on success.
#[cfg(feature = "server")]
fn run_openssl(args: &[&str], description: &str) -> Result<Vec<u8>> {
let output = Command::new("openssl")
.args(args)
.output()
.with_context(|| format!("failed to run openssl for: {description}"))?;
if !output.status.success() {
bail!(
"openssl {description} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(output.stdout)
}
/// Run an openssl subcommand that reads key material from stdin.
#[cfg(feature = "server")]
fn run_openssl_with_stdin(args: &[&str], stdin_data: &[u8], description: &str) -> Result<Vec<u8>> {
let mut child = Command::new("openssl")
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("failed to spawn openssl for: {description}"))?;
child
.stdin
.take()
.context("openssl process missing stdin")?
.write_all(stdin_data)
.with_context(|| format!("failed to write to openssl stdin for: {description}"))?;
let output = child
.wait_with_output()
.with_context(|| format!("failed to read openssl output for: {description}"))?;
if !output.status.success() {
bail!(
"openssl {description} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(output.stdout)
}
// ---------------------------------------------------------------------------
// Session secret (server mode only)
// ---------------------------------------------------------------------------
#[cfg(feature = "server")]
fn generate_session_secret() -> String {
let mut rng = rand::thread_rng();
let bytes: [u8; 32] = rng.gen();
hex::encode(&bytes)
}
// ---------------------------------------------------------------------------
// JWT keypair generation (server mode only)
// ---------------------------------------------------------------------------
#[cfg(feature = "server")]
fn generate_jwt_keypair() -> Result<(String, String)> {
let private_pem = run_openssl(&["genpkey", "-algorithm", "Ed25519"], "generate keypair")?;
let public_pem =
run_openssl_with_stdin(&["pkey", "-pubout"], &private_pem, "extract public key")?;
let private_str = String::from_utf8(private_pem).context("private key is not valid UTF-8")?;
let public_str = String::from_utf8(public_pem).context("public key is not valid UTF-8")?;
Ok((private_str, public_str))
}
// ---------------------------------------------------------------------------
// mTLS certificate generation (server mode only)
// ---------------------------------------------------------------------------
#[cfg(feature = "server")]
fn generate_mtls_certs(dir: &Path) -> Result<()> {
std::fs::create_dir_all(dir).context("failed to create certs directory")?;
// 1. CA key + self-signed cert
let ca_key = run_openssl(&["genpkey", "-algorithm", "Ed25519"], "generate CA key")?;
let ca_key_path = dir.join("ca.key");
std::fs::write(&ca_key_path, &ca_key)?;
let ca_cert = run_openssl(
&[
"req",
"-new",
"-x509",
"-key",
ca_key_path
.to_str()
.context("CA key path is not valid UTF-8")?,
"-days",
"3650",
"-subj",
"/CN=Fabro CA",
],
"generate CA cert",
)?;
let ca_cert_path = dir.join("ca.crt");
std::fs::write(&ca_cert_path, &ca_cert)?;
// 2. Server key + CSR signed by CA
let server_key = run_openssl(&["genpkey", "-algorithm", "Ed25519"], "generate server key")?;
let server_key_path = dir.join("server.key");
std::fs::write(&server_key_path, &server_key)?;
let csr = run_openssl_with_stdin(
&[
"req",
"-new",
"-key",
"/dev/stdin",
"-subj",
"/CN=localhost",
],
&server_key,
"generate server CSR",
)?;
let csr_path = dir.join("server.csr");
std::fs::write(&csr_path, &csr)?;
let server_cert = run_openssl(
&[
"x509",
"-req",
"-in",
csr_path.to_str().context("CSR path is not valid UTF-8")?,
"-CA",
ca_cert_path
.to_str()
.context("CA cert path is not valid UTF-8")?,
"-CAkey",
ca_key_path
.to_str()
.context("CA key path is not valid UTF-8")?,
"-CAcreateserial",
"-days",
"3650",
],
"sign server cert",
)?;
std::fs::write(dir.join("server.crt"), &server_cert)?;
// Clean up temporary files
let _ = std::fs::remove_file(&csr_path);
let _ = std::fs::remove_file(dir.join("ca.srl"));
Ok(())
}
// ---------------------------------------------------------------------------
// Config TOML generation (server mode only)
// ---------------------------------------------------------------------------
#[cfg(feature = "server")]
fn format_config_toml(username: &str) -> String {
format!(
r#"[web]
url = "http://localhost:5173"
[web.auth]
provider = "github"
allowed_usernames = ["{username}"]
[api]
base_url = "https://localhost:3000"
authentication_strategies = ["jwt", "mtls"]
[api.tls]
cert = "~/.fabro/certs/server.crt"
key = "~/.fabro/certs/server.key"
ca = "~/.fabro/certs/ca.crt"
"#
)
}
// ---------------------------------------------------------------------------
// .env merge
// ---------------------------------------------------------------------------
fn merge_env(existing: &str, new_vars: &[(&str, &str)]) -> String {
let mut result_lines: Vec<String> = Vec::new();
let mut handled_keys: std::collections::HashSet<&str> = std::collections::HashSet::new();
for line in existing.lines() {
if let Some(eq_pos) = line.find('=') {
let key = line[..eq_pos].trim();
if !key.is_empty() && !key.starts_with('#') {
if let Some((_, new_val)) = new_vars.iter().find(|(k, _)| *k == key) {
result_lines.push(format!("{key}={new_val}"));
handled_keys.insert(key);
continue;
}
}
}
result_lines.push(line.to_string());
}
for (key, val) in new_vars {
if !handled_keys.contains(*key) {
result_lines.push(format!("{key}={val}"));
}
}
let mut result = result_lines.join("\n");
if !result.ends_with('\n') {
result.push('\n');
}
result
}
// ---------------------------------------------------------------------------
// Provider key URLs
// ---------------------------------------------------------------------------
fn provider_key_url(provider: Provider) -> &'static str {
match provider {
Provider::Anthropic => "https://console.anthropic.com/settings/keys",
Provider::OpenAi => "https://platform.openai.com/api-keys",
Provider::Gemini => "https://aistudio.google.com/apikey",
Provider::Kimi => "https://platform.moonshot.cn/console/api-keys",
Provider::Zai => "https://open.bigmodel.cn/usercenter/apikeys",
Provider::Minimax => {
"https://platform.minimaxi.com/user-center/basic-information/interface-key"
}
Provider::Inception => "https://console.inceptionlabs.ai/api-keys",
}
}
fn provider_display_name(provider: Provider) -> &'static str {
match provider {
Provider::Anthropic => "Anthropic",
Provider::OpenAi => "OpenAI",
Provider::Gemini => "Gemini",
Provider::Kimi => "Kimi",
Provider::Zai => "Zai",
Provider::Minimax => "Minimax",
Provider::Inception => "Inception",
}
}
// ---------------------------------------------------------------------------
// OpenAI OAuth helpers
// ---------------------------------------------------------------------------
/// Check if a binary exists on PATH using the doctor.rs pattern.
fn detect_binary_on_path(binary: &str) -> bool {
Command::new(binary)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Convert OAuth tokens to env var pairs for ~/.fabro/.env.
fn openai_oauth_env_pairs(access_token: &str, refresh_token: &str) -> Vec<(String, String)> {
vec![
("OPENAI_API_KEY".to_string(), access_token.to_string()),
(
"OPENAI_REFRESH_TOKEN".to_string(),
refresh_token.to_string(),
),
]
}
// ---------------------------------------------------------------------------
// Interactive setup
// ---------------------------------------------------------------------------
fn prompt_confirm(prompt: &str, default: bool) -> Result<bool> {
Ok(
Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.default(default)
.interact_on(&dialoguer::console::Term::stderr())?,
)
}
#[cfg(feature = "server")]
fn prompt_input(prompt: &str) -> Result<String> {
Ok(
dialoguer::Input::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.interact_on(&dialoguer::console::Term::stderr())?,
)
}
fn prompt_password(prompt: &str) -> Result<String> {
Ok(
Password::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.interact_on(&dialoguer::console::Term::stderr())?,
)
}
fn prompt_select(prompt: &str, items: &[String]) -> Result<usize> {
Ok(
Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.items(items)
.interact_on(&dialoguer::console::Term::stderr())?,
)
}
fn prompt_multiselect(prompt: &str, items: &[String]) -> Result<Vec<usize>> {
Ok(
MultiSelect::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.items(items)
.interact_on(&dialoguer::console::Term::stderr())?,
)
}
// ---------------------------------------------------------------------------
// GitHub App manifest flow
// ---------------------------------------------------------------------------
#[derive(serde::Deserialize)]
struct CallbackParams {
code: String,
}
/// Run the GitHub App manifest registration flow via a temporary local server.
/// Returns env var pairs (key, value) for secrets to merge into `.env`.
async fn setup_github_app(arc_dir: &Path, s: &Styles) -> Result<Vec<(String, String)>> {
// Random suffix so app names don't collide
let mut rng = rand::thread_rng();
let suffix: String = (0..6)
.map(|_| format!("{:x}", rng.gen::<u8>() % 16))
.collect();
let app_name = format!("Arc-{suffix}");
// Bind to random port
let listener = TcpListener::bind("127.0.0.1:0")
.await
.context("failed to bind local server")?;
let addr: SocketAddr = listener.local_addr()?;
let port = addr.port();
let manifest = serde_json::json!({
"name": app_name,
"url": "https://github.com/apps/arc",
"redirect_url": format!("http://127.0.0.1:{port}/callback"),
"public": false,
"default_permissions": {
"contents": "write",
"metadata": "read",
"pull_requests": "write",
"checks": "write",
"issues": "write",
"emails": "read"
},
"default_events": []
});
let manifest_json = serde_json::to_string(&manifest)?;
let escaped_manifest = manifest_json
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;");
// Channel to receive the code from the callback
let (code_tx, code_rx) = oneshot::channel::<String>();
// Channel to trigger graceful shutdown
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let code_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(code_tx)));
let shutdown_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(shutdown_tx)));
let index_html = format!(
r#"<!DOCTYPE html>
<html>
<body>
<p>Redirecting to GitHub...</p>
<form id="f" method="post" action="https://github.com/settings/apps/new">
<input type="hidden" name="manifest" value="{escaped_manifest}">
</form>
<script>document.getElementById('f').submit();</script>
</body>
</html>"#
);
let app = axum::Router::new()
.route(
"/",
get(move || async move { Html(index_html.clone()) }),
)
.route(
"/callback",
get(move |Query(params): Query<CallbackParams>| async move {
if let Some(tx) = code_tx.lock().unwrap().take() {
let _ = tx.send(params.code);
}
if let Some(tx) = shutdown_tx.lock().unwrap().take() {
let _ = tx.send(());
}
Html(r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Fabro Setup</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #f6f8fa; color: #1f2328; }
.card { text-align: center; background: #fff; border: 1px solid #d1d9e0; border-radius: 12px; padding: 48px; max-width: 420px; }
.check { font-size: 48px; margin-bottom: 16px; }
h1 { font-size: 20px; font-weight: 600; margin: 0 0 8px; }
p { font-size: 14px; color: #59636e; margin: 0; }
</style>
</head>
<body>
<div class="card">
<div class="check">&#10003;</div>
<h1>GitHub App created</h1>
<p>You can close this tab and return to your terminal.</p>
</div>
</body>
</html>"#.to_string())
}),
);
// Spawn server with graceful shutdown
tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
})
.await
.ok();
});
// Open browser
let url = format!("http://127.0.0.1:{port}/");
eprintln!(" {}", s.dim.apply_to("Opening browser..."));
if let Err(e) = open::that(&url) {
eprintln!(" Could not open browser automatically: {e}");
eprintln!(" Please open this URL manually: {url}");
}
eprintln!(
" {}",
s.dim.apply_to("Waiting for GitHub... (Ctrl+C to cancel)")
);
// Wait for the code
let code = code_rx
.await
.context("did not receive callback from GitHub (was the browser flow completed?)")?;
// Exchange code for app credentials
eprintln!(" {}", s.dim.apply_to("Exchanging code with GitHub..."));
let client = reqwest::Client::new();
let resp = client
.post(format!(
"https://api.github.com/app-manifests/{code}/conversions"
))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "fabro-cli")
.send()
.await
.context("failed to exchange code with GitHub")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("GitHub manifest conversion failed ({status}): {body}");
}
let body: serde_json::Value = resp.json().await.context("invalid JSON from GitHub")?;
let app_id = body["id"]
.as_i64()
.context("missing 'id' in GitHub response")?
.to_string();
let slug = body["slug"]
.as_str()
.context("missing 'slug' in GitHub response")?
.to_string();
let client_id = body["client_id"]
.as_str()
.context("missing 'client_id' in GitHub response")?
.to_string();
let client_secret = body["client_secret"]
.as_str()
.context("missing 'client_secret' in GitHub response")?
.to_string();
let webhook_secret = body["webhook_secret"].as_str().map(String::from);
let pem = body["pem"]
.as_str()
.context("missing 'pem' in GitHub response")?
.to_string();
// Write non-secret config to cli.toml
let cli_toml_path = arc_dir.join("cli.toml");
let existing = std::fs::read_to_string(&cli_toml_path).unwrap_or_default();
let mut doc: toml::Value = if existing.is_empty() {
toml::Value::Table(Default::default())
} else {
toml::from_str(&existing).context("failed to parse existing cli.toml")?
};
let table = doc.as_table_mut().context("cli.toml root is not a table")?;
let git = table
.entry("git")
.or_insert(toml::Value::Table(Default::default()));
let git_table = git
.as_table_mut()
.context("cli.toml [git] is not a table")?;
git_table.insert("app_id".into(), toml::Value::String(app_id));
git_table.insert("slug".into(), toml::Value::String(slug.clone()));
git_table.insert("client_id".into(), toml::Value::String(client_id));
std::fs::write(&cli_toml_path, toml::to_string_pretty(&doc)?)?;
eprintln!(
" {}",
s.dim.apply_to(format!("Wrote {}", cli_toml_path.display()))
);
eprintln!(
" {}",
s.dim
.apply_to(format!("App: https://github.com/apps/{slug}"))
);
// Return secrets as env pairs
let pem_b64 =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, pem.as_bytes());
let mut env_pairs = vec![
("GITHUB_APP_PRIVATE_KEY".to_string(), pem_b64),
("GITHUB_APP_CLIENT_SECRET".to_string(), client_secret),
];
if let Some(secret) = webhook_secret {
env_pairs.push(("GITHUB_APP_WEBHOOK_SECRET".to_string(), secret));
}
Ok(env_pairs)
}
fn write_env_file(arc_dir: &Path, env_pairs: &[(String, String)], s: &Styles) -> Result<()> {
let env_path = arc_dir.join(".env");
let existing = std::fs::read_to_string(&env_path).unwrap_or_default();
let refs: Vec<(&str, &str)> = env_pairs
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let merged = merge_env(&existing, &refs);
std::fs::write(&env_path, &merged)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600))?;
}
eprintln!(
" {}",
s.dim.apply_to(format!("Wrote {}", env_path.display()))
);
Ok(())
}
pub async fn run_install() -> Result<()> {
let s = Styles::detect_stderr();
let emoji = console::Emoji("⚒️ ", "");
eprintln!();
eprintln!(" {}{}", emoji, s.bold.apply_to("Fabro Install"));
eprintln!();
eprintln!(
" {}",
s.dim
.apply_to("Let's get Fabro set up. This will configure your")
);
eprintln!(" {}", s.dim.apply_to("LLM providers and GitHub App."));
eprintln!();
let arc_dir = dirs::home_dir()
.context("could not determine home directory")?
.join(".fabro");
std::fs::create_dir_all(&arc_dir)?;
// Pre-flight checks (server mode only — standalone doesn't need openssl/node/dot)
#[cfg(feature = "server")]
{
eprintln!(
" {}",
s.dim.apply_to("[Pre-flight] System dependency checks")
);
let dep_outcomes = doctor::probe_system_deps();
let dep_check = doctor::check_system_deps(doctor::DEP_SPECS, &dep_outcomes);
if dep_check.status == doctor::CheckStatus::Error {
eprintln!(" Missing required system dependencies:");
for detail in &dep_check.details {
eprintln!(" {}", detail.text);
}
bail!("Install missing required tools before running setup");
}
// Check if dot is missing and offer to install
let dot_idx = doctor::DEP_SPECS.iter().position(|s| s.name == "dot");
if let Some(idx) = dot_idx {
if matches!(dep_outcomes[idx], doctor::ProbeOutcome::NotFound) {
let install = tokio::task::spawn_blocking(|| {
prompt_confirm("Graphviz (dot) not found. Install via Homebrew?", true)
})
.await??;
if install {
let status = Command::new("brew")
.args(["install", "graphviz"])
.status()
.context("failed to run brew install graphviz")?;
if !status.success() {
eprintln!(" Warning: brew install graphviz failed");
}
}
}
}
for detail in &dep_check.details {
eprintln!(" {}", detail.text);
}
eprintln!();
}
// Step 1: LLM Providers
eprintln!(" {}", s.bold.apply_to("Step 1 · LLM Providers"));
eprintln!(" {}", s.dim.apply_to("──────────────────────"));
eprintln!();
let mut env_pairs: Vec<(String, String)> = Vec::new();
let mut configured_providers: Vec<Provider> = Vec::new();
let codex_detected = detect_binary_on_path("codex");
let mut openai_via_oauth = false;
if codex_detected {
tracing::debug!("Codex binary detected on PATH");
let use_oauth = tokio::task::spawn_blocking(|| {
prompt_confirm(
"OpenAI (Codex) detected. Set up OpenAI via browser login?",
true,
)
})
.await??;
if use_oauth {
eprintln!(
" {}",
s.dim.apply_to("Opening browser for OpenAI login...")
);
match fabro_openai_oauth::run_browser_flow(
fabro_openai_oauth::DEFAULT_ISSUER,
fabro_openai_oauth::DEFAULT_CLIENT_ID,
)
.await
{
Ok(tokens) => {
tracing::info!("OpenAI OAuth browser flow completed");
env_pairs.extend(openai_oauth_env_pairs(
&tokens.access_token,
&tokens.refresh_token,
));
configured_providers.push(Provider::OpenAi);
openai_via_oauth = true;
eprintln!(
" {} OpenAI configured via browser login",
s.green.apply_to("✔")
);
}
Err(e) => {
tracing::warn!(error = %e, "OpenAI OAuth browser flow failed");
eprintln!(" Browser login failed: {e}");
eprintln!(
" {}",
s.dim.apply_to("Falling back to manual API key entry.")
);
let (env_var, key) = prompt_and_validate_key(Provider::OpenAi, &s).await?;
env_pairs.push((env_var, key));
configured_providers.push(Provider::OpenAi);
openai_via_oauth = true;
}
}
}
}
if !openai_via_oauth {
// First provider — single choice from the top 3
let primary_providers = [Provider::Anthropic, Provider::OpenAi, Provider::Gemini];
let primary_labels: Vec<String> = primary_providers
.iter()
.map(|p| provider_display_name(*p).to_string())
.collect();
let primary_idx: usize = tokio::task::spawn_blocking({
let labels = primary_labels.clone();
move || prompt_select("Choose your first LLM provider", &labels)
})
.await??;
let first_provider = primary_providers[primary_idx];
{
let (env_var, key) = prompt_and_validate_key(first_provider, &s).await?;
env_pairs.push((env_var, key));
configured_providers.push(first_provider);
}
}
// Additional providers
eprintln!();
let add_more =
tokio::task::spawn_blocking(|| prompt_confirm("Set up additional LLM providers?", false))
.await??;
if add_more {
let remaining_labels: Vec<String> = Provider::ALL
.iter()
.filter(|p| !configured_providers.contains(p))
.map(|p| {
let env_vars = p.api_key_env_vars().join(" / ");
format!("{} ({})", provider_display_name(*p), env_vars)
})
.collect();
let remaining_providers: Vec<Provider> = Provider::ALL
.iter()
.filter(|p| !configured_providers.contains(p))
.copied()
.collect();
let selected_indices: Vec<usize> = tokio::task::spawn_blocking({
let labels = remaining_labels.clone();
move || prompt_multiselect("Which additional LLM providers?", &labels)
})
.await??;
for idx in selected_indices {
let provider = remaining_providers[idx];
let (env_var, key) = prompt_and_validate_key(provider, &s).await?;
env_pairs.push((env_var, key));
}
}
// Write LLM provider env vars immediately
if !env_pairs.is_empty() {
write_env_file(&arc_dir, &env_pairs, &s)?;
}
eprintln!();
// Step 2: GitHub App
eprintln!(" {}", s.bold.apply_to("Step 2 · GitHub App"));
eprintln!(" {}", s.dim.apply_to("───────────────────"));
eprintln!();
{
let setup_github = tokio::task::spawn_blocking(|| {
prompt_confirm("Set up a GitHub App? (Recommended)", true)
})
.await??;
if setup_github {
let github_env_pairs = setup_github_app(&arc_dir, &s).await?;
let slug = {
let cli_toml_path = arc_dir.join("cli.toml");
let toml_content = std::fs::read_to_string(&cli_toml_path).unwrap_or_default();
let doc: toml::Value =
toml::from_str(&toml_content).unwrap_or(toml::Value::Table(Default::default()));
doc.get("git")
.and_then(|g| g.get("slug"))
.and_then(|s| s.as_str())
.unwrap_or("unknown")
.to_string()
};
eprintln!(
" {} GitHub App registered ({})",
s.green.apply_to("✔"),
slug
);
// Merge GitHub env vars into .env
if !github_env_pairs.is_empty() {
write_env_file(&arc_dir, &github_env_pairs, &s)?;
}
} else {
eprintln!(" Skipped");
}
}
eprintln!();
// Server configuration (server mode only)
#[cfg(feature = "server")]
{
eprintln!(" {}", s.bold.apply_to("Server · Configuration"));
eprintln!(" {}", s.dim.apply_to("─────────────────────"));
eprintln!();
let config_path = arc_dir.join("server.toml");
let write_config = if config_path.exists() {
tokio::task::spawn_blocking(|| {
prompt_confirm("~/.fabro/server.toml already exists. Overwrite?", false)
})
.await??
} else {
true
};
if write_config {
let username: String =
tokio::task::spawn_blocking(|| prompt_input("GitHub username for allowed access"))
.await??;
let toml_content = format_config_toml(&username);
std::fs::write(&config_path, &toml_content)?;
eprintln!(
" {}",
s.dim.apply_to(format!("Wrote {}", config_path.display()))
);
} else {
eprintln!(" {}", s.dim.apply_to("Keeping existing server.toml"));
}
eprintln!();
}
// Secrets and certificates (server mode only)
#[cfg(feature = "server")]
{
eprintln!(
" {}",
s.dim.apply_to("Generating secrets and certificates...")
);
let session_secret = generate_session_secret();
eprintln!(" {} Session secret generated", s.green.apply_to("✔"));
let (jwt_private_pem, jwt_public_pem) = generate_jwt_keypair()?;
eprintln!(" {} Ed25519 JWT keypair generated", s.green.apply_to("✔"));
let certs_dir = arc_dir.join("certs");
generate_mtls_certs(&certs_dir)?;
eprintln!(
" {} mTLS CA + server certificates generated",
s.green.apply_to("✔")
);
let jwt_private_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
jwt_private_pem.as_bytes(),
);
let jwt_public_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
jwt_public_pem.as_bytes(),
);
let server_env_pairs = vec![
("FABRO_JWT_PRIVATE_KEY".to_string(), jwt_private_b64),
("FABRO_JWT_PUBLIC_KEY".to_string(), jwt_public_b64),
("SESSION_SECRET".to_string(), session_secret),
];
write_env_file(&arc_dir, &server_env_pairs, &s)?;
eprintln!();
eprintln!(" To start Arc, run these commands:");
eprintln!();
eprintln!(" fabro serve");
eprintln!(" cd apps/fabro-web && npx react-router dev");
eprintln!();
}
// Verify setup
let env_path = arc_dir.join(".env");
let run_doctor =
tokio::task::spawn_blocking(|| prompt_confirm("Run fabro doctor to verify?", true))
.await??;
if run_doctor {
// Reload .env so doctor sees the values we just wrote
let _ = dotenvy::from_path(&env_path);
eprintln!();
doctor::run_doctor(true, true).await;
}
eprintln!();
eprintln!(
" Setup complete! Go to your project and run {} to get started.",
s.bold_cyan.apply_to("fabro init")
);
Ok(())
}
// ---------------------------------------------------------------------------
// Hex encoding (server mode only — used by generate_session_secret)
// ---------------------------------------------------------------------------
#[cfg(feature = "server")]
mod hex {
pub fn encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// API key validation
// ---------------------------------------------------------------------------
async fn validate_api_key(provider: Provider, api_key: &str) -> Result<(), String> {
// Temporarily set the env var so Client::from_env() picks it up
let env_var = provider.api_key_env_vars()[0];
std::env::set_var(env_var, api_key);
let client = fabro_llm::client::Client::from_env()
.await
.map_err(|e| e.to_string())?;
let params = fabro_llm::generate::GenerateParams::new(doctor::cheapest_model(provider))
.provider(provider.as_str())
.prompt("Say OK")
.max_tokens(16)
.client(std::sync::Arc::new(client));
tokio::time::timeout(
std::time::Duration::from_secs(30),
fabro_llm::generate::generate(params),
)
.await
.map_err(|_| "timeout (30s)".to_string())?
.map(|_| ())
.map_err(|e| e.to_string())
}
async fn prompt_and_validate_key(provider: Provider, s: &Styles) -> Result<(String, String)> {
let env_var = provider.api_key_env_vars()[0];
let url = provider_key_url(provider);
eprintln!(
" {}",
s.dim.apply_to(format!("Get your API key at: {url}"))
);
loop {
let prompt = env_var.to_string();
let key: String = tokio::task::spawn_blocking(move || prompt_password(&prompt)).await??;
eprintln!(" {}", s.dim.apply_to("Validating API key..."));
match validate_api_key(provider, &key).await {
Ok(()) => {
eprintln!(" {} API key is valid", s.green.apply_to("✔"));
return Ok((env_var.to_string(), key));
}
Err(e) => {
eprintln!(" [error] API key validation failed: {e}");
let retry = tokio::task::spawn_blocking(|| {
prompt_confirm("Try again with a different key?", true)
})
.await??;
if !retry {
return Ok((env_var.to_string(), key));
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// -- Binary detection --
#[test]
fn detect_binary_finds_existing_command() {
assert!(detect_binary_on_path("git"));
}
#[test]
fn detect_binary_returns_false_for_nonexistent() {
assert!(!detect_binary_on_path("arc_nonexistent_xyz"));
}
// -- OpenAI OAuth env pairs --
#[test]
fn openai_oauth_env_pairs_sets_api_key() {
let pairs = openai_oauth_env_pairs("tok", "ref");
assert!(pairs.contains(&("OPENAI_API_KEY".to_string(), "tok".to_string())));
}
#[test]
fn openai_oauth_env_pairs_sets_refresh_token() {
let pairs = openai_oauth_env_pairs("tok", "ref");
assert!(pairs.contains(&("OPENAI_REFRESH_TOKEN".to_string(), "ref".to_string())));
}
#[test]
fn openai_oauth_env_pairs_count() {
let pairs = openai_oauth_env_pairs("tok", "ref");
assert_eq!(pairs.len(), 2);
}
// -- Session secret (server only) --
#[test]
#[cfg(feature = "server")]
fn session_secret_length() {
let secret = generate_session_secret();
assert_eq!(secret.len(), 64);
}
#[test]
#[cfg(feature = "server")]
fn session_secret_is_hex() {
let secret = generate_session_secret();
assert!(secret.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
#[cfg(feature = "server")]
fn session_secret_is_lowercase() {
let secret = generate_session_secret();
assert!(secret.chars().all(|c| !c.is_ascii_uppercase()));
}
// -- JWT keypair (server only) --
#[test]
#[cfg(feature = "server")]
fn jwt_keypair_private_pem_header() {
let (private, _) = generate_jwt_keypair().unwrap();
assert!(
private.starts_with("-----BEGIN PRIVATE KEY-----"),
"private PEM: {private}"
);
}
#[test]
#[cfg(feature = "server")]
fn jwt_keypair_public_pem_header() {
let (_, public) = generate_jwt_keypair().unwrap();
assert!(
public.starts_with("-----BEGIN PUBLIC KEY-----"),
"public PEM: {public}"
);
}
#[test]
#[cfg(feature = "server")]
fn jwt_keypair_public_parses() {
let (_, public) = generate_jwt_keypair().unwrap();
jsonwebtoken::DecodingKey::from_ed_pem(public.as_bytes()).expect("public key should parse");
}
// -- mTLS cert generation (server only) --
#[test]
#[cfg(feature = "server")]
fn mtls_certs_creates_files() {
let dir = tempfile::tempdir().unwrap();
let certs_dir = dir.path().join("certs");
generate_mtls_certs(&certs_dir).unwrap();
assert!(certs_dir.join("ca.key").exists());
assert!(certs_dir.join("ca.crt").exists());
assert!(certs_dir.join("server.key").exists());
assert!(certs_dir.join("server.crt").exists());
}
#[test]
#[cfg(feature = "server")]
fn mtls_ca_cert_is_pem() {
let dir = tempfile::tempdir().unwrap();
let certs_dir = dir.path().join("certs");
generate_mtls_certs(&certs_dir).unwrap();
let ca_crt = std::fs::read_to_string(certs_dir.join("ca.crt")).unwrap();
assert!(
ca_crt.starts_with("-----BEGIN CERTIFICATE-----"),
"ca.crt: {ca_crt}"
);
}
#[test]
#[cfg(feature = "server")]
fn mtls_server_cert_is_pem() {
let dir = tempfile::tempdir().unwrap();
let certs_dir = dir.path().join("certs");
generate_mtls_certs(&certs_dir).unwrap();
let server_crt = std::fs::read_to_string(certs_dir.join("server.crt")).unwrap();
assert!(
server_crt.starts_with("-----BEGIN CERTIFICATE-----"),
"server.crt: {server_crt}"
);
}
#[test]
#[cfg(feature = "server")]
fn mtls_certs_parse_via_rustls() {
let dir = tempfile::tempdir().unwrap();
let certs_dir = dir.path().join("certs");
generate_mtls_certs(&certs_dir).unwrap();
let ca_pem = std::fs::read(certs_dir.join("ca.crt")).unwrap();
let mut reader = std::io::Cursor::new(&ca_pem);
let ca_certs: Vec<_> = rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(ca_certs.len(), 1);
let server_pem = std::fs::read(certs_dir.join("server.crt")).unwrap();
let mut reader = std::io::Cursor::new(&server_pem);
let server_certs: Vec<_> = rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(server_certs.len(), 1);
}
// -- Config TOML generation (server only) --
#[test]
#[cfg(feature = "server")]
fn config_toml_roundtrips() {
let toml_str = format_config_toml("brynary");
let config: fabro_config::server::ServerConfig =
toml::from_str(&toml_str).expect("config should parse");
assert_eq!(config.web.auth.allowed_usernames, vec!["brynary"]);
}
#[test]
#[cfg(feature = "server")]
fn config_toml_has_auth_strategies() {
let toml_str = format_config_toml("alice");
let config: fabro_config::server::ServerConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(
config.api.authentication_strategies,
vec![
fabro_config::server::ApiAuthStrategy::Jwt,
fabro_config::server::ApiAuthStrategy::Mtls,
]
);
}
#[test]
#[cfg(feature = "server")]
fn config_toml_has_tls_paths() {
use std::path::PathBuf;
let toml_str = format_config_toml("bob");
let config: fabro_config::server::ServerConfig = toml::from_str(&toml_str).unwrap();
let tls = config.api.tls.expect("tls should be set");
assert_eq!(tls.cert, PathBuf::from("~/.fabro/certs/server.crt"));
assert_eq!(tls.key, PathBuf::from("~/.fabro/certs/server.key"));
assert_eq!(tls.ca, PathBuf::from("~/.fabro/certs/ca.crt"));
}
// -- .env merge --
#[test]
fn merge_env_replaces_existing() {
let result = merge_env("FOO=old\nBAR=keep\n", &[("FOO", "new"), ("BAZ", "added")]);
assert!(result.contains("FOO=new"));
assert!(result.contains("BAR=keep"));
assert!(result.contains("BAZ=added"));
}
#[test]
fn merge_env_empty_existing() {
let result = merge_env("", &[("FOO", "bar"), ("BAZ", "qux")]);
assert!(result.contains("FOO=bar"));
assert!(result.contains("BAZ=qux"));
}
#[test]
fn merge_env_preserves_comments_and_blanks() {
let existing = "# A comment\n\nFOO=old\n# Another\nBAR=keep\n";
let result = merge_env(existing, &[("FOO", "new")]);
assert!(result.contains("# A comment"));
assert!(result.contains("# Another"));
assert!(result.contains("FOO=new"));
assert!(result.contains("BAR=keep"));
}
#[test]
fn merge_env_full_scenario() {
let result = merge_env("FOO=old\nBAR=keep", &[("FOO", "new"), ("BAZ", "added")]);
assert_eq!(result, "FOO=new\nBAR=keep\nBAZ=added\n");
}
// -- Provider key URLs --
#[test]
fn every_provider_has_key_url() {
for provider in Provider::ALL {
let url = provider_key_url(*provider);
assert!(!url.is_empty(), "{provider:?} has empty URL");
assert!(url.starts_with("https://"), "{provider:?} URL: {url}");
}
}
// -- API key validation --
#[tokio::test]
async fn validate_api_key_rejects_invalid_key() {
let result = validate_api_key(Provider::Anthropic, "sk-invalid-key-12345").await;
assert!(result.is_err(), "expected invalid key to be rejected");
}
}