better-auth 0.10.0

The most comprehensive authentication framework for Rust
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use chrono::Utc;
use clap::{Parser, ValueEnum};
use serde::Serialize;
use serde_json::Value;

type DynError = Box<dyn std::error::Error + Send + Sync + 'static>;

const DEFAULT_REPO_URL: &str = "https://github.com/better-auth/better-auth.git";
const GENERATOR_TEMPLATE_REL_PATH: &str = "scripts/generate-openapi-matrix.mjs";

#[derive(Debug, Parser)]
#[command(
    name = "sync_better_auth_openapi",
    about = "Clone a pinned better-auth ref and export OpenAPI specs for multiple auth profiles"
)]
struct Cli {
    #[arg(long = "repo-url", default_value = DEFAULT_REPO_URL)]
    repo_url: String,

    #[arg(long = "ref", value_name = "git-ref")]
    git_ref: String,

    #[arg(long = "repo-dir", default_value = ".cache/better-auth-upstream")]
    repo_dir: PathBuf,

    #[arg(long = "output-dir", default_value = "reference/upstream-openapi")]
    output_dir: PathBuf,

    #[arg(
        long,
        value_name = "list",
        value_delimiter = ',',
        default_value = "core,aligned-rs"
    )]
    profiles: Vec<Profile>,

    #[arg(long)]
    skip_install: bool,

    #[arg(long)]
    skip_build: bool,

    #[arg(long)]
    keep_generator: bool,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
enum Profile {
    #[value(name = "core")]
    Core,
    #[value(name = "aligned-rs", alias = "aligned_rs", alias = "aligned")]
    AlignedRs,
    #[value(name = "all-in", alias = "all_in", alias = "allin")]
    AllIn,
}

impl Profile {
    fn as_str(self) -> &'static str {
        match self {
            Self::Core => "core",
            Self::AlignedRs => "aligned-rs",
            Self::AllIn => "all-in",
        }
    }
}

#[derive(Debug, Serialize)]
struct Manifest {
    generated_at: String,
    repo_url: String,
    git_ref: String,
    commit: String,
    repo_dir: String,
    profiles: Vec<ManifestProfile>,
}

#[derive(Debug, Serialize)]
struct ManifestProfile {
    name: String,
    json: String,
    yaml: String,
}

fn main() -> Result<(), DynError> {
    let options = Cli::parse();

    ensure_command_available("git")?;
    ensure_command_available("node")?;
    ensure_command_available("pnpm")?;

    let repo_dir = options.repo_dir.clone();
    let output_dir = options.output_dir.clone();

    clone_or_update_repo(&options.repo_url, &repo_dir)?;
    checkout_ref(&repo_dir, &options.git_ref)?;
    let commit = git_rev_parse_head(&repo_dir)?;

    if !options.skip_install {
        run_checked(
            "pnpm",
            &["install", "--no-frozen-lockfile"],
            Some(&repo_dir),
        )?;
    }
    if !options.skip_build {
        run_checked("pnpm", &["build"], Some(&repo_dir))?;
    }

    fs::create_dir_all(&output_dir)?;
    let js_workdir = resolve_js_workdir(&repo_dir);
    let generator_path = write_generator_script(&js_workdir)?;
    let generator_arg = generator_path
        .strip_prefix(&js_workdir)
        .unwrap_or(&generator_path)
        .to_string_lossy()
        .into_owned();

    let mut manifest_profiles = Vec::new();
    for profile in options.profiles {
        let json_path = output_dir.join(format!("openapi.{}.json", profile.as_str()));
        let yaml_path = output_dir.join(format!("openapi.{}.yaml", profile.as_str()));

        run_checked(
            "node",
            &[
                generator_arg.as_str(),
                "--profile",
                profile.as_str(),
                "--output",
                json_path.to_string_lossy().as_ref(),
            ],
            Some(&js_workdir),
        )?;

        convert_json_to_yaml(&json_path, &yaml_path)?;

        manifest_profiles.push(ManifestProfile {
            name: profile.as_str().to_string(),
            json: json_path.to_string_lossy().into_owned(),
            yaml: yaml_path.to_string_lossy().into_owned(),
        });
    }

    let manifest = Manifest {
        generated_at: Utc::now().to_rfc3339(),
        repo_url: options.repo_url,
        git_ref: options.git_ref,
        commit,
        repo_dir: repo_dir.to_string_lossy().into_owned(),
        profiles: manifest_profiles,
    };

    let manifest_path = output_dir.join("manifest.json");
    fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?;

    if !options.keep_generator {
        let _ = fs::remove_file(&generator_path);
    }

    eprintln!(
        "[done] OpenAPI matrix generated in {}",
        output_dir.display()
    );
    eprintln!("[done] Manifest: {}", manifest_path.display());
    Ok(())
}

