diff --git a/Cargo.lock b/Cargo.lock index 19754876c..a83ec13d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1621,6 +1621,7 @@ dependencies = [ "progenitor-client", "rand 0.8.5", "regex", + "ring", "rustls", "rustls-pemfile", "scopeguard", diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml index 6f6de98b0..edeb11cba 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/crates/fabro-cli/Cargo.toml @@ -70,6 +70,7 @@ base64.workspace = true ulid.workspace = true scopeguard = "1" rustls = { version = "0.23", default-features = false, features = ["std", "ring"] } +ring = "0.17" rustls-pemfile = "2" x509-parser = "0.16" rand.workspace = true diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index f51a56311..e07f2d957 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -1297,6 +1297,9 @@ pub(crate) struct InstallNonInteractiveArgs { #[arg(long, hide = true)] pub(crate) github_strategy: Option, + #[arg(long, hide = true)] + pub(crate) github_owner: Option, + #[arg(long, hide = true)] pub(crate) github_username: Option, diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs index f20cc1f0c..f62ed7704 100644 --- a/lib/crates/fabro-cli/src/commands/doctor.rs +++ b/lib/crates/fabro-cli/src/commands/doctor.rs @@ -38,27 +38,16 @@ pub(crate) enum ProbeOutcome { Ok { version: Option }, } -static OPENSSL_RE: LazyLock = - LazyLock::new(|| Regex::new(r"(?:OpenSSL|LibreSSL)\s+(\d+)\.(\d+)\.(\d+)").unwrap()); static DOT_RE: LazyLock = LazyLock::new(|| Regex::new(r"graphviz version (\d+)\.(\d+)\.(\d+)").unwrap()); -pub(crate) const DEP_SPECS: &[DepSpec] = &[ - DepSpec { - name: "openssl", - command: &["openssl", "version"], - required: true, - min_version: Version::new(3, 0, 0), - pattern: &OPENSSL_RE, - }, - DepSpec { - name: "dot", - command: &["dot", "-V"], - required: false, - min_version: Version::new(2, 0, 0), - pattern: &DOT_RE, - }, -]; +pub(crate) const DEP_SPECS: &[DepSpec] = &[DepSpec { + name: "dot", + command: &["dot", "-V"], + required: false, + min_version: Version::new(2, 0, 0), + pattern: &DOT_RE, +}]; fn parse_version(re: &Regex, output: &str) -> Option { let caps = re.captures(output)?; @@ -672,14 +661,6 @@ mod tests { assert_eq!(result.status, CheckStatus::Warning); } - #[test] - fn parse_version_openssl() { - assert_eq!( - parse_version(&OPENSSL_RE, "OpenSSL 3.4.1 11 Feb 2025"), - Some(Version::new(3, 4, 1)) - ); - } - #[test] fn parse_version_dot() { assert_eq!( @@ -690,7 +671,6 @@ mod tests { #[test] fn parse_version_garbage_returns_none() { - assert_eq!(parse_version(&OPENSSL_RE, "not a version"), None); assert_eq!(parse_version(&DOT_RE, "no version here"), None); } @@ -733,18 +713,10 @@ mod tests { #[test] fn check_system_deps_all_present() { - let specs = [ - spec("openssl", true, Version::new(3, 0, 0)), - spec("dot", false, Version::new(2, 0, 0)), - ]; - let outcomes = [ - ProbeOutcome::Ok { - version: Some(Version::new(3, 4, 1)), - }, - ProbeOutcome::Ok { - version: Some(Version::new(12, 2, 1)), - }, - ]; + let specs = [spec("dot", false, Version::new(2, 0, 0))]; + let outcomes = [ProbeOutcome::Ok { + version: Some(Version::new(12, 2, 1)), + }]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Pass); assert_eq!(result.summary, "all found"); @@ -752,7 +724,7 @@ mod tests { #[test] fn check_system_deps_required_missing_is_error() { - let specs = [spec("openssl", true, Version::new(3, 0, 0))]; + let specs = [spec("required-tool", true, Version::new(3, 0, 0))]; let outcomes = [ProbeOutcome::NotFound]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Error); @@ -768,7 +740,7 @@ mod tests { #[test] fn check_system_deps_outdated_is_warning() { - let specs = [spec("openssl", true, Version::new(3, 0, 0))]; + let specs = [spec("required-tool", true, Version::new(3, 0, 0))]; let outcomes = [ProbeOutcome::Ok { version: Some(Version::new(1, 1, 1)), }]; @@ -778,7 +750,7 @@ mod tests { #[test] fn check_system_deps_unparseable_success_is_pass() { - let specs = [spec("openssl", true, Version::new(3, 0, 0))]; + let specs = [spec("required-tool", true, Version::new(3, 0, 0))]; let outcomes = [ProbeOutcome::Ok { version: None }]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Pass); @@ -787,7 +759,7 @@ mod tests { #[test] fn check_system_deps_required_command_failed_is_error() { - let specs = [spec("openssl", true, Version::new(3, 0, 0))]; + let specs = [spec("required-tool", true, Version::new(3, 0, 0))]; let outcomes = [ProbeOutcome::Failed]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Error); @@ -804,7 +776,7 @@ mod tests { #[test] fn check_system_deps_error_beats_warning() { let specs = [ - spec("openssl", true, Version::new(3, 0, 0)), + spec("required-tool", true, Version::new(3, 0, 0)), spec("dot", false, Version::new(2, 0, 0)), ]; let outcomes = [ProbeOutcome::NotFound, ProbeOutcome::NotFound]; diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index f12478e96..2e0116f70 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -23,7 +23,8 @@ use fabro_util::terminal::Styles; use fabro_util::{dev_token, session_secret}; use futures::future::BoxFuture; use rand::Rng; -use tokio::io::AsyncWriteExt; +use ring::rand::SystemRandom; +use ring::signature::{Ed25519KeyPair, KeyPair as _}; use tokio::net::TcpListener; use tokio::process::Command as TokioCommand; use tokio::sync::oneshot; @@ -42,74 +43,53 @@ use crate::shared::provider_auth::{ }; use crate::{server_client, user_config}; -// --------------------------------------------------------------------------- -// OpenSSL helpers -// --------------------------------------------------------------------------- - -/// Run an openssl subcommand and return stdout on success. -async fn run_openssl(args: &[&str], description: &str) -> Result> { - let output = TokioCommand::new("openssl") - .args(args) - .output() - .await - .with_context(|| format!("failed to run openssl for: {description}"))?; - if !output.status.success() { - bail!( - "openssl {description} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - Ok(output.stdout) -} - -/// Run an openssl subcommand that reads key material from stdin. -async fn run_openssl_with_stdin( - args: &[&str], - stdin_data: &[u8], - description: &str, -) -> Result> { - let mut child = TokioCommand::new("openssl") - .args(args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .with_context(|| format!("failed to spawn openssl for: {description}"))?; - let mut stdin = child - .stdin - .take() - .context("openssl process missing stdin")?; - stdin - .write_all(stdin_data) - .await - .with_context(|| format!("failed to write to openssl stdin for: {description}"))?; - drop(stdin); - let output = child - .wait_with_output() - .await - .with_context(|| format!("failed to read openssl output for: {description}"))?; - if !output.status.success() { - bail!( - "openssl {description} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - Ok(output.stdout) -} - // --------------------------------------------------------------------------- // JWT keypair generation // --------------------------------------------------------------------------- -async fn generate_jwt_keypair() -> Result<(String, String)> { - let private_pem = - run_openssl(&["genpkey", "-algorithm", "Ed25519"], "generate keypair").await?; - let public_pem = - run_openssl_with_stdin(&["pkey", "-pubout"], &private_pem, "extract public key").await?; +const ED25519_SPKI_PREFIX: [u8; 12] = [ + 0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65, 0x70, 0x03, 0x21, 0x00, +]; +const ED25519_PUBLIC_KEY_LEN: usize = 32; - let private_str = String::from_utf8(private_pem).context("private key is not valid UTF-8")?; - let public_str = String::from_utf8(public_pem).context("public key is not valid UTF-8")?; - Ok((private_str, public_str)) +fn pem_encode(label: &str, bytes: &[u8]) -> String { + let body = BASE64_STANDARD.encode(bytes); + let mut pem = String::new(); + pem.push_str("-----BEGIN "); + pem.push_str(label); + pem.push_str("-----\n"); + for chunk in body.as_bytes().chunks(64) { + pem.push_str(std::str::from_utf8(chunk).expect("base64 output should be valid UTF-8")); + pem.push('\n'); + } + pem.push_str("-----END "); + pem.push_str(label); + pem.push_str("-----\n"); + pem +} + +fn ed25519_public_key_spki(public_key: &[u8]) -> Result> { + if public_key.len() != ED25519_PUBLIC_KEY_LEN { + bail!("generated Ed25519 public key had unexpected length"); + } + + let mut spki = Vec::with_capacity(ED25519_SPKI_PREFIX.len() + public_key.len()); + spki.extend_from_slice(&ED25519_SPKI_PREFIX); + spki.extend_from_slice(public_key); + Ok(spki) +} + +fn generate_jwt_keypair() -> Result<(String, String)> { + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()) + .map_err(|_| anyhow!("failed to generate Ed25519 keypair"))?; + let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()) + .map_err(|_| anyhow!("failed to parse generated Ed25519 keypair"))?; + let public_der = ed25519_public_key_spki(keypair.public_key().as_ref())?; + + Ok(( + pem_encode("PRIVATE KEY", pkcs8.as_ref()), + pem_encode("PUBLIC KEY", &public_der), + )) } // --------------------------------------------------------------------------- @@ -202,7 +182,36 @@ fn write_github_app_settings( app_id: &str, slug: &str, client_id: &str, + allowed_usernames: &[String], ) -> Result<()> { + anyhow::ensure!( + !allowed_usernames.is_empty(), + "GitHub App install requires at least one allowed GitHub username" + ); + + let root = root_table_mut(doc)?; + let server = ensure_table(root, "server")?; + let auth = ensure_table(server, "auth")?; + let methods = auth + .entry("methods".to_string()) + .or_insert_with(|| toml::Value::Array(Vec::new())) + .as_array_mut() + .context("settings.toml [server.auth].methods is not an array")?; + if !methods.iter().any(|value| value.as_str() == Some("github")) { + methods.push(toml::Value::String("github".to_string())); + } + let github_auth = ensure_table(auth, "github")?; + github_auth.insert( + "allowed_usernames".to_string(), + toml::Value::Array( + allowed_usernames + .iter() + .cloned() + .map(toml::Value::String) + .collect(), + ), + ); + let github = github_integration_table(doc)?; github.insert("strategy".into(), toml::Value::String("app".to_string())); github.insert("app_id".into(), toml::Value::String(app_id.to_string())); @@ -267,6 +276,7 @@ impl InstallNonInteractiveArgs { || self.llm_api_key_stdin || self.llm_api_key_env.is_some() || self.github_strategy.is_some() + || self.github_owner.is_some() || self.github_username.is_some() || self.overwrite_settings || self.keep_existing_settings @@ -282,6 +292,8 @@ impl InstallNonInteractiveArgs { Some("--llm-api-key-env") } else if self.github_strategy.is_some() { Some("--github-strategy") + } else if self.github_owner.is_some() { + Some("--github-owner") } else if self.github_username.is_some() { Some("--github-username") } else if self.overwrite_settings { @@ -312,11 +324,18 @@ Non-interactive usage: --github-strategy token \ --github-username brynary + fabro install --non-interactive \ + --llm-provider anthropic \ + --llm-api-key-env ANTHROPIC_API_KEY \ + --github-strategy app \ + --github-owner personal + Hidden non-interactive flags: --llm-provider --llm-api-key-stdin --llm-api-key-env --github-strategy + --github-owner --github-username --overwrite-settings --keep-existing-settings @@ -324,7 +343,7 @@ Hidden non-interactive flags: Notes: - Only one API-key-based LLM provider is supported in non-interactive mode. - - GitHub CLI is supported in non-interactive mode; GitHub App setup is not."# + - GitHub App setup prints a local handoff URL and waits for the browser callback."# } #[derive(Debug, Clone)] @@ -352,6 +371,48 @@ enum ServerConfigSelection { Write, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GitHubAppHandoffMode { + Interactive, + Manual, +} + +fn install_json_event_line(value: &serde_json::Value) -> Result { + serde_json::to_string(&value).context("failed to serialize install JSON event") +} + +fn emit_install_json_event(value: &serde_json::Value) -> Result<()> { + let line = install_json_event_line(value)?; + #[allow(clippy::print_stdout)] + { + println!("{line}"); + } + Ok(()) +} + +fn install_complete_event() -> serde_json::Value { + serde_json::json!({ + "event": "install_complete", + "status": "success", + }) +} + +fn install_error_event(message: &str) -> serde_json::Value { + serde_json::json!({ + "event": "install_error", + "status": "error", + "message": message, + }) +} + +fn install_github_app_handoff_event(url: &str, owner: &GitHubAppOwner) -> serde_json::Value { + serde_json::json!({ + "event": "github_app_handoff", + "url": url, + "owner": owner.scripted_value(), + }) +} + #[async_trait] trait InstallInputSource { async fn choose_graphviz_install(&self, dot_missing: bool) -> Result; @@ -557,9 +618,21 @@ impl NonInteractiveInstallInputSource { ); match self.args.github_strategy { - Some(InstallGitHubStrategyArg::Token) => {} + Some(InstallGitHubStrategyArg::Token) => { + anyhow::ensure!( + self.args.github_owner.is_none(), + "--github-owner is only supported with --github-strategy app" + ); + } Some(InstallGitHubStrategyArg::App) => { - bail!("GitHub App setup is not supported with --non-interactive") + let owner = self.args.github_owner.as_deref().context( + "non-interactive install requires --github-owner for --github-strategy app", + )?; + GitHubAppOwner::parse_scripted(owner)?; + anyhow::ensure!( + self.args.github_username.is_none(), + "--github-username is only supported with --github-strategy token" + ); } None => bail!("non-interactive install requires --github-strategy"), } @@ -575,10 +648,15 @@ impl NonInteractiveInstallInputSource { } } - anyhow::ensure!( - self.args.github_username.is_some(), - "non-interactive install requires --github-username" - ); + if matches!( + self.args.github_strategy, + Some(InstallGitHubStrategyArg::Token) + ) { + anyhow::ensure!( + self.args.github_username.is_some(), + "non-interactive install requires --github-username for --github-strategy token" + ); + } Ok(()) } @@ -627,9 +705,14 @@ impl InstallInputSource for NonInteractiveInstallInputSource { ) -> Result { match self.args.github_strategy { Some(InstallGitHubStrategyArg::Token) => Ok(GitHubInstallSelection::Token), - Some(InstallGitHubStrategyArg::App) => { - bail!("GitHub App setup is not supported with --non-interactive") - } + Some(InstallGitHubStrategyArg::App) => Ok(GitHubInstallSelection::App { + owner: GitHubAppOwner::parse_scripted( + self.args.github_owner.as_deref().context( + "non-interactive install requires --github-owner for --github-strategy app", + )?, + )?, + username: best_effort_github_username().await, + }), None => bail!("non-interactive install requires --github-strategy"), } } @@ -657,13 +740,28 @@ impl InstallInputSource for NonInteractiveInstallInputSource { // GitHub App owner selection // --------------------------------------------------------------------------- -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq, Eq)] enum GitHubAppOwner { Personal, Organization(String), } impl GitHubAppOwner { + fn parse_scripted(value: &str) -> Result { + if value == "personal" { + return Ok(Self::Personal); + } + + let Some(org) = value.strip_prefix("org:") else { + bail!("--github-owner must be 'personal' or 'org:'"); + }; + anyhow::ensure!( + !org.trim().is_empty(), + "--github-owner organization slug cannot be empty" + ); + Ok(Self::Organization(org.to_string())) + } + fn manifest_form_action(&self) -> String { match self { Self::Personal => "https://github.com/settings/apps/new".to_string(), @@ -673,6 +771,13 @@ impl GitHubAppOwner { } } + fn scripted_value(&self) -> String { + match self { + Self::Personal => "personal".to_string(), + Self::Organization(org) => format!("org:{org}"), + } + } + fn app_name(&self, username: Option<&str>) -> String { match self { Self::Organization(org) => format!("{org}-fabro"), @@ -693,6 +798,11 @@ impl GitHubAppOwner { } } +async fn best_effort_github_username() -> Option { + let gh = GhCli::detect().await?; + gh.authenticated_user().await +} + /// Ask the user where to create the GitHub App. /// /// Uses the `gh` CLI to discover the username and admin orgs. If `gh` is @@ -778,15 +888,40 @@ fn build_github_app_manifest(app_name: &str, port: u16, web_url: &str) -> serde_ } /// Run the GitHub App manifest registration flow via a temporary local server. -/// Returns secret pairs `(key, value)` to persist for the local server. +/// Returns the app metadata and secret pairs to persist for the local server. +struct GitHubAppRegistration { + app_id: String, + slug: String, + client_id: String, + env_pairs: Vec<(String, String)>, +} + +enum PendingGitHubSettings { + Token, + App { + app_id: String, + slug: String, + client_id: String, + allowed_usernames: Vec, + }, +} + +#[derive(Clone, Copy)] +struct PendingSettingsWrite<'a> { + path: &'a Path, + contents: &'a str, + previous_contents: Option<&'a str>, +} + async fn setup_github_app( - fabro_dir: &Path, s: &Styles, web_url: &str, owner: &GitHubAppOwner, username: Option<&str>, + handoff_mode: GitHubAppHandoffMode, + json_output: bool, printer: Printer, -) -> Result> { +) -> Result { let app_name = owner.app_name(username); // Bind to random port @@ -875,19 +1010,34 @@ async fn setup_github_app( .ok(); }); - // Open browser let url = format!("http://127.0.0.1:{port}/"); - fabro_util::printerr!(printer, " {}", s.dim.apply_to("Opening browser...")); - if let Err(e) = open::that(&url) { - fabro_util::printerr!(printer, " Could not open browser automatically: {e}"); - fabro_util::printerr!(printer, " Please open this URL manually: {url}"); + if json_output { + emit_install_json_event(&install_github_app_handoff_event(&url, owner))?; } - fabro_util::printerr!( - printer, - " {}", - s.dim.apply_to("Waiting for GitHub... (Ctrl+C to cancel)") - ); + match handoff_mode { + GitHubAppHandoffMode::Interactive => { + fabro_util::printerr!(printer, " {}", s.dim.apply_to("Opening browser...")); + if let Err(e) = open::that(&url) { + fabro_util::printerr!(printer, " Could not open browser automatically: {e}"); + fabro_util::printerr!(printer, " Please open this URL manually: {url}"); + } + } + GitHubAppHandoffMode::Manual => { + if !json_output { + fabro_util::printerr!(printer, " Open this URL manually to continue setup:"); + fabro_util::printerr!(printer, " {url}"); + } + } + } + + if !json_output { + fabro_util::printerr!( + printer, + " {}", + s.dim.apply_to("Waiting for GitHub... (Ctrl+C to cancel)") + ); + } // Wait for the code let code = code_rx @@ -941,22 +1091,6 @@ async fn setup_github_app( .context("missing 'pem' in GitHub response")? .to_string(); - // Write non-secret config to settings.toml - let user_toml_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME); - let existing = std::fs::read_to_string(&user_toml_path).unwrap_or_default(); - let mut doc: toml::Value = if existing.is_empty() { - toml::Value::Table(toml::Table::default()) - } else { - toml::from_str(&existing).context("failed to parse existing settings.toml")? - }; - write_github_app_settings(&mut doc, &app_id, &slug, &client_id)?; - std::fs::write(&user_toml_path, toml::to_string_pretty(&doc)?)?; - fabro_util::printerr!( - printer, - " {}", - s.dim - .apply_to(format!("Wrote {}", user_toml_path.display())) - ); fabro_util::printerr!( printer, " {}", @@ -975,7 +1109,12 @@ async fn setup_github_app( env_pairs.push(("GITHUB_APP_WEBHOOK_SECRET".to_string(), secret)); } - Ok(env_pairs) + Ok(GitHubAppRegistration { + app_id, + slug, + client_id, + env_pairs, + }) } async fn persist_vault_secrets_via_server( @@ -1025,21 +1164,6 @@ async fn persist_vault_secrets_with( result } -async fn persist_vault_secrets( - storage_dir: &Path, - secrets: &[CreateSecretRequest], - server_was_running: bool, -) -> Result<()> { - persist_vault_secrets_with( - storage_dir, - secrets, - server_was_running, - |path| Box::pin(server_client::connect_api_client(path)), - |path, timeout| Box::pin(stop::stop_server(path, timeout)), - ) - .await -} - fn credential_secret_request(credential: &AuthCredential) -> Result { Ok(CreateSecretRequest { name: credential_id_for(credential).map_err(anyhow::Error::msg)?, @@ -1065,10 +1189,57 @@ async fn persist_install_outputs( storage_dir: &Path, server_env_secrets: &[(String, String)], vault_secrets: &[CreateSecretRequest], + settings_write: Option>, server_was_running: bool, +) -> Result<()> { + persist_install_outputs_with_settings( + storage_dir, + server_env_secrets, + vault_secrets, + settings_write, + server_was_running, + |path| Box::pin(server_client::connect_api_client(path)), + |path, timeout| Box::pin(stop::stop_server(path, timeout)), + ) + .await +} + +async fn persist_install_outputs_with_settings( + storage_dir: &Path, + server_env_secrets: &[(String, String)], + vault_secrets: &[CreateSecretRequest], + settings_write: Option>, + server_was_running: bool, + connect_api_client: impl for<'a> Fn(&'a Path) -> BoxFuture<'a, Result>, + stop_server: impl for<'a> Fn(&'a Path, Duration) -> BoxFuture<'a, bool>, ) -> Result<()> { persist_server_env_secrets(storage_dir, server_env_secrets)?; - persist_vault_secrets(storage_dir, vault_secrets, server_was_running).await + + if let Some(write) = settings_write { + std::fs::write(write.path, write.contents)?; + } + + let persist_result = persist_vault_secrets_with( + storage_dir, + vault_secrets, + server_was_running, + connect_api_client, + stop_server, + ) + .await; + + if let Err(err) = persist_result { + if let Some(write) = settings_write { + match write.previous_contents { + Some(previous) => std::fs::write(write.path, previous)?, + None if write.path.exists() => std::fs::remove_file(write.path)?, + None => {} + } + } + return Err(err); + } + + Ok(()) } pub(crate) async fn run_install( @@ -1076,7 +1247,29 @@ pub(crate) async fn run_install( globals: &GlobalArgs, printer: Printer, ) -> Result<()> { - globals.require_no_json()?; + if globals.json && !args.non_interactive { + bail!("--json is only supported for install with --non-interactive"); + } + + let result = Box::pin(run_install_inner(args, globals, printer)).await; + if globals.json { + let emit_result = match &result { + Ok(()) => emit_install_json_event(&install_complete_event()), + Err(err) => emit_install_json_event(&install_error_event(&err.to_string())), + }; + if result.is_ok() { + emit_result?; + } + } + + result +} + +async fn run_install_inner( + args: &InstallArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { let web_url = &args.web_url; let s = Styles::detect_stderr(); let emoji = console::Emoji("⚒️ ", ""); @@ -1085,6 +1278,7 @@ pub(crate) async fn run_install( let server_was_running = record::active_server_record(&storage_dir).is_some(); let fabro_dir = fabro_util::Home::from_env().root().to_path_buf(); let config_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME); + let existing_config_contents = std::fs::read_to_string(&config_path).ok(); let config_existed_before_install = config_path.exists(); let input_source: Box = match NonInteractiveInstallInputSource::new(args)? { @@ -1189,20 +1383,11 @@ pub(crate) async fn run_install( fabro_util::printerr!(printer, " {}", s.dim.apply_to("───────────────")); fabro_util::printerr!(printer, ""); - match input_source.choose_github_install(&s, printer).await? { + let pending_github_settings = match input_source.choose_github_install(&s, printer).await? { GitHubInstallSelection::Token => { let token = fabro_github::gh_auth_token() .await .map_err(|err| anyhow!("{err}. Run `gh auth login` and rerun `fabro install`."))?; - let user_toml_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME); - let existing = std::fs::read_to_string(&user_toml_path).unwrap_or_default(); - let mut doc: toml::Value = if existing.is_empty() { - toml::Value::Table(toml::Table::default()) - } else { - toml::from_str(&existing).context("failed to parse existing settings.toml")? - }; - write_token_settings(&mut doc)?; - std::fs::write(&user_toml_path, toml::to_string_pretty(&doc)?)?; fabro_util::printerr!( printer, " {} GitHub token configured", @@ -1214,47 +1399,56 @@ pub(crate) async fn run_install( type_: ApiSecretType::Environment, description: None, }); + Some(PendingGitHubSettings::Token) } GitHubInstallSelection::App { owner, username } => { - let github_env_pairs = setup_github_app( - &fabro_dir, + let allowed_username = username.clone().context( + "GitHub App install requires an authenticated GitHub username; run `gh auth login` and rerun `fabro install`", + )?; + let registration = setup_github_app( &s, web_url, &owner, username.as_deref(), + if args.non_interactive { + GitHubAppHandoffMode::Manual + } else { + GitHubAppHandoffMode::Interactive + }, + globals.json, printer, ) .await?; - let slug = { - let user_toml_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME); - let toml_content = std::fs::read_to_string(&user_toml_path).unwrap_or_default(); - let doc: toml::Value = toml::from_str(&toml_content) - .unwrap_or(toml::Value::Table(toml::Table::default())); - doc.get("server") - .and_then(|server| server.get("integrations")) - .and_then(|integrations| integrations.get("github")) - .and_then(|github| github.get("slug")) - .and_then(|slug| slug.as_str()) - .unwrap_or("unknown") - .to_string() - }; fabro_util::printerr!( printer, " {} GitHub App registered ({})", s.green.apply_to("✔"), - slug + registration.slug ); - server_env_pairs.extend(github_env_pairs); + server_env_pairs.extend(registration.env_pairs.iter().cloned()); + Some(PendingGitHubSettings::App { + app_id: registration.app_id, + slug: registration.slug, + client_id: registration.client_id, + allowed_usernames: vec![allowed_username], + }) } - } + }; fabro_util::printerr!(printer, ""); // Server configuration - { + let settings_toml = { fabro_util::printerr!(printer, " {}", s.bold.apply_to("Server · Configuration")); fabro_util::printerr!(printer, " {}", s.dim.apply_to("─────────────────────")); fabro_util::printerr!(printer, ""); + let existing = existing_config_contents.clone().unwrap_or_default(); + let mut doc: toml::Value = if existing.is_empty() { + toml::Value::Table(toml::Table::default()) + } else { + toml::from_str(&existing).context("failed to parse existing settings.toml")? + }; + match input_source .choose_server_config(config_existed_before_install) .await? @@ -1267,23 +1461,34 @@ pub(crate) async fn run_install( ); } ServerConfigSelection::Write => { - let existing = std::fs::read_to_string(&config_path).unwrap_or_default(); - let mut doc: toml::Value = if existing.is_empty() { - toml::Value::Table(toml::Table::default()) - } else { - toml::from_str(&existing).context("failed to parse existing settings.toml")? - }; merge_server_settings(&mut doc)?; - std::fs::write(&config_path, toml::to_string_pretty(&doc)?)?; - fabro_util::printerr!( - printer, - " {}", - s.dim.apply_to(format!("Wrote {}", config_path.display())) - ); } } + + match pending_github_settings { + Some(PendingGitHubSettings::Token) => { + write_token_settings(&mut doc)?; + } + Some(PendingGitHubSettings::App { + app_id, + slug, + client_id, + allowed_usernames, + }) => { + write_github_app_settings( + &mut doc, + &app_id, + &slug, + &client_id, + &allowed_usernames, + )?; + } + None => {} + } + fabro_util::printerr!(printer, ""); - } + toml::to_string_pretty(&doc)? + }; // Secrets and auth material { @@ -1300,7 +1505,7 @@ pub(crate) async fn run_install( s.green.apply_to("✔") ); - let (jwt_private_pem, jwt_public_pem) = generate_jwt_keypair().await?; + let (jwt_private_pem, jwt_public_pem) = generate_jwt_keypair()?; fabro_util::printerr!( printer, " {} Ed25519 JWT keypair generated", @@ -1341,6 +1546,11 @@ pub(crate) async fn run_install( &storage_dir, &server_env_pairs, &vault_secrets, + Some(PendingSettingsWrite { + path: &config_path, + contents: settings_toml.as_str(), + previous_contents: existing_config_contents.as_deref(), + }), server_was_running, ) .await?; @@ -1361,6 +1571,12 @@ pub(crate) async fn run_install( vault_secrets.len(), Storage::new(&storage_dir).secrets_path().display() ); + fabro_util::printerr!( + printer, + " {} Wrote {}", + s.green.apply_to("✔"), + config_path.display() + ); if server_was_running { fabro_util::printerr!( printer, @@ -1451,7 +1667,7 @@ mod tests { #[tokio::test] async fn jwt_keypair_private_pem_header() { - let (private, _) = generate_jwt_keypair().await.unwrap(); + let (private, _) = generate_jwt_keypair().unwrap(); assert!( private.starts_with("-----BEGIN PRIVATE KEY-----"), "private PEM: {private}" @@ -1460,7 +1676,7 @@ mod tests { #[tokio::test] async fn jwt_keypair_public_pem_header() { - let (_, public) = generate_jwt_keypair().await.unwrap(); + let (_, public) = generate_jwt_keypair().unwrap(); assert!( public.starts_with("-----BEGIN PUBLIC KEY-----"), "public PEM: {public}" @@ -1469,10 +1685,17 @@ mod tests { #[tokio::test] async fn jwt_keypair_public_parses() { - let (_, public) = generate_jwt_keypair().await.unwrap(); + let (_, public) = generate_jwt_keypair().unwrap(); jsonwebtoken::DecodingKey::from_ed_pem(public.as_bytes()).expect("public key should parse"); } + #[tokio::test] + async fn jwt_keypair_private_parses() { + let (private, _) = generate_jwt_keypair().unwrap(); + jsonwebtoken::EncodingKey::from_ed_pem(private.as_bytes()) + .expect("private key should parse"); + } + // -- Config TOML generation -- #[test] @@ -1607,8 +1830,12 @@ client_id = "client-id" #[test] fn write_github_app_settings_uses_server_integrations_github() { let mut doc = toml::Value::Table(toml::Table::default()); + merge_server_settings(&mut doc).unwrap(); - write_github_app_settings(&mut doc, "123", "fabro-app", "client-id").unwrap(); + write_github_app_settings(&mut doc, "123", "fabro-app", "client-id", &[ + "brynary".to_string() + ]) + .unwrap(); let github = doc .get("server") @@ -1635,6 +1862,53 @@ client_id = "client-id" github.get("client_id").and_then(toml::Value::as_str), Some("client-id") ); + + let methods = doc + .get("server") + .and_then(toml::Value::as_table) + .and_then(|server| server.get("auth")) + .and_then(toml::Value::as_table) + .and_then(|auth| auth.get("methods")) + .and_then(toml::Value::as_array) + .expect("server.auth.methods should exist"); + + assert_eq!( + methods + .iter() + .map(|value| value.as_str().expect("auth method should be a string")) + .collect::>(), + vec!["dev-token", "github"] + ); + + let allowed_usernames = doc + .get("server") + .and_then(toml::Value::as_table) + .and_then(|server| server.get("auth")) + .and_then(toml::Value::as_table) + .and_then(|auth| auth.get("github")) + .and_then(toml::Value::as_table) + .and_then(|github| github.get("allowed_usernames")) + .and_then(toml::Value::as_array) + .expect("server.auth.github.allowed_usernames should exist"); + + assert_eq!( + allowed_usernames + .iter() + .map(|value| value.as_str().expect("username should be a string")) + .collect::>(), + vec!["brynary"] + ); + } + + #[test] + fn write_github_app_settings_requires_allowed_usernames() { + let mut doc = toml::Value::Table(toml::Table::default()); + let err = + write_github_app_settings(&mut doc, "123", "fabro-app", "client-id", &[]).unwrap_err(); + assert!( + err.to_string() + .contains("GitHub App install requires at least one allowed GitHub username") + ); } // -- GitHub App owner -- @@ -1657,6 +1931,31 @@ client_id = "client-id" ); } + #[test] + fn github_app_owner_parses_personal_scripted_value() { + assert_eq!( + GitHubAppOwner::parse_scripted("personal").unwrap(), + GitHubAppOwner::Personal + ); + } + + #[test] + fn github_app_owner_parses_org_scripted_value() { + assert_eq!( + GitHubAppOwner::parse_scripted("org:acme").unwrap(), + GitHubAppOwner::Organization("acme".to_string()) + ); + } + + #[test] + fn github_app_owner_rejects_invalid_scripted_value() { + let err = GitHubAppOwner::parse_scripted("acme").unwrap_err(); + assert!( + err.to_string() + .contains("--github-owner must be 'personal' or 'org:'") + ); + } + #[test] fn github_app_owner_app_name_with_org() { let owner = GitHubAppOwner::Organization("acme-corp".to_string()); @@ -1677,6 +1976,25 @@ client_id = "client-id" assert_eq!(name.len(), 12); // "Fabro-" (6) + 6 hex chars } + #[test] + fn install_json_event_line_serializes_handoff_event() { + let event = + install_github_app_handoff_event("http://127.0.0.1:1234/", &GitHubAppOwner::Personal); + let line = install_json_event_line(&event).unwrap(); + let value: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(value["event"], "github_app_handoff"); + assert_eq!(value["url"], "http://127.0.0.1:1234/"); + assert_eq!(value["owner"], "personal"); + } + + #[test] + fn install_error_event_contains_message() { + let value = install_error_event("boom"); + assert_eq!(value["event"], "install_error"); + assert_eq!(value["status"], "error"); + assert_eq!(value["message"], "boom"); + } + // -- GitHub App manifest -- #[test] @@ -1768,6 +2086,84 @@ client_id = "client-id" assert!(!Storage::new(dir.path()).secrets_path().exists()); } + #[tokio::test] + async fn persist_install_outputs_with_settings_does_not_write_settings_on_secret_failure() { + let dir = tempfile::tempdir().unwrap(); + let server_env_pairs = vec![("SESSION_SECRET".to_string(), "session".to_string())]; + let vault_secrets = vec![CreateSecretRequest { + name: "GITHUB_CLI_TOKEN".to_string(), + value: "gh-token".to_string(), + type_: ApiSecretType::Environment, + description: None, + }]; + let settings_path = dir.path().join(SETTINGS_CONFIG_FILENAME); + let stop_called = Arc::new(AtomicBool::new(false)); + + let result = persist_install_outputs_with_settings( + dir.path(), + &server_env_pairs, + &vault_secrets, + Some(PendingSettingsWrite { + path: &settings_path, + contents: "_version = 1\n", + previous_contents: None, + }), + false, + |_| Box::pin(async move { Err(anyhow!("boom")) }), + { + let stop_called = Arc::clone(&stop_called); + move |_, _| { + let stop_called = Arc::clone(&stop_called); + Box::pin(async move { + stop_called.store(true, Ordering::SeqCst); + true + }) + } + }, + ) + .await; + + assert!(result.is_err()); + assert!(Storage::new(dir.path()).server_state().env_path().exists()); + assert!(!settings_path.exists()); + assert!(stop_called.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn persist_install_outputs_with_settings_restores_previous_contents_on_secret_failure() { + let dir = tempfile::tempdir().unwrap(); + let server_env_pairs = vec![("SESSION_SECRET".to_string(), "session".to_string())]; + let vault_secrets = vec![CreateSecretRequest { + name: "GITHUB_CLI_TOKEN".to_string(), + value: "gh-token".to_string(), + type_: ApiSecretType::Environment, + description: None, + }]; + let settings_path = dir.path().join(SETTINGS_CONFIG_FILENAME); + std::fs::write(&settings_path, "_version = 1\n[server]\n").unwrap(); + + let result = persist_install_outputs_with_settings( + dir.path(), + &server_env_pairs, + &vault_secrets, + Some(PendingSettingsWrite { + path: &settings_path, + contents: "_version = 1\n[server]\nfoo = \"bar\"\n", + previous_contents: Some("_version = 1\n[server]\n"), + }), + false, + |_| Box::pin(async move { Err(anyhow!("boom")) }), + |_, _| Box::pin(async move { true }), + ) + .await; + + assert!(result.is_err()); + assert_eq!( + std::fs::read_to_string(&settings_path).unwrap(), + "_version = 1\n[server]\n" + ); + } + #[test] fn non_interactive_source_rejects_missing_scripted_inputs() { let args = install_args(true, InstallNonInteractiveArgs::default()); @@ -1856,10 +2252,9 @@ client_id = "client-id" }; let err = source.validate(false).unwrap_err(); - assert!( - err.to_string() - .contains("non-interactive install requires --github-username") - ); + assert!(err.to_string().contains( + "non-interactive install requires --github-username for --github-strategy token" + )); } #[test] @@ -1877,13 +2272,33 @@ client_id = "client-id" source.validate(true).unwrap(); } - #[tokio::test] - async fn non_interactive_source_rejects_github_app_setup() { + #[test] + fn non_interactive_source_rejects_missing_github_owner_for_app() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { llm_provider: Some(Provider::Anthropic), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::App), + ..InstallNonInteractiveArgs::default() + }, + }; + + let err = source.validate(false).unwrap_err(); + assert!( + err.to_string().contains( + "non-interactive install requires --github-owner for --github-strategy app" + ) + ); + } + + #[test] + fn non_interactive_source_rejects_github_owner_for_token() { + let source = NonInteractiveInstallInputSource { + args: InstallNonInteractiveArgs { + llm_provider: Some(Provider::Anthropic), + llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), + github_strategy: Some(InstallGitHubStrategyArg::Token), + github_owner: Some("personal".to_string()), github_username: Some("brynary".to_string()), ..InstallNonInteractiveArgs::default() }, @@ -1892,10 +2307,45 @@ client_id = "client-id" let err = source.validate(false).unwrap_err(); assert!( err.to_string() - .contains("GitHub App setup is not supported with --non-interactive") + .contains("--github-owner is only supported with --github-strategy app") ); } + #[test] + fn non_interactive_source_rejects_github_username_for_app() { + let source = NonInteractiveInstallInputSource { + args: InstallNonInteractiveArgs { + llm_provider: Some(Provider::Anthropic), + llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), + github_strategy: Some(InstallGitHubStrategyArg::App), + github_owner: Some("personal".to_string()), + github_username: Some("brynary".to_string()), + ..InstallNonInteractiveArgs::default() + }, + }; + + let err = source.validate(false).unwrap_err(); + assert!( + err.to_string() + .contains("--github-username is only supported with --github-strategy token") + ); + } + + #[test] + fn non_interactive_source_allows_github_app_setup() { + let source = NonInteractiveInstallInputSource { + args: InstallNonInteractiveArgs { + llm_provider: Some(Provider::Anthropic), + llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), + github_strategy: Some(InstallGitHubStrategyArg::App), + github_owner: Some("personal".to_string()), + ..InstallNonInteractiveArgs::default() + }, + }; + + source.validate(false).unwrap(); + } + #[tokio::test] async fn non_interactive_source_requires_config_choice_when_settings_exist() { let source = NonInteractiveInstallInputSource { diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index 2c79a0e39..eae036e4b 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -1072,6 +1072,36 @@ async fn ensure_raw_response_success(response: fabro_http::Response) -> Result<( bail!("request failed with status {status}: {body}"); } +fn is_not_found_error(err: &progenitor_client::Error) -> bool +where + E: serde::Serialize + std::fmt::Debug, +{ + match err { + progenitor_client::Error::ErrorResponse(response) => { + response.status() == fabro_http::StatusCode::NOT_FOUND + } + progenitor_client::Error::UnexpectedResponse(response) => { + response.status() == fabro_http::StatusCode::NOT_FOUND + } + _ => false, + } +} +fn convert_type(value: TInput) -> Result +where + TInput: serde::Serialize, + TOutput: DeserializeOwned, +{ + serde_json::from_value(serde_json::to_value(value)?).map_err(Into::into) +} + +fn non_zero_u64_from_u32(value: u32) -> Option { + NonZeroU64::new(u64::from(value)) +} + +fn non_zero_u64_from_usize(value: usize) -> Option { + u64::try_from(value).ok().and_then(NonZeroU64::new) +} + #[cfg(test)] mod tests { use std::sync::{LazyLock, Mutex}; @@ -1140,7 +1170,7 @@ mod tests { .record_path(); record::write_server_record(&record_path, &record::ServerRecord { pid: std::process::id(), - bind: fabro_server::bind::Bind::Unix(temp_home.path().join("fabro.sock")), + bind: Bind::Unix(temp_home.path().join("fabro.sock")), log_path: storage.path().join("server.log"), dev_token_path: Some(token_path), started_at: chrono::Utc::now(), @@ -1199,33 +1229,3 @@ mod tests { assert!(!remote_url_targets_local_host("https://example.com")); } } - -fn is_not_found_error(err: &progenitor_client::Error) -> bool -where - E: serde::Serialize + std::fmt::Debug, -{ - match err { - progenitor_client::Error::ErrorResponse(response) => { - response.status() == fabro_http::StatusCode::NOT_FOUND - } - progenitor_client::Error::UnexpectedResponse(response) => { - response.status() == fabro_http::StatusCode::NOT_FOUND - } - _ => false, - } -} -fn convert_type(value: TInput) -> Result -where - TInput: serde::Serialize, - TOutput: DeserializeOwned, -{ - serde_json::from_value(serde_json::to_value(value)?).map_err(Into::into) -} - -fn non_zero_u64_from_u32(value: u32) -> Option { - NonZeroU64::new(u64::from(value)) -} - -fn non_zero_u64_from_usize(value: usize) -> Option { - u64::try_from(value).ok().and_then(NonZeroU64::new) -} diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index a64a382e0..50dcba537 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -394,9 +394,9 @@ fn settings_local_merges_cli_and_project_defaults() { assert_eq!(cfg["workflow"]["graph"].as_str(), Some("workflow.fabro")); assert_eq!(cfg["run"]["execution"]["approval"].as_str(), Some("prompt")); assert_eq!(cfg["run"]["sandbox"]["provider"].as_str(), Some("daytona")); - assert_eq!(run_model_name(&cfg).as_deref(), Some("project-model")); - assert_eq!(run_model_provider(&cfg).as_deref(), Some("openai")); - assert_eq!(run_goal_inline(&cfg).as_deref(), None); + assert_eq!(run_model_name(&cfg), Some("project-model")); + assert_eq!(run_model_provider(&cfg), Some("openai")); + assert_eq!(run_goal_inline(&cfg), None); // v2 R22: run.inputs replaces the inherited map wholesale rather than // merging by key, so the project layer wipes out the CLI layer's inputs. @@ -438,9 +438,9 @@ fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() { .clone(); let cfg = parse_settings(&output); - assert_eq!(run_goal_inline(&cfg).as_deref(), Some("demo goal")); - assert_eq!(run_model_name(&cfg).as_deref(), Some("run-model")); - assert_eq!(run_model_provider(&cfg).as_deref(), Some("anthropic")); + assert_eq!(run_goal_inline(&cfg), Some("demo goal")); + assert_eq!(run_model_name(&cfg), Some("run-model")); + assert_eq!(run_model_provider(&cfg), Some("anthropic")); // v2 R22: run.inputs replaces wholesale, so the workflow layer wins // over project and cli. @@ -701,7 +701,7 @@ shared = "legacy" .stderr(predicate::str::contains("ignoring legacy config file")); let cfg = parse_settings(&assert.get_output().stdout); - assert_eq!(run_model_name(&cfg).as_deref(), Some("project-model")); + assert_eq!(run_model_name(&cfg), Some("project-model")); let vars = run_inputs(&cfg); assert_eq!( vars.get("shared").and_then(serde_json::Value::as_str), @@ -862,8 +862,8 @@ shared = "cli" assert_eq!(cfg["project"]["directory"].as_str(), Some(".")); assert_eq!(cfg["workflow"]["graph"].as_str(), Some("workflow.fabro")); assert_eq!(cfg["run"]["execution"]["approval"].as_str(), Some("prompt")); - assert_eq!(run_model_name(&cfg).as_deref(), Some("server-model")); - assert_eq!(run_model_provider(&cfg).as_deref(), Some("openai")); + assert_eq!(run_model_name(&cfg), Some("server-model")); + assert_eq!(run_model_provider(&cfg), Some("openai")); assert_eq!(server_storage_root(&cfg), "/srv/fabro-server"); assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("normal")); diff --git a/lib/crates/fabro-cli/tests/it/cmd/install.rs b/lib/crates/fabro-cli/tests/it/cmd/install.rs index ba26f1b73..25bcafb74 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/install.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/install.rs @@ -28,7 +28,7 @@ fn help() { } #[test] -fn install_rejects_json() { +fn install_json_requires_non_interactive() { let context = test_context!(); let output = context .command() @@ -38,7 +38,55 @@ fn install_rejects_json() { assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("--json is not supported for this command")); + assert!(stderr.contains("--json is only supported for install with --non-interactive")); +} + +#[test] +fn install_json_non_interactive_is_not_rejected_as_unsupported() { + let context = test_context!(); + let output = context + .command() + .args(["--json", "install", "--non-interactive"]) + .output() + .expect("command should run"); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Non-interactive install requires additional flags")); + assert!(!stderr.contains("--json is not supported for this command")); +} + +#[test] +fn install_json_non_interactive_allows_github_app_strategy() { + let context = test_context!(); + let output = context + .command() + .env_remove("MISSING_ANTHROPIC_API_KEY") + .args([ + "--json", + "install", + "--non-interactive", + "--llm-provider", + "anthropic", + "--llm-api-key-env", + "MISSING_ANTHROPIC_API_KEY", + "--github-strategy", + "app", + "--github-owner", + "personal", + ]) + .output() + .expect("command should run"); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(!stderr.contains("GitHub App setup is not supported with --non-interactive")); + assert!(!stderr.contains("requires --github-username")); + let stdout = String::from_utf8(output.stdout).unwrap(); + let value: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("install JSON error should parse"); + assert_eq!(value["event"], "install_error"); + assert_eq!(value["status"], "error"); } #[test]