use anyhow::{bail, Context, Result};
use clap::{Args, Parser, Subcommand};
use laterite_auth::{store, AuthConfig, AuthService, NewOperator};
use laterite_core::config::DatabaseConfig;
use laterite_core::Db;
mod doctor;
mod domain;
mod i18n;
mod make;
mod new;
mod plugin;
mod project;
mod serve;
mod theme;
#[derive(Parser)]
#[command(name = "lat", version, about = "The Laterite command-line tool")]
struct Cli {
#[arg(long, global = true, env = "DATABASE_URL")]
database_url: Option<String>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
New(new::NewArgs),
#[command(name = "make:migration")]
MakeMigration(make::MakeMigrationArgs),
#[command(name = "make:plugin")]
MakePlugin(make::MakePluginArgs),
#[command(name = "make:resource")]
MakeResource(make::MakeResourceArgs),
Serve(serve::ServeArgs),
Domain(domain::DomainArgs),
Doctor,
Admin {
#[command(subcommand)]
command: AdminCommand,
},
Plugin {
#[command(subcommand)]
command: plugin::PluginCommand,
},
I18n(i18n::I18nArgs),
Theme {
#[command(subcommand)]
command: theme::ThemeCommand,
},
}
#[derive(Subcommand)]
enum AdminCommand {
Create(CreateArgs),
ResetPassword(ResetArgs),
List,
Unlock {
username: String,
},
Purge,
}
#[derive(Args)]
struct CreateArgs {
username: String,
#[arg(long)]
email: String,
#[arg(long)]
first_name: String,
#[arg(long)]
last_name: Option<String>,
#[arg(long)]
password: Option<String>,
#[arg(long, conflicts_with = "password")]
generate: bool,
}
#[derive(Args)]
struct ResetArgs {
username: String,
#[arg(long)]
password: Option<String>,
#[arg(long, conflicts_with = "password")]
generate: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::New(args) => new::run(args).await,
Command::MakeMigration(args) => make::run(args),
Command::MakePlugin(args) => make::run_plugin(args),
Command::MakeResource(args) => make::run_resource(args),
Command::Serve(args) => serve::run(args),
Command::Domain(args) => domain::run(args),
Command::Doctor => doctor::run().await,
Command::Admin { command } => run_admin(command, cli.database_url).await,
Command::Plugin { command } => plugin::run(command),
Command::I18n(args) => i18n::run(args),
Command::Theme { command } => {
let db = connect(cli.database_url).await?;
theme::run(command, db).await
}
}
}
async fn run_admin(command: AdminCommand, database_url: Option<String>) -> Result<()> {
let pool = connect(database_url).await?;
match command {
AdminCommand::Create(args) => {
let plain = resolve_password(args.password, args.generate)?;
let auth = AuthService::new(pool, auth_from_project());
let id = auth
.create_superuser(NewOperator {
username: &args.username,
email: &args.email,
first_name: &args.first_name,
last_name: args.last_name.as_deref(),
password: &plain,
timezone: None,
})
.await
.context("could not create backend user")?;
let actor = laterite_core::Actor::system("lat admin create");
let target = id.to_string();
auth.record_audit(
laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
"backend_user",
&target,
Some(&args.username),
),
)
.await
.context("could not record the audit entry")?;
if args.generate {
auth.require_password_change(id).await?;
}
println!("Created backend superuser '{}' ({id})", args.username);
if args.generate {
println!("The generated password must be changed at first sign-in");
}
}
AdminCommand::ResetPassword(args) => {
let plain = resolve_password(args.password, args.generate)?;
let svc = AuthService::new(pool.clone(), auth_from_project());
if !svc
.reset_password(
&args.username,
&plain,
&laterite_core::Actor::system("lat admin reset-password"),
)
.await?
{
bail!("no backend user named '{}'", args.username);
}
if args.generate {
if let Some(user) = store::find_user_by_username(&pool, &args.username).await? {
svc.require_password_change(user.id).await?;
}
}
println!(
"Password reset for '{}'; every session and stay-signed-in device was signed out",
args.username
);
if args.generate {
println!("The generated password must be changed at first sign-in");
}
}
AdminCommand::Purge => {
let auth = AuthService::new(pool, auth_from_project());
let purged = auth.purge_expired().await?;
println!(
"Removed {} expired sessions and {} expired stay-signed-in credentials",
purged.sessions, purged.remember_tokens
);
}
AdminCommand::List => {
let users = store::list_backend_users(&pool).await?;
if users.is_empty() {
println!("No backend users.");
return Ok(());
}
for u in users {
let role = if u.is_superuser { "superuser" } else { "user" };
let state = if u.is_active { "active" } else { "inactive" };
println!("{:<20} {:<30} {:<10} {}", u.username, u.email, role, state);
}
}
AdminCommand::Unlock { username } => {
let cleared = store::clear_failed_attempts(&pool, &username).await?;
println!("Cleared {cleared} failed-login record(s) for '{username}'");
}
}
Ok(())
}
async fn connect(database_url: Option<String>) -> Result<Db> {
let config = match database_url {
Some(url) => DatabaseConfig {
url,
max_connections: 5,
acquire_timeout_secs: 5,
},
None => database_from_project()?,
};
laterite_core::db::connect(&config)
.await
.context("could not connect to the database")
}
fn auth_from_project() -> AuthConfig {
#[derive(serde::Deserialize)]
struct AuthOnly {
#[serde(default)]
auth: AuthConfig,
}
project::Project::locate()
.and_then(|project| project.load::<AuthOnly>())
.map(|config| config.auth)
.unwrap_or_default()
}
fn database_from_project() -> Result<DatabaseConfig> {
#[derive(serde::Deserialize)]
struct DatabaseOnly {
database: DatabaseConfig,
}
let project = project::Project::locate().context(
"no database URL: pass --database-url, set DATABASE_URL, or run inside a Laterite \
application",
)?;
let config: DatabaseOnly = project.load()?;
Ok(config.database)
}
fn resolve_password(explicit: Option<String>, generate: bool) -> Result<String> {
if let Some(password) = explicit {
if password.is_empty() {
bail!("password must not be empty");
}
return Ok(password);
}
if generate {
let password = laterite_auth::password::generate();
println!("Generated password: {password}");
return Ok(password);
}
let password = rpassword::prompt_password("Password: ")?;
let confirm = rpassword::prompt_password("Confirm password: ")?;
if password != confirm {
bail!("passwords do not match");
}
if password.is_empty() {
bail!("password must not be empty");
}
Ok(password)
}