Add arc setup interactive setup wizard

Automates first-time setup: generates server.toml, Ed25519 JWT keypair,
mTLS CA+server certificates, session secret, and .env file with proper
permissions. Includes pre-flight system dependency checks shared with
doctor, LLM provider API key collection, and optional doctor verification.

Also renames config file from arc.toml to server.toml across the codebase,
and loads ~/.arc/.env before CWD .env for centralized secret management.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-03 22:24:09 -05:00
parent 7660cea15c
commit be72df55c8
7 changed files with 704 additions and 11 deletions

3
Cargo.lock generated
View file

@ -186,12 +186,14 @@ dependencies = [
"chrono",
"clap",
"daytona-sdk",
"dialoguer",
"dirs",
"dotenvy",
"futures",
"httpmock",
"jsonwebtoken",
"predicates",
"rand 0.8.5",
"regex",
"reqwest 0.12.28",
"rustls-pemfile",
@ -199,6 +201,7 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
"toml",
"tracing",
"tracing-appender",
"tracing-subscriber",

View file

@ -51,7 +51,7 @@ const GIT_DEFAULTS: GitConfig = {
client_id: null,
};
export const ARC_CONFIG_PATH = join(homedir(), ".arc", "arc.toml");
export const ARC_CONFIG_PATH = join(homedir(), ".arc", "server.toml");
function loadAppConfig(): AppConfig {
const configPath = ARC_CONFIG_PATH;

View file

@ -105,12 +105,12 @@ pub struct ServerConfig {
pub run_defaults: RunDefaults,
}
/// Load server config from `~/.arc/arc.toml`, returning defaults if the file doesn't exist.
/// Load server config from `~/.arc/server.toml`, returning defaults if the file doesn't exist.
pub fn load_server_config() -> anyhow::Result<ServerConfig> {
let Some(home) = dirs::home_dir() else {
return Ok(ServerConfig::default());
};
let path = home.join(".arc").join("arc.toml");
let path = home.join(".arc").join("server.toml");
match std::fs::read_to_string(&path) {
Ok(contents) => Ok(toml::from_str(&contents)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ServerConfig::default()),

View file

@ -34,6 +34,8 @@ jsonwebtoken.workspace = true
base64.workspace = true
rustls-pemfile = "2"
x509-parser = "0.16"
rand.workspace = true
dialoguer.workspace = true
[dev-dependencies]
assert_cmd = "2"
@ -41,3 +43,4 @@ predicates = "3"
tempfile = "3"
serde_json.workspace = true
httpmock = "0.8"
toml.workspace = true

View file

@ -175,7 +175,7 @@ fn parse_version(re: &Regex, output: &str) -> Option<Version> {
))
}
const DEP_SPECS: &[DepSpec] = &[
pub const DEP_SPECS: &[DepSpec] = &[
DepSpec { name: "openssl", command: &["openssl", "version"], required: true, min_version: Version::new(3, 0, 0), pattern: &OPENSSL_RE },
DepSpec { name: "node", command: &["node", "--version"], required: true, min_version: Version::new(20, 0, 0), pattern: &NODE_RE },
DepSpec { name: "gh", command: &["gh", "--version"], required: false, min_version: Version::new(2, 0, 0), pattern: &GH_RE },
@ -301,9 +301,9 @@ pub fn check_config(path: Option<PathBuf>) -> CheckResult {
status: CheckStatus::Warning,
summary: "no config file found".to_string(),
details: vec![CheckDetail {
text: "Create ~/.arc/arc.toml to configure Arc".to_string(),
text: "Create ~/.arc/server.toml to configure Arc".to_string(),
}],
remediation: Some("Create ~/.arc/arc.toml".to_string()),
remediation: Some("Create ~/.arc/server.toml".to_string()),
},
}
}
@ -571,7 +571,7 @@ pub fn check_github_app(status: &GithubAppStatus) -> CheckResult {
summary: "not configured".to_string(),
details,
remediation: Some(
"Configure GitHub App in arc.toml and set env vars to enable GitHub integration"
"Configure GitHub App in server.toml and set env vars to enable GitHub integration"
.to_string(),
),
}
@ -867,7 +867,7 @@ pub fn check_crypto(input: &CryptoInput) -> CheckResult {
text: "No authentication strategies or keys configured".to_string(),
}],
remediation: Some(
"Configure authentication_strategies in [api] section of arc.toml".to_string(),
"Configure authentication_strategies in [api] section of server.toml".to_string(),
),
};
}
@ -978,7 +978,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
let styles = Styles::detect_stdout();
// Gather state
let config_path = dirs::home_dir().map(|h| h.join(".arc").join("arc.toml"));
let config_path = dirs::home_dir().map(|h| h.join(".arc").join("server.toml"));
let config_exists = config_path
.as_ref()
.is_some_and(|p| p.exists());
@ -1298,9 +1298,9 @@ mod tests {
#[test]
fn check_config_pass_with_path() {
let result = check_config(Some(PathBuf::from("/home/user/.arc/arc.toml")));
let result = check_config(Some(PathBuf::from("/home/user/.arc/server.toml")));
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.summary.contains(".arc/arc.toml"));
assert!(result.summary.contains(".arc/server.toml"));
}
#[test]

