mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Route install/uninstall through local_server::storage_dir instead of hand- rolled copies, drop dead connect_api_client and run_dir plumbing, eliminate double-resolve in prepare_server_bootstrap, and tighten the boundary allowlist now that uninstall no longer needs the exemption. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1124 lines
37 KiB
Rust
1124 lines
37 KiB
Rust
#![allow(
|
|
clippy::exit,
|
|
reason = "The CLI exits explicitly with the computed process status."
|
|
)]
|
|
|
|
mod args;
|
|
mod command_context;
|
|
mod commands;
|
|
mod gh;
|
|
mod landing;
|
|
mod local_server;
|
|
mod logging;
|
|
mod manifest_builder;
|
|
mod server_client;
|
|
mod server_runs;
|
|
mod shared;
|
|
#[cfg(feature = "sleep_inhibitor")]
|
|
mod sleep_inhibitor;
|
|
mod user_config;
|
|
|
|
#[cfg(test)]
|
|
use std::ffi::OsString;
|
|
|
|
use anyhow::Result;
|
|
use args::{
|
|
Commands, GlobalArgs, LONG_VERSION, RunCommands, ServerCommand, ServerNamespace,
|
|
global_args_cli_layer, printer_from_verbosity, require_no_json_override,
|
|
};
|
|
use clap::{CommandFactory, Parser};
|
|
use fabro_config::merge::combine_files;
|
|
use fabro_telemetry::{git, panic as tel_panic, sanitize, sender};
|
|
use fabro_types::settings::SettingsLayer;
|
|
use fabro_types::settings::cli::OutputVerbosity;
|
|
use fabro_util::printer::Printer;
|
|
use fabro_util::terminal::Styles;
|
|
use rustls::crypto::ring::default_provider;
|
|
use tracing::debug;
|
|
|
|
#[derive(Parser)]
|
|
#[command(name = "fabro", version, long_version = LONG_VERSION)]
|
|
struct Cli {
|
|
#[command(flatten)]
|
|
globals: GlobalArgs,
|
|
|
|
#[command(subcommand)]
|
|
command: Option<Box<Commands>>,
|
|
}
|
|
|
|
impl Cli {
|
|
fn parse() -> Self {
|
|
<Self as Parser>::parse()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn try_parse_from<I, T>(args: I) -> Result<Self, clap::Error>
|
|
where
|
|
I: IntoIterator<Item = T>,
|
|
T: Into<OsString> + Clone,
|
|
{
|
|
<Self as Parser>::try_parse_from(args)
|
|
}
|
|
}
|
|
|
|
#[expect(clippy::print_stderr, reason = "fatal error reporting before exit")]
|
|
#[tokio::main]
|
|
async fn main() {
|
|
let raw_args: Vec<String> = std::env::args().collect();
|
|
let subcommand = raw_args.get(1).map(String::as_str);
|
|
let subcommand_arg = raw_args.get(2).map(String::as_str);
|
|
if subcommand == Some("__render-graph") && !matches!(subcommand_arg, Some("--help" | "-h")) {
|
|
std::process::exit(commands::render_graph::execute());
|
|
}
|
|
|
|
tel_panic::install_panic_hook();
|
|
fabro_telemetry::init_cli();
|
|
|
|
let start = std::time::Instant::now();
|
|
|
|
let (command_name, result) = Box::pin(main_inner()).await;
|
|
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
|
|
|
|
let is_error = result.is_err();
|
|
// An empty command_name means no subcommand was invoked (landing was shown);
|
|
// don't emit a tracking event for that case.
|
|
if !command_name.is_empty() {
|
|
let command = sanitize::sanitize_command(&raw_args, &command_name);
|
|
let repository = git::repository_identifier();
|
|
let ci = std::env::var("CI").is_ok();
|
|
if is_error {
|
|
fabro_telemetry::track!("CLI Errored", {
|
|
"subcommand": command_name,
|
|
"command": command,
|
|
"durationMs": duration_ms,
|
|
"repository": repository,
|
|
"ci": ci,
|
|
"success": false,
|
|
"exitCode": 1,
|
|
}, error);
|
|
} else {
|
|
fabro_telemetry::track!("CLI Executed", {
|
|
"subcommand": command_name,
|
|
"command": command,
|
|
"durationMs": duration_ms,
|
|
"repository": repository,
|
|
"ci": ci,
|
|
"success": true,
|
|
"exitCode": 0,
|
|
});
|
|
}
|
|
}
|
|
fabro_telemetry::shutdown();
|
|
|
|
if let Err(err) = result {
|
|
let style = console::Style::new().red().bold();
|
|
for (i, cause) in err.chain().enumerate() {
|
|
let text = cause.to_string();
|
|
if i == 0 {
|
|
for (j, line) in text.lines().enumerate() {
|
|
if j == 0 {
|
|
eprintln!("{} {line}", style.apply_to("error:"));
|
|
} else {
|
|
eprintln!(" {line}");
|
|
}
|
|
}
|
|
} else {
|
|
for line in text.lines() {
|
|
eprintln!(" > {line}");
|
|
}
|
|
}
|
|
}
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
async fn main_inner() -> (String, Result<()>) {
|
|
let _ = default_provider().install_default();
|
|
|
|
let cli = Cli::parse();
|
|
|
|
let Cli { globals, command } = cli;
|
|
let Some(command) = command else {
|
|
landing::print();
|
|
return (String::new(), Ok(()));
|
|
};
|
|
let bootstrap_printer = Printer::from_flags(globals.quiet, globals.verbose);
|
|
let cli_layer = global_args_cli_layer(&globals);
|
|
let process_local_json = globals.json;
|
|
let command_name = command.name().to_string();
|
|
let pre_tracing_bootstrap = match pre_tracing_bootstrap(command.as_ref()).await {
|
|
Ok(bootstrap) => bootstrap,
|
|
Err(err) => return (command_name, Err(err)),
|
|
};
|
|
|
|
let user_settings = match user_config::load_settings() {
|
|
Ok(settings) => settings,
|
|
Err(err) => return (command_name, Err(err)),
|
|
};
|
|
let combined_settings = combine_files(user_settings, SettingsLayer {
|
|
cli: Some(cli_layer.clone()),
|
|
..SettingsLayer::default()
|
|
});
|
|
let cli_settings = match user_config::resolve_cli_settings(&combined_settings) {
|
|
Ok(cli_settings) => cli_settings,
|
|
Err(err) => return (command_name, Err(err)),
|
|
};
|
|
let printer = printer_from_verbosity(cli_settings.output.verbosity);
|
|
|
|
let config_log_level = match &pre_tracing_bootstrap.sink {
|
|
logging::InternalLogSink::Cli => cli_settings.logging.level.clone(),
|
|
logging::InternalLogSink::Server { .. } => pre_tracing_bootstrap.config_log_level.clone(),
|
|
};
|
|
if let Err(err) = logging::init_tracing(
|
|
globals.debug,
|
|
config_log_level.as_deref(),
|
|
&pre_tracing_bootstrap.sink,
|
|
) {
|
|
fabro_util::printerr!(
|
|
bootstrap_printer,
|
|
"Warning: failed to initialize logging: {err:#}"
|
|
);
|
|
}
|
|
|
|
debug!(command = %command_name, "CLI command started");
|
|
let foreground_server_log_bootstrap = pre_tracing_bootstrap.foreground_server_log_bootstrap;
|
|
|
|
let upgrade_handle = if matches!(
|
|
command.as_ref(),
|
|
Commands::RunCmd(RunCommands::Run(_) | RunCommands::Create(_))
|
|
| Commands::Exec(_)
|
|
| Commands::Repo(_)
|
|
| Commands::Install { .. }
|
|
) {
|
|
commands::upgrade::spawn_upgrade_check(cli_settings.updates.check, printer)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let result = Box::pin(async move {
|
|
match *command {
|
|
Commands::Exec(args) => commands::exec::execute(args, &cli_settings, printer).await?,
|
|
Commands::RunCmd(cmd) => {
|
|
Box::pin(commands::run::dispatch(
|
|
cmd,
|
|
&cli_settings,
|
|
&cli_layer,
|
|
process_local_json,
|
|
printer,
|
|
))
|
|
.await?;
|
|
}
|
|
Commands::Preflight(args) => {
|
|
commands::preflight::execute(args, &cli_settings, &cli_layer, printer).await?;
|
|
}
|
|
Commands::Validate(args) => {
|
|
let styles = Styles::detect_stderr();
|
|
commands::validate::run(&args, &styles, &cli_settings, &cli_layer, printer).await?;
|
|
}
|
|
Commands::Graph(args) => {
|
|
let styles = Styles::detect_stderr();
|
|
commands::graph::run(
|
|
&args,
|
|
&styles,
|
|
&cli_settings,
|
|
&cli_layer,
|
|
process_local_json,
|
|
printer,
|
|
)
|
|
.await?;
|
|
}
|
|
Commands::Parse(args) => {
|
|
commands::parse::run(&args, &cli_settings, printer)?;
|
|
}
|
|
Commands::Artifact(ns) => {
|
|
commands::artifact::dispatch(ns, &cli_settings, &cli_layer, printer).await?;
|
|
}
|
|
Commands::Store(ns) => {
|
|
commands::store::dispatch(ns, &cli_settings, &cli_layer, printer).await?;
|
|
}
|
|
Commands::RunsCmd(cmd) => {
|
|
commands::runs::dispatch(cmd, &cli_settings, &cli_layer, printer).await?;
|
|
}
|
|
Commands::Model { command } => {
|
|
commands::model::execute(command, &cli_settings, &cli_layer, printer).await?;
|
|
}
|
|
Commands::Server(ns) => {
|
|
Box::pin(commands::server::dispatch(
|
|
ns.command,
|
|
&globals,
|
|
foreground_server_log_bootstrap,
|
|
printer,
|
|
))
|
|
.await?;
|
|
}
|
|
Commands::Doctor(args) => {
|
|
let verbose =
|
|
args.verbose || cli_settings.output.verbosity == OutputVerbosity::Verbose;
|
|
let exit_code = Box::pin(commands::doctor::run_doctor(
|
|
&args,
|
|
verbose,
|
|
&cli_settings,
|
|
&cli_layer,
|
|
printer,
|
|
))
|
|
.await?;
|
|
std::process::exit(exit_code);
|
|
}
|
|
Commands::Version(args) => {
|
|
commands::version::version_command(&args, &cli_settings, &cli_layer, printer)
|
|
.await?;
|
|
}
|
|
Commands::Discord => {
|
|
if process_local_json {
|
|
shared::print_json_pretty(&serde_json::json!({
|
|
"url": "https://fabro.sh/discord",
|
|
}))?;
|
|
} else {
|
|
open::that("https://fabro.sh/discord")?;
|
|
}
|
|
}
|
|
Commands::Docs => {
|
|
if process_local_json {
|
|
shared::print_json_pretty(&serde_json::json!({
|
|
"url": "https://docs.fabro.sh/",
|
|
}))?;
|
|
} else {
|
|
open::that("https://docs.fabro.sh/")?;
|
|
}
|
|
}
|
|
Commands::Repo(ns) => {
|
|
commands::repo::dispatch(ns, &cli_settings, &cli_layer, printer).await?;
|
|
}
|
|
Commands::Install { args, command } => {
|
|
Box::pin(commands::install::execute(
|
|
&args,
|
|
command,
|
|
&cli_settings,
|
|
&cli_layer,
|
|
process_local_json,
|
|
printer,
|
|
))
|
|
.await?;
|
|
}
|
|
Commands::Uninstall(args) => {
|
|
commands::uninstall::run_uninstall(&args, &cli_settings, printer).await?;
|
|
}
|
|
Commands::Auth(ns) => {
|
|
commands::auth::dispatch(
|
|
ns,
|
|
&cli_settings,
|
|
&cli_layer,
|
|
process_local_json,
|
|
printer,
|
|
)
|
|
.await?;
|
|
}
|
|
Commands::Pr(ns) => {
|
|
Box::pin(commands::pr::dispatch(
|
|
ns,
|
|
&cli_settings,
|
|
&cli_layer,
|
|
printer,
|
|
))
|
|
.await?;
|
|
}
|
|
Commands::Secret(ns) => {
|
|
commands::secret::dispatch(ns, &cli_settings, &cli_layer, printer).await?;
|
|
}
|
|
Commands::Settings(args) => {
|
|
Box::pin(commands::config::execute(
|
|
&args,
|
|
&cli_settings,
|
|
&cli_layer,
|
|
printer,
|
|
))
|
|
.await?;
|
|
}
|
|
Commands::Workflow(ns) => commands::workflow::dispatch(ns, &cli_settings, printer)?,
|
|
Commands::Upgrade(args) => {
|
|
commands::upgrade::run_upgrade(args, &cli_settings, printer).await?;
|
|
}
|
|
Commands::Provider(ns) => {
|
|
commands::provider::dispatch(
|
|
ns,
|
|
&cli_settings,
|
|
&cli_layer,
|
|
process_local_json,
|
|
printer,
|
|
)
|
|
.await?;
|
|
}
|
|
Commands::Sandbox { command } => {
|
|
commands::sandbox::dispatch(
|
|
command,
|
|
&cli_settings,
|
|
&cli_layer,
|
|
process_local_json,
|
|
printer,
|
|
)
|
|
.await?;
|
|
}
|
|
Commands::System(ns) => {
|
|
commands::system::dispatch(ns, &cli_settings, &cli_layer, printer).await?;
|
|
}
|
|
Commands::Completion(args) => {
|
|
require_no_json_override(process_local_json)?;
|
|
let mut cmd = Cli::command();
|
|
let shell = args.shell;
|
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
let mut buf = Vec::new();
|
|
clap_complete::generate(shell, &mut cmd, "fabro", &mut buf);
|
|
buf
|
|
}));
|
|
match result {
|
|
Ok(buf) => {
|
|
#[expect(
|
|
clippy::disallowed_types,
|
|
clippy::disallowed_methods,
|
|
reason = "sync CLI: stream shell completions to stdout"
|
|
)]
|
|
{
|
|
use std::io::Write;
|
|
std::io::stdout().write_all(&buf)?;
|
|
}
|
|
}
|
|
Err(_) => {
|
|
anyhow::bail!(
|
|
"Failed to generate completions for {shell}. \
|
|
Try zsh, fish, elvish, or powershell instead."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Commands::SendAnalytics { path } => {
|
|
let result = sender::upload(&path).await;
|
|
let _ = std::fs::remove_file(&path);
|
|
result?;
|
|
}
|
|
Commands::SendPanic { path } => {
|
|
let result = tel_panic::capture(&path);
|
|
let _ = std::fs::remove_file(&path);
|
|
result?;
|
|
}
|
|
Commands::RenderGraph => unreachable!("__render-graph handled before CLI bootstrap"),
|
|
#[cfg(debug_assertions)]
|
|
Commands::TestPanic { message } => {
|
|
let event = tel_panic::build_event(&message);
|
|
let json = serde_json::to_string_pretty(&event)?;
|
|
fabro_util::printout!(printer, "{json}");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
})
|
|
.await;
|
|
|
|
// Print upgrade notice after command completes (non-blocking during execution)
|
|
if let Some(handle) = upgrade_handle {
|
|
let _ = handle.await;
|
|
}
|
|
|
|
(command_name, result)
|
|
}
|
|
|
|
struct PreTracingBootstrap {
|
|
sink: logging::InternalLogSink,
|
|
config_log_level: Option<String>,
|
|
foreground_server_log_bootstrap: Option<commands::server::start::ForegroundServerLogBootstrap>,
|
|
}
|
|
|
|
impl PreTracingBootstrap {
|
|
fn cli() -> Self {
|
|
Self {
|
|
sink: logging::InternalLogSink::Cli,
|
|
config_log_level: None,
|
|
foreground_server_log_bootstrap: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn pre_tracing_bootstrap(command: &Commands) -> Result<PreTracingBootstrap> {
|
|
match command {
|
|
Commands::Server(ServerNamespace {
|
|
command: ServerCommand::Start(args),
|
|
}) if args.foreground => {
|
|
prepare_server_bootstrap(
|
|
args.serve_args.config.as_deref(),
|
|
args.storage_dir.as_deref(),
|
|
true,
|
|
)
|
|
.await
|
|
}
|
|
Commands::Server(ServerNamespace {
|
|
command: ServerCommand::Restart(args),
|
|
}) if args.foreground => {
|
|
prepare_server_bootstrap(
|
|
args.serve_args.config.as_deref(),
|
|
args.storage_dir.as_deref(),
|
|
true,
|
|
)
|
|
.await
|
|
}
|
|
Commands::Server(ServerNamespace {
|
|
command: ServerCommand::Serve(args),
|
|
}) => {
|
|
prepare_server_bootstrap(
|
|
args.serve_args.config.as_deref(),
|
|
args.storage_dir.as_deref(),
|
|
false,
|
|
)
|
|
.await
|
|
}
|
|
_ => Ok(PreTracingBootstrap::cli()),
|
|
}
|
|
}
|
|
|
|
async fn prepare_server_bootstrap(
|
|
config_path: Option<&std::path::Path>,
|
|
storage_dir: Option<&std::path::Path>,
|
|
foreground: bool,
|
|
) -> Result<PreTracingBootstrap> {
|
|
let settings =
|
|
user_config::load_settings_with_config_and_storage_dir(config_path, storage_dir)?;
|
|
let storage_dir = local_server::storage_dir(&settings)?;
|
|
let runtime_state = fabro_config::ServerRuntimeState::new(storage_dir.clone());
|
|
let foreground_server_log_bootstrap = if foreground {
|
|
Some(commands::server::start::prepare_foreground_server_log(&storage_dir).await?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(PreTracingBootstrap {
|
|
sink: logging::InternalLogSink::Server {
|
|
path: runtime_state.log_path(),
|
|
},
|
|
config_log_level: local_server::config_log_level(&settings),
|
|
foreground_server_log_bootstrap,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "main.rs tests stage CLI settings fixtures with sync std::fs::write"
|
|
)]
|
|
mod tests {
|
|
use args::{
|
|
AuthCommand, AuthNamespace, Commands, InstallGitHubStrategyArg, ModelsCommand,
|
|
ProviderCommand, ProviderNamespace, StoreCommand, StoreNamespace,
|
|
};
|
|
use tokio::runtime::Runtime;
|
|
|
|
use super::*;
|
|
|
|
fn runtime() -> Runtime {
|
|
Runtime::new().expect("runtime should build")
|
|
}
|
|
|
|
fn write_test_settings(path: &std::path::Path) {
|
|
std::fs::write(
|
|
path,
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.logging]
|
|
level = "warn"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn pre_tracing_bootstrap_uses_cli_sink_for_normal_cli_command() {
|
|
let cli = Cli::try_parse_from(["fabro", "uninstall"]).expect("should parse");
|
|
let command = cli.command.as_deref().unwrap();
|
|
|
|
let bootstrap = runtime()
|
|
.block_on(pre_tracing_bootstrap(command))
|
|
.expect("bootstrap should resolve");
|
|
|
|
assert_eq!(bootstrap.sink, logging::InternalLogSink::Cli);
|
|
assert!(bootstrap.config_log_level.is_none());
|
|
assert!(bootstrap.foreground_server_log_bootstrap.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_provider_login_openai() {
|
|
let cli = Cli::try_parse_from(["fabro", "provider", "login", "--provider", "openai"])
|
|
.expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::Provider(ProviderNamespace {
|
|
command: ProviderCommand::Login(args),
|
|
}) => {
|
|
assert_eq!(args.provider, fabro_model::Provider::OpenAi);
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_provider_login_anthropic() {
|
|
let cli = Cli::try_parse_from(["fabro", "provider", "login", "--provider", "anthropic"])
|
|
.expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::Provider(ProviderNamespace {
|
|
command: ProviderCommand::Login(args),
|
|
}) => {
|
|
assert_eq!(args.provider, fabro_model::Provider::Anthropic);
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_provider_login_api_key_stdin() {
|
|
let cli = Cli::try_parse_from([
|
|
"fabro",
|
|
"provider",
|
|
"login",
|
|
"--provider",
|
|
"anthropic",
|
|
"--api-key-stdin",
|
|
])
|
|
.expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::Provider(ProviderNamespace {
|
|
command: ProviderCommand::Login(args),
|
|
}) => {
|
|
assert_eq!(args.provider, fabro_model::Provider::Anthropic);
|
|
assert!(args.api_key_stdin);
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_auth_logout_all() {
|
|
let cli = Cli::try_parse_from(["fabro", "auth", "logout", "--all"]).expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::Auth(AuthNamespace {
|
|
command: AuthCommand::Logout(args),
|
|
}) => {
|
|
assert!(args.all);
|
|
assert!(args.server.server.is_none());
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_install_non_interactive_accepts_token_strategy() {
|
|
let cli = Cli::try_parse_from([
|
|
"fabro",
|
|
"install",
|
|
"--non-interactive",
|
|
"--llm-provider",
|
|
"anthropic",
|
|
"--llm-api-key-env",
|
|
"ANTHROPIC_API_KEY",
|
|
"--github-strategy",
|
|
"token",
|
|
"--github-username",
|
|
"brynary",
|
|
])
|
|
.expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::Install {
|
|
args,
|
|
command: None,
|
|
} => {
|
|
assert!(args.non_interactive);
|
|
assert_eq!(
|
|
args.scripted.github_strategy,
|
|
Some(InstallGitHubStrategyArg::Token)
|
|
);
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_install_github_non_interactive_accepts_token_strategy() {
|
|
let cli = Cli::try_parse_from([
|
|
"fabro",
|
|
"install",
|
|
"github",
|
|
"--non-interactive",
|
|
"--strategy",
|
|
"token",
|
|
])
|
|
.expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::Install {
|
|
args,
|
|
command: Some(args::InstallCommand::Github(github_args)),
|
|
} => {
|
|
assert!(args.non_interactive);
|
|
assert_eq!(github_args.strategy, Some(InstallGitHubStrategyArg::Token));
|
|
assert_eq!(github_args.owner, None);
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pre_tracing_bootstrap_uses_server_sink_for_server_start_foreground() {
|
|
let storage_dir = tempfile::tempdir().unwrap();
|
|
let config_dir = tempfile::tempdir().unwrap();
|
|
let config_path = config_dir.path().join("settings.toml");
|
|
write_test_settings(&config_path);
|
|
|
|
let cli = Cli::try_parse_from([
|
|
"fabro",
|
|
"server",
|
|
"start",
|
|
"--foreground",
|
|
"--storage-dir",
|
|
storage_dir.path().to_str().unwrap(),
|
|
"--config",
|
|
config_path.to_str().unwrap(),
|
|
])
|
|
.expect("should parse");
|
|
let command = cli.command.as_deref().unwrap();
|
|
|
|
let bootstrap = runtime()
|
|
.block_on(pre_tracing_bootstrap(command))
|
|
.expect("bootstrap should resolve");
|
|
|
|
assert_eq!(bootstrap.sink, logging::InternalLogSink::Server {
|
|
path: storage_dir.path().join("logs").join("server.log"),
|
|
});
|
|
assert_eq!(bootstrap.config_log_level.as_deref(), Some("warn"));
|
|
assert!(bootstrap.foreground_server_log_bootstrap.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn pre_tracing_bootstrap_uses_server_sink_for_server_restart_foreground() {
|
|
let storage_dir = tempfile::tempdir().unwrap();
|
|
let config_dir = tempfile::tempdir().unwrap();
|
|
let config_path = config_dir.path().join("settings.toml");
|
|
write_test_settings(&config_path);
|
|
|
|
let cli = Cli::try_parse_from([
|
|
"fabro",
|
|
"server",
|
|
"restart",
|
|
"--foreground",
|
|
"--storage-dir",
|
|
storage_dir.path().to_str().unwrap(),
|
|
"--config",
|
|
config_path.to_str().unwrap(),
|
|
])
|
|
.expect("should parse");
|
|
let command = cli.command.as_deref().unwrap();
|
|
|
|
let bootstrap = runtime()
|
|
.block_on(pre_tracing_bootstrap(command))
|
|
.expect("bootstrap should resolve");
|
|
|
|
assert_eq!(bootstrap.sink, logging::InternalLogSink::Server {
|
|
path: storage_dir.path().join("logs").join("server.log"),
|
|
});
|
|
assert_eq!(bootstrap.config_log_level.as_deref(), Some("warn"));
|
|
assert!(bootstrap.foreground_server_log_bootstrap.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn pre_tracing_bootstrap_uses_server_sink_for_server_serve() {
|
|
let storage_dir = tempfile::tempdir().unwrap();
|
|
let config_dir = tempfile::tempdir().unwrap();
|
|
let config_path = config_dir.path().join("settings.toml");
|
|
write_test_settings(&config_path);
|
|
let record_path = storage_dir.path().join("server.json");
|
|
|
|
let cli = Cli::try_parse_from([
|
|
"fabro",
|
|
"server",
|
|
"__serve",
|
|
"--storage-dir",
|
|
storage_dir.path().to_str().unwrap(),
|
|
"--record-path",
|
|
record_path.to_str().unwrap(),
|
|
"--config",
|
|
config_path.to_str().unwrap(),
|
|
])
|
|
.expect("should parse");
|
|
let command = cli.command.as_deref().unwrap();
|
|
|
|
let bootstrap = runtime()
|
|
.block_on(pre_tracing_bootstrap(command))
|
|
.expect("bootstrap should resolve");
|
|
|
|
assert_eq!(bootstrap.sink, logging::InternalLogSink::Server {
|
|
path: storage_dir.path().join("logs").join("server.log"),
|
|
});
|
|
assert_eq!(bootstrap.config_log_level.as_deref(), Some("warn"));
|
|
assert!(bootstrap.foreground_server_log_bootstrap.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn pre_tracing_bootstrap_uses_cli_sink_for_server_start_daemon_wrapper() {
|
|
let storage_dir = tempfile::tempdir().unwrap();
|
|
let config_dir = tempfile::tempdir().unwrap();
|
|
let config_path = config_dir.path().join("settings.toml");
|
|
write_test_settings(&config_path);
|
|
|
|
let cli = Cli::try_parse_from([
|
|
"fabro",
|
|
"server",
|
|
"start",
|
|
"--storage-dir",
|
|
storage_dir.path().to_str().unwrap(),
|
|
"--config",
|
|
config_path.to_str().unwrap(),
|
|
])
|
|
.expect("should parse");
|
|
let command = cli.command.as_deref().unwrap();
|
|
|
|
let bootstrap = runtime()
|
|
.block_on(pre_tracing_bootstrap(command))
|
|
.expect("bootstrap should resolve");
|
|
|
|
assert_eq!(bootstrap.sink, logging::InternalLogSink::Cli);
|
|
assert!(bootstrap.config_log_level.is_none());
|
|
assert!(bootstrap.foreground_server_log_bootstrap.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn pre_tracing_bootstrap_uses_cli_sink_for_server_restart_daemon_wrapper() {
|
|
let storage_dir = tempfile::tempdir().unwrap();
|
|
let config_dir = tempfile::tempdir().unwrap();
|
|
let config_path = config_dir.path().join("settings.toml");
|
|
write_test_settings(&config_path);
|
|
|
|
let cli = Cli::try_parse_from([
|
|
"fabro",
|
|
"server",
|
|
"restart",
|
|
"--storage-dir",
|
|
storage_dir.path().to_str().unwrap(),
|
|
"--config",
|
|
config_path.to_str().unwrap(),
|
|
])
|
|
.expect("should parse");
|
|
let command = cli.command.as_deref().unwrap();
|
|
|
|
let bootstrap = runtime()
|
|
.block_on(pre_tracing_bootstrap(command))
|
|
.expect("bootstrap should resolve");
|
|
|
|
assert_eq!(bootstrap.sink, logging::InternalLogSink::Cli);
|
|
assert!(bootstrap.config_log_level.is_none());
|
|
assert!(bootstrap.foreground_server_log_bootstrap.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_provider_login_missing_provider_flag() {
|
|
let result = Cli::try_parse_from(["fabro", "provider", "login"]);
|
|
assert!(result.is_err(), "should fail without --provider");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_provider_login_bogus_provider() {
|
|
let result = Cli::try_parse_from(["fabro", "provider", "login", "--provider", "bogus"]);
|
|
assert!(result.is_err(), "should fail with unknown provider");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_create_command() {
|
|
let cli = Cli::try_parse_from(["fabro", "create", "my-workflow.toml", "--goal", "test"])
|
|
.expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::RunCmd(RunCommands::Create(args)) => {
|
|
assert_eq!(
|
|
args.workflow.as_deref(),
|
|
Some(std::path::Path::new("my-workflow.toml"))
|
|
);
|
|
assert_eq!(args.goal.as_deref(), Some("test"));
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_run_storage_dir_after_subcommand_is_rejected() {
|
|
let result = Cli::try_parse_from([
|
|
"fabro",
|
|
"run",
|
|
"test/simple.fabro",
|
|
"--storage-dir",
|
|
"/tmp/fabro",
|
|
]);
|
|
assert!(result.is_err(), "should reject run --storage-dir");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_model_list_server_target_after_subcommand() {
|
|
let cli = Cli::try_parse_from([
|
|
"fabro",
|
|
"model",
|
|
"list",
|
|
"--server",
|
|
"http://localhost:3000/api/v1",
|
|
])
|
|
.expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::Model {
|
|
command: Some(ModelsCommand::List(args)),
|
|
} => assert_eq!(args.target.as_deref(), Some("http://localhost:3000/api/v1")),
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_exec_server_target_after_subcommand() {
|
|
let cli = Cli::try_parse_from([
|
|
"fabro",
|
|
"exec",
|
|
"--server",
|
|
"http://localhost:3000/api/v1",
|
|
"fix the bug",
|
|
])
|
|
.expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::Exec(args) => {
|
|
assert_eq!(args.server.as_deref(), Some("http://localhost:3000/api/v1"));
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_model_server_target_conflicts_with_storage_dir() {
|
|
let result = Cli::try_parse_from([
|
|
"fabro",
|
|
"model",
|
|
"list",
|
|
"--storage-dir",
|
|
"/tmp/fabro",
|
|
"--server",
|
|
"http://localhost:3000",
|
|
]);
|
|
assert!(
|
|
result.is_err(),
|
|
"should fail with conflicting model target flags"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_global_server_target_before_subcommand_is_rejected() {
|
|
let result = Cli::try_parse_from([
|
|
"fabro",
|
|
"--server",
|
|
"http://localhost:3000/api/v1",
|
|
"model",
|
|
"list",
|
|
]);
|
|
assert!(result.is_err(), "should reject top-level --server");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_global_storage_dir_before_subcommand_is_rejected() {
|
|
let result = Cli::try_parse_from([
|
|
"fabro",
|
|
"--storage-dir",
|
|
"/tmp/fabro",
|
|
"run",
|
|
"test/simple.fabro",
|
|
]);
|
|
assert!(result.is_err(), "should reject top-level --storage-dir");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_upgrade_prerelease_conflicts_with_version() {
|
|
use clap::error::ErrorKind;
|
|
let err = Cli::try_parse_from(["fabro", "upgrade", "--prerelease", "--version", "0.1.0"])
|
|
.err()
|
|
.expect("should reject --prerelease combined with --version");
|
|
assert_eq!(
|
|
err.kind(),
|
|
ErrorKind::ArgumentConflict,
|
|
"expected ArgumentConflict, got {:?}: {err}",
|
|
err.kind()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_store_dump_command() {
|
|
let cli = Cli::try_parse_from(["fabro", "store", "dump", "ABC123", "-o", "./out"])
|
|
.expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::Store(StoreNamespace {
|
|
command: StoreCommand::Dump(args),
|
|
}) => {
|
|
assert_eq!(args.run, "ABC123");
|
|
assert_eq!(args.output, std::path::PathBuf::from("./out"));
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_start_command() {
|
|
let cli = Cli::try_parse_from(["fabro", "start", "ABC123"]).expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::RunCmd(RunCommands::Start(args)) => {
|
|
assert_eq!(args.run, "ABC123");
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_attach_command() {
|
|
let cli = Cli::try_parse_from(["fabro", "attach", "ABC123"]).expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::RunCmd(RunCommands::Attach(args)) => {
|
|
assert_eq!(args.run, "ABC123");
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_sandbox_cp_command() {
|
|
let cli = Cli::try_parse_from(["fabro", "sandbox", "cp", "ABC123:/tmp/file", "./file"])
|
|
.expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::Sandbox {
|
|
command: args::SandboxCommand::Cp(args),
|
|
} => {
|
|
assert_eq!(args.src, "ABC123:/tmp/file");
|
|
assert_eq!(args.dst, "./file");
|
|
assert!(!args.recursive);
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_run_worker_command() {
|
|
let cli = Cli::try_parse_from([
|
|
"fabro",
|
|
"__run-worker",
|
|
"--server",
|
|
"/tmp/fabro.sock",
|
|
"--artifact-upload-token",
|
|
"token-123",
|
|
"--run-dir",
|
|
"/tmp/run",
|
|
"--run-id",
|
|
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
"--mode",
|
|
"start",
|
|
])
|
|
.expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::RunCmd(RunCommands::RunWorker(args)) => {
|
|
assert_eq!(args.server, "/tmp/fabro.sock");
|
|
assert_eq!(args.artifact_upload_token.as_deref(), Some("token-123"));
|
|
assert_eq!(args.run_dir, std::path::PathBuf::from("/tmp/run"));
|
|
assert_eq!(args.run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap());
|
|
assert!(matches!(args.mode, args::RunWorkerMode::Start));
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_run_worker_with_resume_mode() {
|
|
let cli = Cli::try_parse_from([
|
|
"fabro",
|
|
"__run-worker",
|
|
"--server",
|
|
"http://127.0.0.1:3000",
|
|
"--run-dir",
|
|
"/tmp/run",
|
|
"--run-id",
|
|
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
"--mode",
|
|
"resume",
|
|
])
|
|
.expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::RunCmd(RunCommands::RunWorker(args)) => {
|
|
assert_eq!(args.server, "http://127.0.0.1:3000");
|
|
assert!(args.artifact_upload_token.is_none());
|
|
assert_eq!(args.run_dir, std::path::PathBuf::from("/tmp/run"));
|
|
assert_eq!(args.run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap());
|
|
assert!(matches!(args.mode, args::RunWorkerMode::Resume));
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_render_graph_command() {
|
|
let cli = Cli::try_parse_from(["fabro", "__render-graph"]).expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::RenderGraph => {}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_settings_command() {
|
|
let cli = Cli::try_parse_from(["fabro", "settings"]).expect("should parse");
|
|
assert_eq!(cli.command.as_ref().unwrap().name(), "settings");
|
|
match *cli.command.unwrap() {
|
|
Commands::Settings(args) => {
|
|
assert!(!args.local);
|
|
assert!(args.target.server.is_none());
|
|
assert!(args.workflow.is_none());
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_settings_with_workflow() {
|
|
let cli = Cli::try_parse_from(["fabro", "settings", "demo"]).expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::Settings(args) => {
|
|
assert_eq!(args.workflow, Some(std::path::PathBuf::from("demo")));
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_settings_local_mode() {
|
|
let cli =
|
|
Cli::try_parse_from(["fabro", "settings", "--local", "demo"]).expect("should parse");
|
|
match *cli.command.unwrap() {
|
|
Commands::Settings(args) => {
|
|
assert!(args.local);
|
|
assert_eq!(args.workflow, Some(std::path::PathBuf::from("demo")));
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_quiet_flag() {
|
|
let cli = Cli::try_parse_from(["fabro", "--quiet", "settings"]).expect("should parse");
|
|
assert!(cli.globals.quiet);
|
|
assert!(!cli.globals.verbose);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_verbose_flag() {
|
|
let cli = Cli::try_parse_from(["fabro", "--verbose", "settings"]).expect("should parse");
|
|
assert!(!cli.globals.quiet);
|
|
assert!(cli.globals.verbose);
|
|
}
|
|
|
|
#[test]
|
|
fn quiet_and_verbose_conflict() {
|
|
let result = Cli::try_parse_from(["fabro", "--quiet", "--verbose", "settings"]);
|
|
assert!(
|
|
result.is_err(),
|
|
"should fail when both --quiet and --verbose"
|
|
);
|
|
}
|
|
}
|