mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
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:
commit
01b1fd13bd
8 changed files with 737 additions and 262 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1621,6 +1621,7 @@ dependencies = [
|
|||
"progenitor-client",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"ring",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"scopeguard",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue