From 4ac4fc4ea9620c8e5fb953dd806a9ef9cd44e12c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 10 Mar 2026 14:36:13 -0400 Subject: [PATCH] Add GitHub App manifest setup to arc install Adds a temporary local HTTP server in `arc install` that handles the GitHub App manifest registration flow, enabling standalone users to register a GitHub App without running the arc-web server. Also gates server-only install steps (pre-flight checks, server.toml, secrets/certs, start servers) behind #[cfg(feature = "server")] and removes the `gh` CLI from system dependency checks. Reloads .env before running arc doctor so it picks up freshly written credentials. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 56 +++- lib/crates/arc-cli/Cargo.toml | 3 + lib/crates/arc-cli/src/doctor.rs | 21 -- lib/crates/arc-cli/src/install.rs | 457 +++++++++++++++++++++++------- lib/crates/arc-config/src/cli.rs | 19 ++ 5 files changed, 430 insertions(+), 126 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 203dc2e21..5cd4b8875 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,7 @@ dependencies = [ "arc-util", "arc-workflows", "assert_cmd", + "axum", "base64", "bollard", "chrono", @@ -205,6 +206,7 @@ dependencies = [ "futures", "httpmock", "jsonwebtoken", + "open", "predicates", "rand 0.8.5", "regex", @@ -902,7 +904,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1369,7 +1371,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1456,7 +1458,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2321,6 +2323,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2806,7 +2827,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2932,6 +2953,17 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openapiv3" version = "2.2.0" @@ -3069,6 +3101,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pem" version = "3.0.6" @@ -3365,7 +3403,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3754,7 +3792,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3822,7 +3860,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4632,7 +4670,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5511,7 +5549,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/lib/crates/arc-cli/Cargo.toml b/lib/crates/arc-cli/Cargo.toml index fb12c34a7..b64c5a51a 100644 --- a/lib/crates/arc-cli/Cargo.toml +++ b/lib/crates/arc-cli/Cargo.toml @@ -48,6 +48,9 @@ rustls-pemfile = "2" x509-parser = "0.16" rand.workspace = true dialoguer.workspace = true +axum = "0.8" +open = "5" +serde_json.workspace = true [dev-dependencies] assert_cmd = "2" diff --git a/lib/crates/arc-cli/src/doctor.rs b/lib/crates/arc-cli/src/doctor.rs index 7999d907f..1902accde 100644 --- a/lib/crates/arc-cli/src/doctor.rs +++ b/lib/crates/arc-cli/src/doctor.rs @@ -32,8 +32,6 @@ pub enum ProbeOutcome { static OPENSSL_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?:OpenSSL|LibreSSL)\s+(\d+)\.(\d+)\.(\d+)").unwrap()); static NODE_RE: LazyLock = LazyLock::new(|| Regex::new(r"v(\d+)\.(\d+)\.(\d+)").unwrap()); -static GH_RE: LazyLock = - LazyLock::new(|| Regex::new(r"gh version (\d+)\.(\d+)\.(\d+)").unwrap()); static DOT_RE: LazyLock = LazyLock::new(|| Regex::new(r"graphviz version (\d+)\.(\d+)\.(\d+)").unwrap()); @@ -61,13 +59,6 @@ pub const DEP_SPECS: &[DepSpec] = &[ 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, - }, DepSpec { name: "dot", command: &["dot", "-V"], @@ -1641,17 +1632,6 @@ mod tests { ); } - #[test] - fn parse_version_gh() { - assert_eq!( - parse_version( - &GH_RE, - "gh version 2.67.0 (2025-01-31)\nhttps://github.com/cli/cli/releases/tag/v2.67.0" - ), - Some(Version::new(2, 67, 0)), - ); - } - #[test] fn parse_version_dot() { assert_eq!( @@ -1664,7 +1644,6 @@ mod tests { fn parse_version_garbage_returns_none() { assert_eq!(parse_version(&OPENSSL_RE, "not a version"), None); assert_eq!(parse_version(&NODE_RE, "node not found"), None); - assert_eq!(parse_version(&GH_RE, "something else"), None); assert_eq!(parse_version(&DOT_RE, "no version here"), None); } diff --git a/lib/crates/arc-cli/src/install.rs b/lib/crates/arc-cli/src/install.rs index 4e8ad4991..456df0564 100644 --- a/lib/crates/arc-cli/src/install.rs +++ b/lib/crates/arc-cli/src/install.rs @@ -1,19 +1,28 @@ +#[cfg(feature = "server")] use std::io::Write as _; +use std::net::SocketAddr; use std::path::Path; +#[cfg(feature = "server")] use std::process::{Command, Stdio}; use anyhow::{bail, Context, Result}; use arc_llm::provider::Provider; +use axum::extract::Query; +use axum::response::Html; +use axum::routing::get; use dialoguer::{Confirm, Input, MultiSelect}; use rand::Rng; +use tokio::net::TcpListener; +use tokio::sync::oneshot; use crate::doctor; // --------------------------------------------------------------------------- -// OpenSSL helpers +// 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> { let output = Command::new("openssl") .args(args) @@ -29,6 +38,7 @@ fn run_openssl(args: &[&str], description: &str) -> Result> { } /// 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> { let mut child = Command::new("openssl") .args(args) @@ -56,9 +66,10 @@ fn run_openssl_with_stdin(args: &[&str], stdin_data: &[u8], description: &str) - } // --------------------------------------------------------------------------- -// Session secret +// 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(); @@ -66,9 +77,10 @@ fn generate_session_secret() -> String { } // --------------------------------------------------------------------------- -// JWT keypair generation +// 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 = @@ -80,9 +92,10 @@ fn generate_jwt_keypair() -> Result<(String, String)> { } // --------------------------------------------------------------------------- -// mTLS certificate generation +// 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")?; @@ -161,9 +174,10 @@ fn generate_mtls_certs(dir: &Path) -> Result<()> { } // --------------------------------------------------------------------------- -// Config TOML generation +// Config TOML generation (server mode only) // --------------------------------------------------------------------------- +#[cfg(feature = "server")] fn format_config_toml(username: &str) -> String { format!( r#"[web] @@ -280,6 +294,201 @@ fn prompt_multiselect(prompt: &str, items: &[String]) -> Result> { ) } +// --------------------------------------------------------------------------- +// 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) -> Result> { + // Random suffix so app names don't collide + let mut rng = rand::thread_rng(); + let suffix: String = (0..6) + .map(|_| format!("{:x}", rng.gen::() % 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('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'"); + + // Channel to receive the code from the callback + let (code_tx, code_rx) = oneshot::channel::(); + // 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#" + + +

