mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-05 08:10:39 +00:00
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:
parent
7660cea15c
commit
be72df55c8
7 changed files with 704 additions and 11 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
677
crates/arc-cli/src/setup.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue