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