Skip to main content

rustauth_cli/
app.rs

1use std::ffi::OsString;
2use std::path::{Path, PathBuf};
3
4use clap::{Parser, Subcommand, ValueEnum};
5use clap_complete::Shell;
6
7use crate::config::{CliConfig, ConfigError};
8use crate::db::DbCliError;
9
10#[derive(Debug, Parser)]
11#[command(name = "rustauth", version, about = "Command-line tools for RustAuth.")]
12pub struct Cli {
13    #[arg(short = 'c', long, global = true, default_value = ".")]
14    cwd: PathBuf,
15    #[arg(long, global = true)]
16    config: Option<PathBuf>,
17    #[command(subcommand)]
18    command: Commands,
19}
20
21#[derive(Debug, Subcommand)]
22pub(crate) enum Commands {
23    Init(InitArgs),
24    Doctor(DiagnosticArgs),
25    Info(InfoArgs),
26    Secret(SecretArgs),
27    Db(DbArgs),
28    Generate(GenerateArgs),
29    Migrate(MigrateArgs),
30    Schema(SchemaArgs),
31    Plugins(PluginsArgs),
32    Completions(CompletionsArgs),
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
36pub(crate) enum InitFramework {
37    Axum,
38    #[value(name = "actix-web", alias = "actix_web")]
39    ActixWeb,
40}
41
42impl InitFramework {
43    pub(crate) fn as_config_str(self) -> &'static str {
44        match self {
45            Self::Axum => "axum",
46            Self::ActixWeb => "actix-web",
47        }
48    }
49}
50
51#[derive(Debug, clap::Args)]
52pub(crate) struct InitArgs {
53    /// HTTP framework for the integration snippet and `[project].framework` in `rustauth.toml`.
54    #[arg(long, required = true)]
55    pub(crate) framework: InitFramework,
56    #[arg(long)]
57    pub(crate) adapter: Option<String>,
58    #[arg(long)]
59    pub(crate) database: Option<String>,
60    #[arg(long)]
61    pub(crate) base_url: Option<String>,
62    #[arg(long, value_delimiter = ',')]
63    pub(crate) plugins: Vec<String>,
64    #[arg(short = 'y', long)]
65    pub(crate) yes: bool,
66    #[arg(long)]
67    pub(crate) force: bool,
68    /// Write a generated secret into a new `.env` (development convenience).
69    #[arg(long)]
70    pub(crate) seed_secrets: bool,
71}
72
73#[derive(Debug, clap::Args)]
74pub(crate) struct DiagnosticArgs {
75    #[arg(long)]
76    pub(crate) production: bool,
77    #[arg(long)]
78    pub(crate) json: bool,
79    #[arg(long)]
80    pub(crate) strict: bool,
81}
82
83#[derive(Debug, clap::Args)]
84pub(crate) struct InfoArgs {
85    #[arg(short = 'j', long)]
86    pub(crate) json: bool,
87    #[arg(short = 'C', long)]
88    pub(crate) copy: bool,
89}
90
91#[derive(Debug, clap::Args)]
92pub(crate) struct SecretArgs {
93    #[arg(long, default_value_t = 32)]
94    pub(crate) bytes: usize,
95    #[arg(long)]
96    pub(crate) check: Option<String>,
97    #[arg(long)]
98    pub(crate) check_env: Option<String>,
99    #[arg(long)]
100    pub(crate) env_line: bool,
101    /// When checking a secret, apply production-strength rules (default: true).
102    #[arg(long, default_value_t = true)]
103    pub(crate) production: bool,
104    /// Shorthand to check a secret with relaxed development rules.
105    #[arg(long, conflicts_with = "production")]
106    pub(crate) dev: bool,
107}
108
109#[derive(Debug, clap::Args)]
110pub(crate) struct DbArgs {
111    #[command(subcommand)]
112    pub(crate) command: DbCommands,
113}
114
115#[derive(Debug, Subcommand)]
116pub(crate) enum DbCommands {
117    Status(StatusArgs),
118    Generate(GenerateArgs),
119    Migrate(MigrateArgs),
120}
121
122#[derive(Debug, clap::Args)]
123pub(crate) struct StatusArgs {
124    #[arg(long)]
125    pub(crate) json: bool,
126    #[arg(long)]
127    pub(crate) check: bool,
128}
129
130#[derive(Debug, clap::Args)]
131pub(crate) struct GenerateArgs {
132    #[arg(long)]
133    pub(crate) output: Option<PathBuf>,
134    #[arg(long)]
135    pub(crate) output_dir: Option<PathBuf>,
136    #[arg(long)]
137    pub(crate) adapter: Option<String>,
138    #[arg(long)]
139    pub(crate) dialect: Option<String>,
140    #[arg(long)]
141    pub(crate) from_empty: bool,
142    #[arg(long)]
143    pub(crate) force: bool,
144    #[arg(short = 'y', long)]
145    pub(crate) yes: bool,
146}
147
148#[derive(Debug, clap::Args)]
149pub(crate) struct MigrateArgs {
150    #[arg(long)]
151    pub(crate) dry_run: bool,
152    #[arg(short = 'y', long)]
153    pub(crate) yes: bool,
154}
155
156#[derive(Debug, clap::Args)]
157pub(crate) struct SchemaArgs {
158    #[command(subcommand)]
159    pub(crate) command: SchemaCommands,
160}
161
162#[derive(Debug, Subcommand)]
163pub(crate) enum SchemaCommands {
164    Print(SchemaPrintArgs),
165}
166
167#[derive(Debug, clap::Args)]
168pub(crate) struct SchemaPrintArgs {
169    #[arg(long, value_enum, default_value_t = SchemaFormat::Sql)]
170    pub(crate) format: SchemaFormat,
171    #[arg(long, default_value = "sqlite")]
172    pub(crate) dialect: String,
173}
174
175#[derive(Debug, Clone, Copy, ValueEnum)]
176pub(crate) enum SchemaFormat {
177    Sql,
178    Json,
179}
180
181#[derive(Debug, clap::Args)]
182pub(crate) struct PluginsArgs {
183    #[command(subcommand)]
184    pub(crate) command: PluginsCommands,
185}
186
187#[derive(Debug, Subcommand)]
188pub(crate) enum PluginsCommands {
189    List(PluginListArgs),
190    Add(PluginChangeArgs),
191    Remove(PluginChangeArgs),
192}
193
194#[derive(Debug, clap::Args)]
195pub(crate) struct PluginListArgs {
196    #[arg(long)]
197    pub(crate) json: bool,
198}
199
200#[derive(Debug, clap::Args)]
201pub(crate) struct PluginChangeArgs {
202    pub(crate) plugin: String,
203    #[arg(short = 'y', long)]
204    pub(crate) yes: bool,
205}
206
207#[derive(Debug, clap::Args)]
208pub(crate) struct CompletionsArgs {
209    pub(crate) shell: Shell,
210}
211
212pub fn run() -> i32 {
213    run_from(std::env::args_os())
214}
215
216pub fn run_cargo() -> i32 {
217    let mut args = std::env::args_os().collect::<Vec<_>>();
218    if args
219        .get(1)
220        .and_then(|arg| arg.to_str())
221        .is_some_and(is_cargo_subcommand_name)
222    {
223        args.remove(1);
224    }
225    run_from(args)
226}
227
228fn is_cargo_subcommand_name(value: &str) -> bool {
229    matches!(
230        value,
231        "rustauth" | "rust-auth" | "better-auth" | "betterauth"
232    )
233}
234
235pub fn run_from<I, T>(args: I) -> i32
236where
237    I: IntoIterator<Item = T>,
238    T: Into<OsString> + Clone,
239{
240    match Cli::try_parse_from(args) {
241        Ok(cli) => match execute(cli) {
242            Ok(()) => 0,
243            Err(AppError::SilentExit { code }) => code,
244            Err(error) => {
245                eprintln!("{error}");
246                1
247            }
248        },
249        Err(error) => {
250            let _ = error.print();
251            error.exit_code()
252        }
253    }
254}
255
256fn execute(cli: Cli) -> Result<(), AppError> {
257    let runtime = tokio::runtime::Runtime::new().map_err(AppError::Runtime)?;
258    runtime.block_on(async move { execute_async(cli).await })
259}
260
261async fn execute_async(cli: Cli) -> Result<(), AppError> {
262    let cwd = crate::paths::absolute_cwd(&cli.cwd)?;
263    let config_path = crate::paths::resolve_config_path(&cwd, cli.config.as_deref());
264    crate::env::load_project_env(&cwd, &config_path)?;
265    let context = AppContext { config_path, cwd };
266    match cli.command {
267        Commands::Init(args) => crate::commands::init::run(&context, args),
268        Commands::Doctor(args) => crate::commands::doctor::run(&context, args).await,
269        Commands::Info(args) => crate::commands::info::run(&context, args).await,
270        Commands::Secret(args) => crate::commands::secret::run(args),
271        Commands::Db(args) => match args.command {
272            DbCommands::Status(args) => crate::commands::db::status(&context, args).await,
273            DbCommands::Generate(args) => crate::commands::db::generate(&context, args).await,
274            DbCommands::Migrate(args) => crate::commands::db::migrate(&context, args).await,
275        },
276        Commands::Generate(args) => crate::commands::db::generate(&context, args).await,
277        Commands::Migrate(args) => crate::commands::db::migrate(&context, args).await,
278        Commands::Schema(args) => match args.command {
279            SchemaCommands::Print(args) => crate::commands::schema::print(&context, args),
280        },
281        Commands::Plugins(args) => match args.command {
282            PluginsCommands::List(args) => crate::commands::plugins::list(args),
283            PluginsCommands::Add(args) => crate::commands::plugins::add(&context, args).await,
284            PluginsCommands::Remove(args) => crate::commands::plugins::remove(&context, args),
285        },
286        Commands::Completions(args) => crate::commands::completions::run(args),
287    }
288}
289
290pub(crate) struct AppContext {
291    cwd: PathBuf,
292    config_path: PathBuf,
293}
294
295impl AppContext {
296    pub(crate) fn cwd(&self) -> &Path {
297        &self.cwd
298    }
299
300    pub(crate) fn config_path(&self) -> &Path {
301        &self.config_path
302    }
303
304    pub(crate) fn load_config(&self) -> Result<CliConfig, AppError> {
305        CliConfig::load(&self.config_path).map_err(|error| match error {
306            ConfigError::Read { path, source }
307                if source.kind() == std::io::ErrorKind::NotFound =>
308            {
309                AppError::Message(format!(
310                    "No RustAuth CLI config found at {}. Run `rustauth init --framework axum` or `rustauth init --framework actix-web`, or pass --config <path>.",
311                    path.display()
312                ))
313            }
314            other => AppError::Config(other),
315        })
316    }
317
318    /// Loads the config when present, otherwise falls back to defaults.
319    ///
320    /// Returns the config plus a flag indicating whether it was loaded from
321    /// disk. A missing `rustauth.toml` is not an error so read-only commands
322    /// can run in a fresh checkout, but parse failures still surface.
323    pub(crate) fn load_config_or_default(&self) -> Result<(CliConfig, bool), AppError> {
324        match CliConfig::load_optional(&self.config_path)? {
325            Some(config) => Ok((config, true)),
326            None => Ok((CliConfig::default(), false)),
327        }
328    }
329
330    pub(crate) fn resolve_project_path(&self, path: &Path) -> PathBuf {
331        crate::paths::resolve_project_path(&self.cwd, path)
332    }
333}
334
335#[derive(Debug, thiserror::Error)]
336pub(crate) enum AppError {
337    #[error("{0}")]
338    Message(String),
339    #[error(transparent)]
340    Config(#[from] ConfigError),
341    #[error(transparent)]
342    Db(#[from] DbCliError),
343    #[error(transparent)]
344    RustAuth(#[from] rustauth_core::error::RustAuthError),
345    #[error(transparent)]
346    Json(#[from] serde_json::Error),
347    #[error("failed to start async runtime: {0}")]
348    Runtime(std::io::Error),
349    #[error("{context}: {source}")]
350    Io {
351        context: String,
352        source: std::io::Error,
353    },
354    #[error("command exited with status {code}")]
355    SilentExit { code: i32 },
356}