View file

@ -1,5 +1,6 @@
mod doctor;
mod logging;
mod setup;
use anyhow::Result;
use clap::{Parser, Subcommand};
@ -53,6 +54,8 @@ enum Command {
#[arg(short, long)]
live: bool,
},
/// Interactive setup wizard for Arc
Setup,
}
#[derive(Subcommand)]
@ -77,6 +80,9 @@ enum LlmCommand {
async fn main() -> Result<()> {
let cli = Cli::parse();
if !cli.no_dotenv {
if let Some(home) = dirs::home_dir() {
let _ = dotenvy::from_path(home.join(".arc").join(".env"));
}
dotenvy::dotenv().ok();
}
@ -92,6 +98,7 @@ async fn main() -> Result<()> {
Command::Models { .. } => "models",
Command::Serve(_) => "serve",
Command::Doctor { .. } => "doctor",
Command::Setup => "setup",
};
debug!(command = %command_name, "CLI command started");
@ -130,6 +137,9 @@ async fn main() -> Result<()> {
let exit_code = doctor::run_doctor(verbose, live).await;
std::process::exit(exit_code);
}
Command::Setup => {
setup::run_setup().await?;
}
}
Ok(())

677
crates/arc-cli/src/setup.rs Normal file
View file

@ -0,0 +1,677 @@
use std::io::Write as _;
use std::path::Path;
use std::process::{Command, Stdio};
use anyhow::{bail, Context, Result};
use arc_llm::provider::Provider;
use dialoguer::{Confirm, Input, MultiSelect};
use rand::Rng;
use crate::doctor;
// ---------------------------------------------------------------------------
// Session secret
// ---------------------------------------------------------------------------
fn generate_session_secret() -> String {
let mut rng = rand::thread_rng();
let bytes: [u8; 32] = rng.gen();
hex::encode(&bytes)
}
// ---------------------------------------------------------------------------
// JWT keypair generation
// ---------------------------------------------------------------------------
fn generate_jwt_keypair() -> Result<(String, String)> {
let private_output = Command::new("openssl")
.args(["genpkey", "-algorithm", "Ed25519"])
.output()
.context("failed to run openssl genpkey")?;
if !private_output.status.success() {
bail!(
"openssl genpkey failed: {}",
String::from_utf8_lossy(&private_output.stderr)
);
}
let private_pem = private_output.stdout;
let mut child = Command::new("openssl")
.args(["pkey", "-pubout"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn openssl pkey")?;
child
.stdin
.take()
.unwrap()
.write_all(&private_pem)
.context("failed to write private key to openssl stdin")?;
let public_output = child
.wait_with_output()
.context("failed to read openssl pkey output")?;
if !public_output.status.success() {
bail!(
"openssl pkey -pubout failed: {}",
String::from_utf8_lossy(&public_output.stderr)
);
}
let private_pem_str =
String::from_utf8(private_pem).context("private key is not valid UTF-8")?;
let public_pem_str =
String::from_utf8(public_output.stdout).context("public key is not valid UTF-8")?;
Ok((private_pem_str, public_pem_str))
}
// ---------------------------------------------------------------------------
// mTLS certificate generation
// ---------------------------------------------------------------------------
fn generate_mtls_certs(dir: &Path) -> Result<()> {
std::fs::create_dir_all(dir).context("failed to create certs directory")?;
// 1. CA key
let ca_key_output = Command::new("openssl")
.args(["genpkey", "-algorithm", "Ed25519"])
.output()
.context("failed to generate CA key")?;
if !ca_key_output.status.success() {
bail!("openssl genpkey (CA) failed");
}
let ca_key_path = dir.join("ca.key");
std::fs::write(&ca_key_path, &ca_key_output.stdout)?;
// 2. CA self-signed cert
let ca_cert_output = Command::new("openssl")
.args([
"req", "-new", "-x509", "-key",
ca_key_path.to_str().unwrap(),
"-days", "3650",
"-subj", "/CN=Arc CA",
])
.output()
.context("failed to generate CA cert")?;
if !ca_cert_output.status.success() {
bail!(
"openssl req (CA cert) failed: {}",
String::from_utf8_lossy(&ca_cert_output.stderr)
);
}
let ca_cert_path = dir.join("ca.crt");
std::fs::write(&ca_cert_path, &ca_cert_output.stdout)?;
// 3. Server key
let server_key_output = Command::new("openssl")
.args(["genpkey", "-algorithm", "Ed25519"])
.output()
.context("failed to generate server key")?;
if !server_key_output.status.success() {
bail!("openssl genpkey (server) failed");
}
let server_key_path = dir.join("server.key");
std::fs::write(&server_key_path, &server_key_output.stdout)?;
// 4. Server CSR
let mut csr_child = Command::new("openssl")
.args(["req", "-new", "-key", "/dev/stdin", "-subj", "/CN=localhost"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn openssl req (CSR)")?;
csr_child
.stdin
.take()
.unwrap()
.write_all(&server_key_output.stdout)?;
let csr_output = csr_child.wait_with_output()?;
if !csr_output.status.success() {
bail!(
"openssl req (CSR) failed: {}",
String::from_utf8_lossy(&csr_output.stderr)
);
}
// 5. Sign server cert with CA
let csr_path = dir.join("server.csr");
std::fs::write(&csr_path, &csr_output.stdout)?;
let server_cert_output = Command::new("openssl")
.args([
"x509", "-req",
"-in", csr_path.to_str().unwrap(),
"-CA", ca_cert_path.to_str().unwrap(),
"-CAkey", ca_key_path.to_str().unwrap(),
"-CAcreateserial",
"-days", "3650",
])
.output()
.context("failed to sign server cert")?;
if !server_cert_output.status.success() {
bail!(
"openssl x509 (sign) failed: {}",
String::from_utf8_lossy(&server_cert_output.stderr)
);
}
let server_cert_path = dir.join("server.crt");
std::fs::write(&server_cert_path, &server_cert_output.stdout)?;
// Clean up CSR and serial file
let _ = std::fs::remove_file(&csr_path);
let _ = std::fs::remove_file(dir.join("ca.srl"));
Ok(())
}
// ---------------------------------------------------------------------------
// Config TOML generation
// ---------------------------------------------------------------------------
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 = "~/.arc/certs/server.crt"
key = "~/.arc/certs/server.key"
ca = "~/.arc/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",
}
}
// ---------------------------------------------------------------------------
// 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())?)
}
fn prompt_input(prompt: &str) -> Result<String> {
Ok(Input::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.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())?,
)
}
pub async fn run_setup() -> Result<()> {
eprintln!("Arc Setup");
eprintln!("=========");
eprintln!();
let arc_dir = dirs::home_dir()
.context("could not determine home directory")?
.join(".arc");
std::fs::create_dir_all(&arc_dir)?;
// Step 0: Pre-flight checks
eprintln!("[Step 0/7] Pre-flight 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: Configuration
eprintln!("[Step 1/7] Configuration");
let config_path = arc_dir.join("server.toml");
let write_config = if config_path.exists() {
tokio::task::spawn_blocking(|| {
prompt_confirm("~/.arc/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!(" Wrote {}", config_path.display());
} else {
eprintln!(" Keeping existing server.toml");
}
eprintln!();
// Step 2: Generating secrets and certificates
eprintln!("[Step 2/7] Generating secrets and certificates");
let session_secret = generate_session_secret();
eprintln!(" [ok] Session secret generated");
let (jwt_private_pem, jwt_public_pem) = generate_jwt_keypair()?;
eprintln!(" [ok] Ed25519 JWT keypair generated");
let certs_dir = arc_dir.join("certs");
generate_mtls_certs(&certs_dir)?;
eprintln!(" [ok] mTLS CA + server certificates generated");
eprintln!();
// Step 3: LLM providers
eprintln!("[Step 3/7] LLM providers");
let provider_labels: Vec<String> = Provider::ALL
.iter()
.map(|p| {
let env_vars = p.api_key_env_vars().join(" / ");
format!("{} ({})", provider_display_name(*p), env_vars)
})
.collect();
let selected_indices: Vec<usize> = tokio::task::spawn_blocking({
let labels = provider_labels.clone();
move || prompt_multiselect("Which LLM providers do you want to configure?", &labels)
})
.await??;
let mut api_keys: Vec<(Provider, String, String)> = Vec::new();
for idx in selected_indices {
let provider = Provider::ALL[idx];
let env_var = provider.api_key_env_vars()[0];
let url = provider_key_url(provider);
eprintln!(" Get your API key at: {url}");
let prompt = env_var.to_string();
let key: String =
tokio::task::spawn_blocking(move || prompt_input(&prompt)).await??;
api_keys.push((provider, env_var.to_string(), key));
}
eprintln!();
// Step 4: Writing ~/.arc/.env
eprintln!("[Step 4/7] Writing ~/.arc/.env");
let env_path = arc_dir.join(".env");
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 api_key_entries: Vec<(String, String)> = api_keys
.iter()
.map(|(_, env_var, key)| (env_var.clone(), key.clone()))
.collect();
let mut env_pairs: Vec<(String, String)> = api_key_entries;
env_pairs.push(("ARC_JWT_PRIVATE_KEY".to_string(), jwt_private_b64));
env_pairs.push(("ARC_JWT_PUBLIC_KEY".to_string(), jwt_public_b64));
env_pairs.push(("SESSION_SECRET".to_string(), session_secret));
let existing_env = std::fs::read_to_string(&env_path).unwrap_or_default();
let env_refs: Vec<(&str, &str)> = env_pairs
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let merged = merge_env(&existing_env, &env_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!(
" Wrote {} ({} variables)",
env_path.display(),
env_pairs.len()
);
eprintln!();
// Step 5: Start servers
eprintln!("[Step 5/7] Start servers");
eprintln!(" To start Arc, run these commands:");
eprintln!();
eprintln!(" arc serve");
eprintln!(" cd apps/arc-web && npx react-router dev");
eprintln!();
// Step 6: Verify setup
eprintln!("[Step 6/7] Verify setup");
let run_doctor =
tokio::task::spawn_blocking(|| prompt_confirm("Run arc doctor to verify?", true))
.await??;
if run_doctor {
eprintln!();
doctor::run_doctor(true, false).await;
}
eprintln!();
eprintln!("Setup complete!");
Ok(())
}
// ---------------------------------------------------------------------------
// Hex encoding (avoid adding a dep just for this)
// ---------------------------------------------------------------------------
mod hex {
pub fn encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
// -- Session secret --
#[test]
fn session_secret_length() {
let secret = generate_session_secret();
assert_eq!(secret.len(), 64);
}
#[test]
fn session_secret_is_hex() {
let secret = generate_session_secret();
assert!(secret.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn session_secret_is_lowercase() {
let secret = generate_session_secret();
assert!(secret.chars().all(|c| !c.is_ascii_uppercase()));
}
// -- JWT keypair --
#[test]
fn jwt_keypair_private_pem_header() {
let (private, _) = generate_jwt_keypair().unwrap();
assert!(
private.starts_with("-----BEGIN PRIVATE KEY-----"),
"private PEM: {private}"
);
}
#[test]
fn jwt_keypair_public_pem_header() {
let (_, public) = generate_jwt_keypair().unwrap();
assert!(
public.starts_with("-----BEGIN PUBLIC KEY-----"),
"public PEM: {public}"
);
}
#[test]
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 --
#[test]
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]
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]
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]
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 --
#[test]
fn config_toml_roundtrips() {
let toml_str = format_config_toml("brynary");
let config: arc_api::server_config::ServerConfig =
toml::from_str(&toml_str).expect("config should parse");
assert_eq!(config.web.auth.allowed_usernames, vec!["brynary"]);
}
#[test]
fn config_toml_has_auth_strategies() {
let toml_str = format_config_toml("alice");
let config: arc_api::server_config::ServerConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(
config.api.authentication_strategies,
vec![
arc_api::server_config::ApiAuthStrategy::Jwt,
arc_api::server_config::ApiAuthStrategy::Mtls,
]
);
}
#[test]
fn config_toml_has_tls_paths() {
let toml_str = format_config_toml("bob");
let config: arc_api::server_config::ServerConfig = toml::from_str(&toml_str).unwrap();
let tls = config.api.tls.expect("tls should be set");
assert_eq!(tls.cert, PathBuf::from("~/.arc/certs/server.crt"));
assert_eq!(tls.key, PathBuf::from("~/.arc/certs/server.key"));
assert_eq!(tls.ca, PathBuf::from("~/.arc/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}");
}
}
}