Merge origin/main into local main

Resolve conflicts in install.rs: apply gh_cli→token rename from local
to new non-interactive App support and pending_github_settings pattern
from origin/main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-13 21:38:54 -04:00
commit 01b1fd13bd
8 changed files with 737 additions and 262 deletions

1
Cargo.lock generated
View file

@ -1621,6 +1621,7 @@ dependencies = [
"progenitor-client",
"rand 0.8.5",
"regex",
"ring",
"rustls",
"rustls-pemfile",
"scopeguard",

View file

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

View file

@ -1297,6 +1297,9 @@ pub(crate) struct InstallNonInteractiveArgs {
#[arg(long, hide = true)]
pub(crate) github_strategy: Option<InstallGitHubStrategyArg>,
#[arg(long, hide = true)]
pub(crate) github_owner: Option<String>,
#[arg(long, hide = true)]
pub(crate) github_username: Option<String>,

View file

@ -38,27 +38,16 @@ pub(crate) enum ProbeOutcome {
Ok { version: Option<Version> },
}
static OPENSSL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?:OpenSSL|LibreSSL)\s+(\d+)\.(\d+)\.(\d+)").unwrap());
static DOT_RE: LazyLock<Regex> =
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<Version> {
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];

File diff suppressed because it is too large Load diff

View file

@ -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<E>(err: &progenitor_client::Error<E>) -> 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<TInput, TOutput>(value: TInput) -> Result<TOutput>
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> {
NonZeroU64::new(u64::from(value))
}
fn non_zero_u64_from_usize(value: usize) -> Option<NonZeroU64> {
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<E>(err: &progenitor_client::Error<E>) -> 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<TInput, TOutput>(value: TInput) -> Result<TOutput>
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> {
NonZeroU64::new(u64::from(value))
}
fn non_zero_u64_from_usize(value: usize) -> Option<NonZeroU64> {
u64::try_from(value).ok().and_then(NonZeroU64::new)
}

View file

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

View file

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