use std::path::{Path, PathBuf};
use clap::Args;
const EXAMPLE_CONFIG: &str =
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/rolter.example.toml"));
#[derive(Args, Debug)]
pub struct EasyUpArgs {
#[arg(short, long, env = "ROLTER_CONFIG", default_value = "rolter.toml")]
pub config: PathBuf,
#[arg(long, default_value = "0.0.0.0")]
pub host: String,
#[arg(long, env = "ROLTER_PORT", default_value_t = 4000)]
pub gateway_port: u16,
#[arg(long, env = "ROLTER_CONTROL_PORT", default_value_t = 4001)]
pub control_port: u16,
#[arg(long, env = "ROLTER_UI_DIR", default_value = "ui/dist")]
pub ui_dir: PathBuf,
#[arg(long, env = "ROLTER_REDIS_URL")]
pub redis_url: Option<String>,
#[arg(long, env = "ROLTER_ADMIN_TOKEN")]
pub admin_token: Option<String>,
#[arg(long, env = "CLICKHOUSE_URL")]
pub clickhouse_url: Option<String>,
#[cfg(feature = "postgres")]
#[arg(long, env = "ROLTER_DATABASE_URL")]
pub database_url: Option<String>,
#[cfg(feature = "postgres")]
#[arg(long)]
pub admin_email: Option<String>,
#[cfg(feature = "postgres")]
#[arg(long)]
pub admin_password: Option<String>,
#[cfg(feature = "postgres")]
#[arg(long)]
pub import: Option<PathBuf>,
}
fn ensure_config(path: &Path) -> anyhow::Result<bool> {
if path.exists() {
return Ok(false);
}
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(path, EXAMPLE_CONFIG)?;
Ok(true)
}
fn gateway_args(args: &EasyUpArgs, db_mode: bool) -> rolter_gateway::Args {
rolter_gateway::Args {
config: args.config.clone(),
host: Some(args.host.clone()),
port: Some(args.gateway_port),
snapshot_url: db_mode
.then(|| format!("http://127.0.0.1:{}/internal/snapshot", args.control_port)),
snapshot_poll_secs: 5,
redis_url: args.redis_url.clone(),
admin_url: db_mode.then(|| format!("http://127.0.0.1:{}", args.control_port)),
admin_token: args.admin_token.clone(),
}
}
fn control_args(args: &EasyUpArgs, database_url: Option<String>) -> rolter_control::Args {
#[cfg(not(feature = "postgres"))]
let _ = &database_url;
rolter_control::Args {
host: args.host.clone(),
port: args.control_port,
ui_dir: args.ui_dir.clone(),
config: Some(args.config.clone()),
#[cfg(feature = "postgres")]
database_url,
redis_url: args.redis_url.clone(),
clickhouse_url: args.clickhouse_url.clone(),
admin_token: args.admin_token.clone(),
}
}
pub async fn run(args: EasyUpArgs) -> anyhow::Result<()> {
let created = ensure_config(&args.config)?;
if created {
tracing::info!(config = %args.config.display(), "created config from bundled example");
}
#[cfg(feature = "postgres")]
let database_url: Option<String> = args.database_url.clone();
#[cfg(not(feature = "postgres"))]
let database_url: Option<String> = None;
#[cfg(feature = "postgres")]
let admin_note: Option<String> = if let Some(url) = database_url.clone() {
use rolter_control::seed::{seed, SeedOptions};
use rolter_store::postgres::{connect, run_migrations};
tracing::info!("database mode: connecting, migrating and seeding");
let pool = connect(&url).await?;
run_migrations(&pool).await?;
let summary = seed(
&pool,
&SeedOptions {
org: "default".to_string(),
org_slug: None,
admin_email: args.admin_email.clone(),
admin_password: args.admin_password.clone(),
import: args.import.clone().or_else(|| Some(args.config.clone())),
},
)
.await?;
pool.close().await;
summary
.admin_created
.then_some(summary.admin_email)
.flatten()
} else {
None
};
#[cfg(not(feature = "postgres"))]
let admin_note: Option<String> = None;
let db_mode = database_url.is_some();
print_summary(&args, db_mode, admin_note.as_deref());
let control = rolter_control::run(control_args(&args, database_url));
let gateway = rolter_gateway::run(gateway_args(&args, db_mode));
tokio::try_join!(control, gateway)?;
Ok(())
}
fn print_summary(args: &EasyUpArgs, db_mode: bool, admin_created_email: Option<&str>) {
let mode = if db_mode { "database" } else { "file" };
eprintln!("\nrolter easy-up — {mode} mode");
eprintln!(
" gateway (OpenAI/Anthropic): http://{}:{}",
args.host, args.gateway_port
);
eprintln!(
" control + UI: http://{}:{}",
args.host, args.control_port
);
eprintln!(" config: {}", args.config.display());
if !db_mode {
eprintln!(" built-in model: fake-llm (no keys, no database)");
}
if let Some(email) = admin_created_email {
eprintln!(" admin user created: {email}");
}
eprintln!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundled_example_config_matches_workspace_root() {
let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../../rolter.example.toml");
let root_contents = std::fs::read_to_string(root).unwrap();
assert_eq!(
EXAMPLE_CONFIG, root_contents,
"crates/rolter/rolter.example.toml is out of sync with the workspace-root copy; \
re-run `cp rolter.example.toml crates/rolter/rolter.example.toml`"
);
}
#[test]
fn ensure_config_writes_when_missing_and_is_idempotent() {
let dir = std::env::temp_dir().join(format!("rolter-easyup-{}", std::process::id()));
let path = dir.join("rolter.toml");
let _ = std::fs::remove_dir_all(&dir);
assert!(ensure_config(&path).unwrap());
assert!(path.exists());
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(written, EXAMPLE_CONFIG);
assert!(!ensure_config(&path).unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn gateway_polls_snapshot_only_in_db_mode() {
let args = EasyUpArgs {
config: PathBuf::from("rolter.toml"),
host: "0.0.0.0".to_string(),
gateway_port: 4000,
control_port: 4001,
ui_dir: PathBuf::from("ui/dist"),
redis_url: None,
admin_token: None,
clickhouse_url: None,
#[cfg(feature = "postgres")]
database_url: None,
#[cfg(feature = "postgres")]
admin_email: None,
#[cfg(feature = "postgres")]
admin_password: None,
#[cfg(feature = "postgres")]
import: None,
};
assert!(gateway_args(&args, false).snapshot_url.is_none());
assert_eq!(
gateway_args(&args, true).snapshot_url.as_deref(),
Some("http://127.0.0.1:4001/internal/snapshot")
);
}
}