Redirecting to GitHub...

+
+ +
+ + +"# + ); + + let app = axum::Router::new() + .route( + "/", + get(move || async move { Html(index_html.clone()) }), + ) + .route( + "/callback", + get(move |Query(params): Query| 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( + "

GitHub App created! You can close this tab.

".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!(" Opening browser to {url}"); + if let Err(e) = open::that(&url) { + eprintln!(" Could not open browser automatically: {e}"); + eprintln!(" Please open this URL manually: {url}"); + } + + eprintln!(" Waiting for GitHub... (press 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!(" 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", "arc-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!(" Wrote GitHub App config to {}", cli_toml_path.display()); + eprintln!(" 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) +} + pub async fn run_install() -> Result<()> { eprintln!("Arc Install"); eprintln!("==========="); @@ -290,86 +499,120 @@ pub async fn run_install() -> Result<()> { .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); + // Pre-flight checks (server mode only — standalone doesn't need openssl/node/dot) + #[cfg(feature = "server")] + { + eprintln!("[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); + 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"); } - 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??; + // 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"); + 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!(); } - 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) + // Step 1: GitHub App setup + eprintln!("[Step 1/4] GitHub App"); + let mut github_env_pairs: Vec<(String, String)> = Vec::new(); + { + let setup_github = tokio::task::spawn_blocking(|| { + prompt_confirm( + "Set up a GitHub App? (creates a new GitHub App for PR creation and repo access)", + true, + ) }) - .await?? - } else { - true - }; + .await??; - 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"); + if setup_github { + github_env_pairs = setup_github_app(&arc_dir).await?; + eprintln!(" [ok] GitHub App registered"); + } else { + eprintln!(" Skipped"); + } } eprintln!(); - // Step 2: Generating secrets and certificates - eprintln!("[Step 2/7] Generating secrets and certificates"); + // Server configuration (server mode only) + #[cfg(feature = "server")] + { + eprintln!("[Server] 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 + }; - let session_secret = generate_session_secret(); - eprintln!(" [ok] Session secret generated"); + if write_config { + let username: String = + tokio::task::spawn_blocking(|| prompt_input("GitHub username for allowed access")) + .await??; - let (jwt_private_pem, jwt_public_pem) = generate_jwt_keypair()?; - eprintln!(" [ok] Ed25519 JWT keypair generated"); + 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!(); + } - let certs_dir = arc_dir.join("certs"); - generate_mtls_certs(&certs_dir)?; - eprintln!(" [ok] mTLS CA + server certificates generated"); - eprintln!(); + // Secrets and certificates (server mode only) + #[cfg(feature = "server")] + let (session_secret, jwt_private_pem, jwt_public_pem); + #[cfg(feature = "server")] + { + eprintln!("[Server] Generating secrets and certificates"); - // Step 3: LLM providers - eprintln!("[Step 3/7] LLM providers"); + session_secret = generate_session_secret(); + eprintln!(" [ok] Session secret generated"); + + let (priv_pem, pub_pem) = generate_jwt_keypair()?; + jwt_private_pem = priv_pem; + jwt_public_pem = pub_pem; + 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 2: LLM providers + eprintln!("[Step 2/4] LLM providers"); let provider_labels: Vec = Provider::ALL .iter() .map(|p| { @@ -398,22 +641,26 @@ pub async fn run_install() -> Result<()> { } eprintln!(); - // Step 4: Writing ~/.arc/.env - eprintln!("[Step 4/7] Writing ~/.arc/.env"); + // Step 3: Writing ~/.arc/.env + eprintln!("[Step 3/4] 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(), - ); + #[cfg(feature = "server")] + { + 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(), + ); - 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)); + 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)); + } + env_pairs.extend(github_env_pairs); let existing_env = std::fs::read_to_string(&env_path).unwrap_or_default(); let env_refs: Vec<(&str, &str)> = env_pairs @@ -436,20 +683,25 @@ pub async fn run_install() -> Result<()> { ); 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!(); + // Start servers (server mode only) + #[cfg(feature = "server")] + { + eprintln!("[Server] 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"); + // Step 4: Verify setup + eprintln!("[Step 4/4] Verify setup"); let run_doctor = tokio::task::spawn_blocking(|| prompt_confirm("Run arc 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; } @@ -460,9 +712,10 @@ pub async fn run_install() -> Result<()> { } // --------------------------------------------------------------------------- -// Hex encoding (avoid adding a dep just for this) +// 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() @@ -475,33 +728,35 @@ mod hex { #[cfg(test)] mod tests { - use std::path::PathBuf; - use super::*; - // -- Session secret -- + // -- 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 -- + // -- JWT keypair (server only) -- #[test] + #[cfg(feature = "server")] fn jwt_keypair_private_pem_header() { let (private, _) = generate_jwt_keypair().unwrap(); assert!( @@ -511,6 +766,7 @@ mod tests { } #[test] + #[cfg(feature = "server")] fn jwt_keypair_public_pem_header() { let (_, public) = generate_jwt_keypair().unwrap(); assert!( @@ -520,14 +776,16 @@ mod tests { } #[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 -- + // -- 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"); @@ -540,6 +798,7 @@ mod tests { } #[test] + #[cfg(feature = "server")] fn mtls_ca_cert_is_pem() { let dir = tempfile::tempdir().unwrap(); let certs_dir = dir.path().join("certs"); @@ -553,6 +812,7 @@ mod tests { } #[test] + #[cfg(feature = "server")] fn mtls_server_cert_is_pem() { let dir = tempfile::tempdir().unwrap(); let certs_dir = dir.path().join("certs"); @@ -566,6 +826,7 @@ mod tests { } #[test] + #[cfg(feature = "server")] fn mtls_certs_parse_via_rustls() { let dir = tempfile::tempdir().unwrap(); let certs_dir = dir.path().join("certs"); @@ -586,9 +847,10 @@ mod tests { assert_eq!(server_certs.len(), 1); } - // -- Config TOML generation -- + // -- Config TOML generation (server only) -- #[test] + #[cfg(feature = "server")] fn config_toml_roundtrips() { let toml_str = format_config_toml("brynary"); let config: arc_config::server::ServerConfig = @@ -597,6 +859,7 @@ mod tests { } #[test] + #[cfg(feature = "server")] fn config_toml_has_auth_strategies() { let toml_str = format_config_toml("alice"); let config: arc_config::server::ServerConfig = toml::from_str(&toml_str).unwrap(); @@ -610,7 +873,9 @@ mod tests { } #[test] + #[cfg(feature = "server")] fn config_toml_has_tls_paths() { + use std::path::PathBuf; let toml_str = format_config_toml("bob"); let config: arc_config::server::ServerConfig = toml::from_str(&toml_str).unwrap(); let tls = config.api.tls.expect("tls should be set"); diff --git a/lib/crates/arc-config/src/cli.rs b/lib/crates/arc-config/src/cli.rs index 76df49869..764a42957 100644 --- a/lib/crates/arc-config/src/cli.rs +++ b/lib/crates/arc-config/src/cli.rs @@ -40,6 +40,7 @@ pub struct ExecDefaults { pub struct CliGitConfig { pub app_id: Option, pub slug: Option, + pub client_id: Option, #[serde(default)] pub author: crate::server::GitAuthorConfig, } @@ -90,6 +91,10 @@ impl CliConfig { self.git.as_ref().and_then(|g| g.slug.as_deref()) } + pub fn client_id(&self) -> Option<&str> { + self.git.as_ref().and_then(|g| g.client_id.as_deref()) + } + pub fn git_author(&self) -> Option<&crate::server::GitAuthorConfig> { self.git.as_ref().map(|g| &g.author) } @@ -311,6 +316,20 @@ email = "arc@test.com" assert_eq!(git.author.name.as_deref(), Some("arc-bot")); } + #[test] + fn parse_git_config_with_client_id() { + let toml = r#" +[git] +app_id = "12345" +slug = "my-app" +client_id = "Iv1.abc123" +"#; + let config: CliConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.client_id(), Some("Iv1.abc123")); + let git = config.git.unwrap(); + assert_eq!(git.client_id.as_deref(), Some("Iv1.abc123")); + } + #[test] fn parse_llm_with_provider_and_fallbacks() { let toml = r#"