fn ensure_command_available(cmd: &str) -> Result<(), DynError> {
    let status = Command::new("bash")
        .arg("-lc")
        .arg(r#"command -v "$1" >/dev/null 2>&1"#)
        .arg("bash")
        .arg(cmd)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()?;

    if status.success() {
        Ok(())
    } else {
        Err(format!("Required command `{cmd}` is not available in PATH").into())
    }
}

fn clone_or_update_repo(repo_url: &str, repo_dir: &Path) -> Result<(), DynError> {
    if repo_dir.join(".git").exists() {
        run_checked(
            "git",
            &["fetch", "--tags", "--prune", "origin"],
            Some(repo_dir),
        )?;
        return Ok(());
    }

    if let Some(parent) = repo_dir.parent() {
        fs::create_dir_all(parent)?;
    }

    run_checked(
        "git",
        &[
            "clone",
            "--filter=blob:none",
            repo_url,
            repo_dir.to_string_lossy().as_ref(),
        ],
        None,
    )?;

    Ok(())
}

fn checkout_ref(repo_dir: &Path, git_ref: &str) -> Result<(), DynError> {
    run_checked("git", &["checkout", "--detach", git_ref], Some(repo_dir))
}

fn git_rev_parse_head(repo_dir: &Path) -> Result<String, DynError> {
    let output = Command::new("git")
        .args(["rev-parse", "HEAD"])
        .current_dir(repo_dir)
        .output()?;

    if !output.status.success() {
        return Err("Failed to resolve HEAD commit".into());
    }

    Ok(String::from_utf8(output.stdout)?.trim().to_string())
}

fn write_generator_script(js_workdir: &Path) -> Result<PathBuf, DynError> {
    let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(GENERATOR_TEMPLATE_REL_PATH);
    if !source.exists() {
        return Err(format!("Generator template not found: {}", source.display()).into());
    }

    let generator_dir = js_workdir.join(".tmp");
    fs::create_dir_all(&generator_dir)?;
    let generator_path = generator_dir.join("generate-openapi-matrix.mjs");
    fs::copy(source, &generator_path)?;
    Ok(generator_path)
}

fn resolve_js_workdir(repo_dir: &Path) -> PathBuf {
    let candidate = repo_dir.join("packages/better-auth");
    if candidate.join("package.json").exists() {
        candidate
    } else {
        repo_dir.to_path_buf()
    }
}

fn convert_json_to_yaml(json_path: &Path, yaml_path: &Path) -> Result<(), DynError> {
    let json_raw = fs::read_to_string(json_path)?;
    let json_value: Value = serde_json::from_str(&json_raw)?;
    let yaml = serde_yaml::to_string(&json_value)?;
    fs::write(yaml_path, yaml)?;
    Ok(())
}

fn run_checked(program: &str, args: &[&str], cwd: Option<&Path>) -> Result<(), DynError> {
    eprintln!("$ {} {}", program, display_args(args));

    let mut command = Command::new(program);
    command.args(args);
    if let Some(dir) = cwd {
        command.current_dir(dir);
    }

    let status = command
        .stdin(Stdio::null())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()?;

    if status.success() {
        Ok(())
    } else {
        Err(format!("Command failed: {} {}", program, display_args(args)).into())
    }
}

fn display_args(args: &[&str]) -> String {
    args.iter()
        .map(quote_if_needed)
        .collect::<Vec<_>>()
        .join(" ")
}

fn quote_if_needed(value: &&str) -> String {
    if value.contains(' ') {
        format!("\"{value}\"")
    } else {
        (*value).to_string()
    }
}