Files
nx9-auth/src/cli/mod.rs
T

1000 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use anyhow::Context;
use std::io::{self, Write};
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use crate::{
config::Config,
db::{
self,
models::{Tenant, User, UserStatus},
},
error::AppError,
identity::users as identity_users,
security::tokens as token_security,
};
/// Resolve a user by ID or username (username lookup is case-sensitive, as stored).
async fn resolve_user(
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
id_or_username: &str,
) -> anyhow::Result<User> {
if let Some(user) = provider.users().find_by_id(id_or_username).await? {
return Ok(user);
}
if let Some(user) = provider.users().find_by_username(id_or_username).await? {
return Ok(user);
}
anyhow::bail!("User not found: '{id_or_username}' (use ID or username)");
}
// ── CLI Definition ────────────────────────────────────────────────────────────
#[derive(Parser)]
#[command(
name = "nx9-auth",
about = "nx9-auth \u{2014} Self-hosted Identity & Access Management",
version = env!("CARGO_PKG_VERSION"),
author,
)]
pub struct Cli {
/// Path to the configuration file.
#[arg(long, short, global = true, env = "NX9_AUTH_CONFIG")]
pub config: Option<PathBuf>,
/// Enable verbose logging for CLI commands.
#[arg(long, short, global = true)]
pub verbose: bool,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Start the HTTP server (API + Admin UI).
Serve,
/// Run pending database migrations.
Migrate,
/// Check system health and configuration.
Doctor,
/// Create the initial administrator account.
CreateAdmin {
/// Username for the new admin account.
username: String,
},
/// Create a new user account.
CreateUser {
/// Username for the new user account.
username: String,
},
/// List all users.
ListUsers,
/// Disable a user account.
DisableUser {
/// User ID or username to disable.
id_or_username: String,
},
/// Enable a user account.
EnableUser {
/// User ID or username to enable.
id_or_username: String,
},
/// Reset a user's password.
ResetPassword {
/// User ID or username to reset.
id_or_username: String,
},
/// Create a personal access token for a user.
CreateToken {
/// User ID or username to create the token for.
#[arg(long)]
user: String,
/// Descriptive name for the token.
#[arg(long, default_value = "CLI token")]
name: String,
},
/// Revoke a personal access token by ID.
RevokeToken {
/// Token ID to revoke.
id: String,
},
/// Initialize config, database, and admin user.
Init {
/// Run in non-interactive mode.
#[arg(long)]
non_interactive: bool,
/// Skip administrator user creation.
#[arg(long)]
skip_admin: bool,
/// Force initialization even if already initialized (overwrites existing configuration).
#[arg(long)]
force: bool,
/// Administrator username (required in non-interactive mode unless --skip-admin is set).
#[arg(long)]
admin_user: Option<String>,
/// Administrator password (required in non-interactive mode unless --skip-admin is set).
#[arg(long)]
admin_password: Option<String>,
},
/// Show configuration and database paths.
ConfigPath {
/// Output in machine-readable JSON format.
#[arg(long)]
json: bool,
},
/// Show details of a user by ID or username.
ShowUser {
/// User ID or username.
id_or_username: String,
/// Display all granular permissions and roles.
#[arg(long)]
permissions: bool,
},
/// Show details of a personal access token by ID.
ShowToken {
/// Token ID.
id: String,
},
/// Backup the database to a target path.
Backup {
/// Path where the backup file will be created.
path: PathBuf,
},
/// Restore the database from a backup file.
Restore {
/// Path to the backup file to restore from.
path: PathBuf,
},
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Securely prompt for a password (no echo).
fn prompt_password(prompt: &str) -> anyhow::Result<String> {
print!("{prompt}");
io::stdout().flush()?;
// Use rpassword-style reading without the dep: read from /dev/tty or stdin
// For CLI use, we read a line and trim it (terminals will hide input for
// proper TTY usage; a future version can add rpassword)
let mut pw = String::new();
io::stdin().read_line(&mut pw)?;
Ok(pw.trim().to_string())
}
fn prompt_password_confirmed(prompt: &str, is_admin: bool) -> anyhow::Result<String> {
let pw1 = prompt_password(prompt)?;
let pw2 = prompt_password("Confirm password: ")?;
if pw1 != pw2 {
anyhow::bail!("passwords do not match");
}
crate::security::passwords::validate_password_strength(&pw1, is_admin)
.map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(pw1)
}
// ── Command Handlers ──────────────────────────────────────────────────────────
pub async fn run(command: Commands, config: Config) -> anyhow::Result<()> {
match command {
Commands::Serve => {
// Handled in main.rs — should not be reached here
unreachable!("serve is handled in main")
}
Commands::Migrate => cmd_migrate(&config).await,
Commands::Doctor => cmd_doctor(&config).await,
Commands::CreateAdmin { username } => cmd_create_admin(&config, &username).await,
Commands::CreateUser { username } => cmd_create_user(&config, &username).await,
Commands::ListUsers => cmd_list_users(&config).await,
Commands::DisableUser { id_or_username } => {
cmd_set_status(&config, &id_or_username, UserStatus::Disabled).await
}
Commands::EnableUser { id_or_username } => {
cmd_set_status(&config, &id_or_username, UserStatus::Active).await
}
Commands::ResetPassword { id_or_username } => {
cmd_reset_password(&config, &id_or_username).await
}
Commands::CreateToken { user, name } => cmd_create_token(&config, &user, &name).await,
Commands::RevokeToken { id } => cmd_revoke_token(&config, &id).await,
Commands::Init {
non_interactive,
skip_admin,
force,
admin_user,
admin_password,
} => {
cmd_init(
&config,
non_interactive,
skip_admin,
force,
admin_user.as_deref(),
admin_password.as_deref(),
)
.await
}
Commands::ConfigPath { json } => cmd_config_path(&config, json).await,
Commands::ShowUser {
id_or_username,
permissions,
} => cmd_show_user(&config, &id_or_username, permissions).await,
Commands::ShowToken { id } => cmd_show_token(&config, &id).await,
Commands::Backup { path } => cmd_backup(&config, &path).await,
Commands::Restore { path } => cmd_restore(&config, &path).await,
}
}
// ── migrate ───────────────────────────────────────────────────────────────────
async fn cmd_migrate(config: &Config) -> anyhow::Result<()> {
let (_provider, backend, _pool) = db::init_provider(config).await?;
println!("✓ Migrations applied successfully ({backend}).");
Ok(())
}
// ── doctor ────────────────────────────────────────────────────────────────────
async fn run_doctor_checks(config: &Config) -> anyhow::Result<bool> {
let mut ok = true;
println!("\nnx9-auth doctor\n");
// 1. Config file loads
println!(" ✓ Config file loads and parses");
// 2. DB backend & connection
let (url, backend) = match config.database.resolved_url() {
Ok(res) => res,
Err(e) => {
println!(" ✗ Failed to resolve database configuration: {e}");
println!("\nDoctor result: FAIL\n");
return Ok(false);
}
};
println!(" ✓ Database backend detected: {backend}");
println!(" ✓ Database URL: {url}");
let provider = match db::init_provider(config).await {
Ok((p, _, _)) => {
println!(" ✓ Database connection & migrations successful");
p
}
Err(e) => {
println!(" ✗ Database initialization failed: {e}");
println!("\nDoctor result: FAIL\n");
return Ok(false);
}
};
// 5. Default tenant exists
match provider.tenants().find_by_id(Tenant::DEFAULT_ID).await {
Ok(Some(_)) => println!(" ✓ Default tenant exists"),
_ => {
println!(" ✗ Default tenant missing — run `nx9-auth migrate`");
ok = false;
}
}
// 6. Admin role exists
match provider.roles().admin_role_exists().await {
Ok(true) => println!(" ✓ admin role exists"),
Ok(false) => {
println!(" ✗ admin role missing — run `nx9-auth migrate`");
ok = false;
}
Err(e) => {
println!(" ✗ role check failed: {}", e);
ok = false;
}
}
// 7. At least one admin user exists
match provider.users().count_admins().await {
Ok(n) if n > 0 => println!(" ✓ {} admin user(s) exist", n),
Ok(_) => {
println!(" ✗ No admin users — run `nx9-auth create-admin <username>`");
ok = false;
}
Err(e) => {
println!(" ✗ admin count failed: {}", e);
ok = false;
}
}
println!();
if ok {
println!("Doctor result: OK\n");
} else {
println!("Doctor result: FAIL\n");
}
Ok(ok)
}
async fn cmd_doctor(config: &Config) -> anyhow::Result<()> {
let ok = run_doctor_checks(config).await?;
if !ok {
std::process::exit(1);
}
Ok(())
}
// ── create-admin ──────────────────────────────────────────────────────────────
async fn cmd_create_admin(config: &Config, username: &str) -> anyhow::Result<()> {
let (provider, _backend, _pool) = db::init_provider(config).await?;
let password = prompt_password_confirmed("Password for admin: ", true)?;
let user = identity_users::create_user(
&provider,
&config.security,
Tenant::DEFAULT_ID,
username,
&password,
None,
None,
None,
)
.await?;
crate::identity::roles::assign_role(&provider, &user.id, "admin", None, None, None).await?;
println!("✓ Admin user '{}' created (id: {})", user.username, user.id);
Ok(())
}
// ── create-user ───────────────────────────────────────────────────────────────
async fn cmd_create_user(config: &Config, username: &str) -> anyhow::Result<()> {
let (provider, _backend, _pool) = db::init_provider(config).await?;
let password = prompt_password_confirmed("Password: ", false)?;
let user = identity_users::create_user(
&provider,
&config.security,
Tenant::DEFAULT_ID,
username,
&password,
None,
None,
None,
)
.await?;
println!("✓ User '{}' created (id: {})", user.username, user.id);
Ok(())
}
// ── list-users ────────────────────────────────────────────────────────────────
async fn cmd_list_users(config: &Config) -> anyhow::Result<()> {
let (provider, _backend, _pool) = db::init_provider(config).await?;
let users = provider.users().list(Tenant::DEFAULT_ID).await?;
if users.is_empty() {
println!("No users found.");
return Ok(());
}
// Table header
println!(
"\n{:<38} {:<24} {:<10} Created",
"ID", "Username", "Status"
);
println!("{}", "-".repeat(90));
for u in &users {
println!(
"{:<38} {:<24} {:<10} {}",
u.id,
u.username,
UserStatus::from_i32(u.status).as_str(),
&u.created_at[..10],
);
}
println!("\n{} user(s) total\n", users.len());
Ok(())
}
// ── disable/enable-user ───────────────────────────────────────────────────────
async fn cmd_set_status(
config: &Config,
id_or_username: &str,
status: UserStatus,
) -> anyhow::Result<()> {
let (provider, _backend, _pool) = db::init_provider(config).await?;
let user = resolve_user(&provider, id_or_username).await?;
identity_users::update_status(&provider, &user.id, status.as_i32(), None, None, None).await?;
println!(
"✓ User '{}' status set to {}",
user.username,
status.as_str()
);
Ok(())
}
// ── reset-password ────────────────────────────────────────────────────────────
async fn cmd_reset_password(config: &Config, id_or_username: &str) -> anyhow::Result<()> {
let (provider, _backend, _pool) = db::init_provider(config).await?;
let user = resolve_user(&provider, id_or_username).await?;
let user_roles = provider.roles().list_for_user(&user.id).await?;
let is_admin = user_roles.iter().any(|r| r.name == "admin");
let password =
prompt_password_confirmed(&format!("New password for '{}': ", user.username), is_admin)?;
identity_users::reset_password(
&provider,
&config.security,
&user.id,
&password,
None,
None,
None,
)
.await?;
println!("✓ Password reset for user '{}'", user.username);
Ok(())
}
// ── create-token ──────────────────────────────────────────────────────────────
async fn cmd_create_token(config: &Config, user_ref: &str, name: &str) -> anyhow::Result<()> {
let (provider, _backend, _pool) = db::init_provider(config).await?;
let user = resolve_user(&provider, user_ref).await?;
let (token, raw) = token_security::create_token(
&provider,
&user.id,
name,
&config.security,
None,
None,
None,
)
.await?;
println!(
"\nPersonal Access Token created for user '{}':",
user.username
);
println!(" Token ID: {}", token.id);
println!(" Name: {}", token.name);
println!(
" Expires at: {}",
token.expires_at.as_deref().unwrap_or("never")
);
println!();
println!(" Token: {}", raw);
println!();
println!("⚠ Store this token securely — it will not be shown again.");
println!();
Ok(())
}
// ── revoke-token ──────────────────────────────────────────────────────────────
async fn cmd_revoke_token(config: &Config, id: &str) -> anyhow::Result<()> {
let (provider, _backend, _pool) = db::init_provider(config).await?;
let token = provider
.tokens()
.find_by_id(id)
.await
.map_err(AppError::Database)?
.ok_or_else(|| anyhow::anyhow!("token not found: {}", id))?;
token_security::revoke_token(&provider, id, None, None, None).await?;
println!("✓ Token '{}' (id: {}) revoked", token.name, token.id);
Ok(())
}
// ── init ──────────────────────────────────────────────────────────────────────
async fn cmd_init(
config: &Config,
non_interactive: bool,
skip_admin: bool,
force: bool,
admin_user: Option<&str>,
admin_password: Option<&str>,
) -> anyhow::Result<()> {
println!("Initializing nx9-auth...\n");
// 1. Create config and data and state directories
let config_path = config
.config_path
.clone()
.or_else(Config::default_user_config_path);
if let Some(ref path) = config_path {
println!("Creating configuration directory...");
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
// Write default config if it doesn't exist or if forced
if !path.exists() || force {
let default_content = Config::generate_default_toml();
std::fs::write(path, default_content)?;
if force && path.exists() {
println!(
"✓ Overwrote config file with default settings at: {}",
path.display()
);
} else {
println!("✓ Created default config file at: {}", path.display());
}
} else {
println!("✓ Configuration file already exists at: {}", path.display());
}
}
let sqlite_path = config.database.sqlite_path();
let db_path = std::path::Path::new(&sqlite_path);
println!("Creating database directory...");
if let Some(parent) = db_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
// Create state directory
if let Ok(home) = std::env::var("HOME") {
let state_dir = std::path::Path::new(&home).join(".local/state/nx9-auth");
std::fs::create_dir_all(&state_dir)?;
println!("✓ Created state directory at: {}", state_dir.display());
}
// 2. Open DB pool and run migrations
println!("Initializing database and migrations...");
let (provider, backend, _pool) = db::init_provider(config).await?;
println!("✓ Database initialized ({backend}).");
// 3. Create administrator
if skip_admin {
println!("ℹ Administrator creation skipped.");
} else {
let admin_count = provider.users().count_admins().await?;
if admin_count == 0 {
let username: String;
let password: String;
if non_interactive {
let u = admin_user.ok_or_else(|| {
anyhow::anyhow!("--admin-user is required in non-interactive mode")
})?;
let p = admin_password.ok_or_else(|| {
anyhow::anyhow!("--admin-password is required in non-interactive mode")
})?;
// Validate strength
crate::security::passwords::validate_password_strength(p, true)
.map_err(|e| anyhow::anyhow!("Password validation failed: {}", e))?;
username = u.to_string();
password = p.to_string();
} else {
println!("\nCreate administrator:");
print!("Username [admin]: ");
io::stdout().flush()?;
let mut u_in = String::new();
io::stdin().read_line(&mut u_in)?;
let u_trimmed = u_in.trim();
username = if u_trimmed.is_empty() {
"admin".to_string()
} else {
u_trimmed.to_string()
};
password = prompt_password_confirmed("Password: ", true)?;
}
let user = identity_users::create_user(
&provider,
&config.security,
Tenant::DEFAULT_ID,
&username,
&password,
None,
None,
None,
)
.await?;
crate::identity::roles::assign_role(&provider, &user.id, "admin", None, None, None)
.await?;
println!("✓ Admin user '{}' created successfully.", username);
} else {
println!("✓ Administrator account already exists.");
}
}
// 4. Run post-install validation (relaxed)
println!("\nValidation:");
let init_ok = run_init_validation(config, skip_admin).await?;
if !init_ok {
anyhow::bail!("Post-installation validation checks failed!");
}
println!("\nnx9-auth is ready.\n\nStart the server with:\n nx9-auth serve\n");
Ok(())
}
async fn run_init_validation(config: &Config, admin_skipped: bool) -> anyhow::Result<bool> {
let mut ok = true;
// 1. Config valid
println!(" ✓ Configuration");
// 2. Directories writable
let sqlite_path = config.database.sqlite_path();
let db_path = std::path::Path::new(&sqlite_path);
let mut dirs_ok = true;
if let Some(parent) = db_path.parent()
&& !parent.as_os_str().is_empty()
&& std::fs::create_dir_all(parent).is_err()
{
dirs_ok = false;
}
if let Ok(home) = std::env::var("HOME") {
let state_dir = std::path::Path::new(&home).join(".local/state/nx9-auth");
if std::fs::create_dir_all(&state_dir).is_err() {
dirs_ok = false;
}
}
if dirs_ok {
println!(" ✓ Directories");
} else {
println!(" ✗ Directories not writable");
ok = false;
}
// 3. Database & Migrations reachable
let provider = match db::init_provider(config).await {
Ok((p, _, _)) => {
println!(" ✓ Database");
println!(" ✓ Migrations");
p
}
Err(e) => {
println!(" ✗ Database connection failed: {}", e);
return Ok(false);
}
};
// 4. Admin account check
let admin_count = provider.users().count_admins().await.unwrap_or(0);
if admin_count > 0 {
println!(" ✓ Administrator account");
} else if admin_skipped {
println!(" ℹ Administrator creation skipped");
} else {
println!(" ✗ No administrator account exists");
ok = false;
}
Ok(ok)
}
// ── config-path ──────────────────────────────────────────────────────────────
async fn cmd_config_path(config: &Config, json: bool) -> anyhow::Result<()> {
let config_file = config
.config_path
.clone()
.or_else(Config::default_user_config_path)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let (database_url, _) = config.database.resolved_url().unwrap_or_else(|_| {
(
config.database.sqlite_path(),
crate::config::DatabaseBackend::Sqlite,
)
});
let state_dir = if let Ok(home) = std::env::var("HOME") {
std::path::Path::new(&home)
.join(".local/state/nx9-auth")
.to_string_lossy()
.into_owned()
} else {
"".to_string()
};
if json {
let val = serde_json::json!({
"config": config_file,
"database": database_url,
"state": state_dir,
});
println!("{}", serde_json::to_string_pretty(&val)?);
} else {
println!("\nConfig:");
println!(" {}", config_file);
println!("\nDatabase:");
println!(" {}", database_url);
println!("\nLogs/State:");
println!(" {}", state_dir);
println!();
}
Ok(())
}
// ── show-user ─────────────────────────────────────────────────────────────────
async fn cmd_show_user(
config: &Config,
id_or_username: &str,
permissions: bool,
) -> anyhow::Result<()> {
let (provider, _backend, _pool) = db::init_provider(config).await?;
let user = resolve_user(&provider, id_or_username).await?;
let user_roles = provider.roles().list_for_user(&user.id).await?;
let role_names: Vec<String> = user_roles.into_iter().map(|r| r.name).collect();
println!("\nUser");
println!("────");
println!("ID: {}", user.id);
println!("Username: {}", user.username);
println!("Status: {}", user.status().as_str());
println!("Created: {}", user.created_at);
println!(
"Last Login: {}",
user.last_login_at.as_deref().unwrap_or("never")
);
println!("\nRoles");
println!("─────");
if role_names.is_empty() {
println!("none");
} else {
for role in &role_names {
println!("{}", role);
}
}
if permissions {
println!("\nPermissions");
println!("───────────");
let user_perms = provider.permissions().list_for_user(&user.id).await?;
if user_perms.is_empty() {
println!("none");
} else {
for perm in user_perms {
println!("{}", perm);
}
}
}
println!();
Ok(())
}
// ── show-token ────────────────────────────────────────────────────────────────
async fn cmd_show_token(config: &Config, id: &str) -> anyhow::Result<()> {
let (provider, _backend, _pool) = db::init_provider(config).await?;
let token = provider
.tokens()
.find_by_id(id)
.await
.map_err(AppError::Database)?
.ok_or_else(|| anyhow::anyhow!("Token not found: {}", id))?;
let user = provider.users().find_by_id(&token.user_id).await?;
let username = user
.map(|u| u.username)
.unwrap_or_else(|| "unknown".to_string());
println!("\nToken");
println!("─────");
println!("ID: {}", token.id);
println!("Name: {}", token.name);
println!("User ID: {}", token.user_id);
println!("Username: {}", username);
println!(
"Status: {}",
if token.revoked { "revoked" } else { "active" }
);
println!(
"Expires: {}",
token.expires_at.as_deref().unwrap_or("never")
);
println!(
"Last Used: {}",
token.last_used_at.as_deref().unwrap_or("never")
);
println!("Created: {}", token.created_at);
println!();
Ok(())
}
// ── backup ────────────────────────────────────────────────────────────────────
async fn cmd_backup(config: &Config, path: &std::path::Path) -> anyhow::Result<()> {
let (url, backend) = config.database.resolved_url()?;
match backend {
crate::config::DatabaseBackend::Sqlite => {
let sqlite_path = config.database.sqlite_path();
let source_path = std::path::Path::new(&sqlite_path);
let abs_source =
std::fs::canonicalize(source_path).unwrap_or_else(|_| source_path.to_path_buf());
let abs_target = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
let source_dir = abs_source
.parent()
.ok_or_else(|| anyhow::anyhow!("invalid database source path"))?;
let source_file_name = abs_source
.file_name()
.ok_or_else(|| anyhow::anyhow!("invalid database file name"))?
.to_string_lossy();
let source_wal = source_dir.join(format!("{}-wal", source_file_name));
let source_shm = source_dir.join(format!("{}-shm", source_file_name));
if abs_target == abs_source {
anyhow::bail!(
"Backup destination cannot be the active database file: {}",
path.display()
);
}
if abs_target == source_wal {
anyhow::bail!(
"Backup destination cannot be the active WAL file: {}",
path.display()
);
}
if abs_target == source_shm {
anyhow::bail!(
"Backup destination cannot be the active SHM file: {}",
path.display()
);
}
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
if path.exists() {
std::fs::remove_file(path)?;
}
#[cfg(feature = "sqlite")]
{
let pool = db::create_pool(&sqlite_path).await?;
let path_str = path.to_string_lossy().replace('\'', "''");
let query = format!("VACUUM INTO '{}'", path_str);
sqlx::query(sqlx::AssertSqlSafe(query))
.execute(&pool)
.await?;
println!(
"✓ SQLite database backup created successfully at: {}",
path.display()
);
}
#[cfg(not(feature = "sqlite"))]
{
anyhow::bail!("SQLite database backups require the 'sqlite' feature");
}
}
crate::config::DatabaseBackend::Postgres => {
let output = std::process::Command::new("pg_dump")
.arg("-Fc")
.arg("-d")
.arg(&url)
.arg("-f")
.arg(path)
.output()
.context(
"failed to execute pg_dump (ensure PostgreSQL client tools are installed)",
)?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("pg_dump failed: {err}");
}
println!(
"✓ PostgreSQL database backup created successfully at: {}",
path.display()
);
}
}
Ok(())
}
async fn cmd_restore(config: &Config, path: &std::path::Path) -> anyhow::Result<()> {
if !path.exists() {
anyhow::bail!("Backup file does not exist: {}", path.display());
}
let (url, backend) = config.database.resolved_url()?;
match backend {
crate::config::DatabaseBackend::Sqlite => {
let sqlite_path = config.database.sqlite_path();
let target_path = std::path::Path::new(&sqlite_path);
if let Some(parent) = target_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
std::fs::copy(path, target_path).with_context(|| {
format!("failed to restore backup to {}", target_path.display())
})?;
println!(
"✓ SQLite database restored successfully from: {}",
path.display()
);
}
crate::config::DatabaseBackend::Postgres => {
let output = std::process::Command::new("pg_restore")
.arg("--clean")
.arg("--if-exists")
.arg("-d")
.arg(&url)
.arg(path)
.output()
.context(
"failed to execute pg_restore (ensure PostgreSQL client tools are installed)",
)?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("pg_restore failed: {err}");
}
println!(
"✓ PostgreSQL database restored successfully from: {}",
path.display()
);
}
}
Ok(())
}