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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-10 14:36:13 -04:00
parent d80335e0fc
commit 4ac4fc4ea9
5 changed files with 430 additions and 126 deletions

56
Cargo.lock generated
View file

@ -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]]

View file

@ -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"

View file

@ -32,8 +32,6 @@ pub enum ProbeOutcome {
static OPENSSL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?:OpenSSL|LibreSSL)\s+(\d+)\.(\d+)\.(\d+)").unwrap());
static NODE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"v(\d+)\.(\d+)\.(\d+)").unwrap());
static GH_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"gh version (\d+)\.(\d+)\.(\d+)").unwrap());
static DOT_RE: LazyLock<Regex> =
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);
}

View file

@ -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<Vec<u8>> {
let output = Command::new("openssl")
.args(args)
@ -29,6 +38,7 @@ fn run_openssl(args: &[&str], description: &str) -> Result<Vec<u8>> {
}
/// 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)
@ -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<Vec<usize>> {
)
}
// ---------------------------------------------------------------------------
// 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<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(
"<!DOCTYPE html><html><body><p>GitHub App created! You can close this tab.</p></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!(" 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<String> = 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");

View file

@ -40,6 +40,7 @@ pub struct ExecDefaults {
pub struct CliGitConfig {
pub app_id: Option<String>,
pub slug: Option<String>,
pub client_id: Option<String>,
#[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#"