Something went wrong. Try again.
A human-friendly DSL for ATProto Lexicons forked from stavola.xyz/mlf
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444use clap::{Parser, Subcommand};use miette::IntoDiagnostic;use mlf_cli::credentials::Scope;use mlf_cli::logout::Target as LogoutTarget;use mlf_cli::{check, diff, fetch, generate, init, login, logout, publish, status, unpublish};use std::path::PathBuf;use std::process;
// Import optional code generator plugins// These are automatically registered via inventory when the feature is enabled#[cfg(feature = "typescript")]use mlf_codegen_typescript as _;
#[cfg(feature = "go")]use mlf_codegen_go as _;
#[cfg(feature = "rust")]use mlf_codegen_rust as _;
#[derive(Parser)]#[command(name = "mlf")]#[command(about = "MLF (Matt's Lexicon Format) CLI tool", long_about = None)]struct Cli { #[command(subcommand)] command: Commands,}
#[derive(Subcommand)]enum Commands { Init { #[arg(long, help = "Skip prompts and use defaults")] yes: bool, },
Check { #[arg( help = "MLF lexicon file(s) or directory to validate. If omitted, checks source directory from mlf.toml" )] input: Vec<PathBuf>,
#[arg( long, help = "Root directory for namespace calculation (defaults to mlf.toml source directory or current directory)" )] root: Option<PathBuf>, },
Validate { #[arg(help = "MLF lexicon file")] lexicon: PathBuf,
#[arg(help = "JSON record file to validate against the lexicon")] record: PathBuf, },
Generate { #[command(subcommand)] command: Option<GenerateCommands>, },
Fetch { #[arg( help = "Namespace to fetch (e.g., stream.place). If omitted, fetches all dependencies from mlf.toml" )] nsid: Option<String>,
#[arg(long, help = "Add namespace to dependencies in mlf.toml")] save: bool,
#[arg( long, help = "Update dependencies to latest versions (ignores lockfile)" )] update: bool,
#[arg(long, help = "Require lockfile and fail if dependencies need updating")] locked: bool, },
#[command(about = "Show the publish status of each lexicon in the package")] Status,
#[command(about = "Diff local lexicons against what's currently published")] Diff { #[arg(help = "NSID to diff. If omitted, diffs every changed, new, or removed NSID.")] nsid: Option<String>, },
#[command(about = "Log in to the PDS and/or a DNS plugin")] Login { #[command(subcommand)] target: Option<LoginTargetCmd>,
#[arg( long, help = "Write to the project-local credentials file instead of global" )] project: bool,
#[arg(long, help = "Fail if any field needs an interactive prompt")] non_interactive: bool, },
#[command(about = "Publish every lexicon in the workspace to the configured PDS + DNS")] Publish { #[arg(long, help = "Compute the plan but don't perform any network writes")] dry_run: bool,
#[arg(long, help = "Override breaking-change aborts and DNS-mismatch aborts")] force: bool,
#[arg( long, help = "Fail instead of prompting for any missing credentials or confirmations" )] non_interactive: bool,
#[arg( long, help = "In a workspace, publish only the named member (matched by [package].name)" )] package: Option<String>,
/// Ephemeral credential overrides (not stored). Syntax mirrors /// login: `--pds-<field>=value` or `--dns-<plugin>-<field>=value`. #[arg( allow_hyphen_values = true, trailing_var_arg = true, num_args = 0.., help = "Optional credential overrides forwarded into the session (e.g. --pds-app-password=TKN)" )] args: Vec<String>, },
#[command(about = "Delete every lexicon the workspace has published, per the manifest")] Unpublish { #[arg(long, help = "Skip the interactive confirmation prompt")] yes: bool, },
#[command(about = "Clear stored credentials")] Logout { #[command(subcommand)] target: Option<LogoutTargetCmd>,
#[arg( long, help = "Clear from the project-local credentials file instead of global" )] project: bool, },}
#[derive(Subcommand)]enum LoginTargetCmd { #[command(about = "Log in as the publishing account (handle + app password)")] Pds { #[arg(long, help = "Handle (e.g. matt.example.com)")] handle: Option<String>,
#[arg(long, help = "App password")] app_password: Option<String>, }, #[command(about = "Log in to a DNS plugin")] Dns { #[arg(help = "Plugin name, e.g. cloudflare")] plugin: String,
/// Trailing `--field value` pairs are parsed against the plugin's /// options schema after the handshake. We use `allow_hyphen_values` /// + `trailing_var_arg` so clap doesn't reject unknown flags. #[arg( allow_hyphen_values = true, trailing_var_arg = true, num_args = 0.., help = "Plugin-specific --<field>=<value> or --<field> <value> arguments" )] args: Vec<String>, },}
#[derive(Subcommand)]enum LogoutTargetCmd { Pds, Dns { #[arg(help = "Plugin name. If omitted, every [dns.*] entry is cleared.")] plugin: Option<String>, },}
#[derive(Subcommand)]enum GenerateCommands { Lexicon { #[arg( short, long, help = "Input MLF file(s) or directory. If omitted, uses source directory from mlf.toml" )] input: Vec<PathBuf>,
#[arg( short, long, help = "Output directory. If omitted, uses first lexicon output from mlf.toml" )] output: Option<PathBuf>,
#[arg( long, help = "Root directory for namespace calculation (defaults to mlf.toml source directory or current directory)" )] root: Option<PathBuf>,
#[arg(long, help = "Use flat file structure (e.g., app.bsky.post.json)")] flat: bool, }, Code { #[arg( short, long, help = "Generator to use (typescript, go, rust, etc.). If omitted, uses first code output from mlf.toml" )] generator: Option<String>,
#[arg( short, long, help = "Input MLF file(s) or directory. If omitted, uses source directory from mlf.toml" )] input: Vec<PathBuf>,
#[arg( short, long, help = "Output directory. If omitted, uses matching output from mlf.toml" )] output: Option<PathBuf>,
#[arg( long, help = "Root directory for namespace calculation (defaults to mlf.toml source directory or current directory)" )] root: Option<PathBuf>,
#[arg(long, help = "Use flat file structure (e.g., app.bsky.post.ts)")] flat: bool, }, Mlf { #[arg( short, long, help = "Input JSON lexicon files (glob patterns supported)" )] input: Vec<String>,
#[arg( short, long, help = "Output directory. If omitted, uses first mlf output from mlf.toml" )] output: Option<PathBuf>,
#[arg(long, help = "Use flat file structure (e.g., app.bsky.post.json)")] flat: bool, },}
#[tokio::main]async fn main() { let cli = Cli::parse();
let result: Result<(), miette::Report> = match cli.command { Commands::Init { yes } => init::run_init(yes).into_diagnostic(), Commands::Check { input, root } => check::run_check(input, root).into_diagnostic(), Commands::Validate { lexicon, record } => { check::validate(lexicon, record).into_diagnostic() } Commands::Generate { command } => match command { Some(GenerateCommands::Lexicon { input, output, root, flat, }) => generate::lexicon::run(input, output, root, flat).into_diagnostic(), Some(GenerateCommands::Code { generator, input, output, root, flat, }) => generate::code::run(generator, input, output, root, flat).into_diagnostic(), Some(GenerateCommands::Mlf { input, output, flat, }) => generate::mlf::run(input, output, flat).into_diagnostic(), None => { // Run all outputs from mlf.toml generate::run_all().into_diagnostic() } }, Commands::Fetch { nsid, save, update, locked, } => fetch::run_fetch(nsid, save, update, locked) .await .into_diagnostic(), Commands::Status => status::run_status().await.into_diagnostic(), Commands::Diff { nsid } => diff::run_diff(nsid).await.into_diagnostic(), Commands::Login { target, project, non_interactive, } => { let scope = if project { Scope::Project } else { Scope::Global }; let mut args = login::LoginArgs { scope, non_interactive, ..Default::default() }; match target { None => login::run_login_all(args).await.into_diagnostic(), Some(LoginTargetCmd::Pds { handle, app_password, }) => { if let Some(h) = handle { args.field_overrides.insert("handle".into(), h); } if let Some(p) = app_password { args.field_overrides.insert("app_password".into(), p); } login::run_login_pds(args).await.into_diagnostic() } Some(LoginTargetCmd::Dns { plugin, args: raw_args, }) => { for (k, v) in parse_field_args(&raw_args) { args.field_overrides.insert(k, v); } login::run_login_dns(&plugin, args).await.into_diagnostic() } } } Commands::Publish { dry_run, force, non_interactive, package, args, } => { let overrides = parse_publish_overrides(&args); publish::run_publish(publish::PublishOpts { dry_run, force, non_interactive, package, overrides, }) .await .into_diagnostic() } Commands::Unpublish { yes } => unpublish::run_unpublish(unpublish::UnpublishOpts { yes }) .await .into_diagnostic(), Commands::Logout { target, project } => { let scope = if project { Scope::Project } else { Scope::Global }; let (tgt, name) = match target { None => (LogoutTarget::All, None), Some(LogoutTargetCmd::Pds) => (LogoutTarget::Pds, None), Some(LogoutTargetCmd::Dns { plugin: None }) => (LogoutTarget::AllDns, None), Some(LogoutTargetCmd::Dns { plugin: Some(p) }) => (LogoutTarget::Dns, Some(p)), }; logout::run_logout(tgt, name.as_deref(), scope).into_diagnostic() } };
if let Err(e) = result { eprintln!("{:?}", e); process::exit(1); }}
/// Parse plugin-forwarded `--field value` / `--field=value` pairs into/// a flat `(field_name, value)` list. The plugin-declared options schema/// determines which names are valid; we just collect whatever was/// passed so login.rs can match against the schema./// Parse ephemeral credential overrides from `mlf publish`'s trailing/// args. Recognises `--pds-<field>=value` and `--dns-<plugin>-<field>=value`/// (and the `--flag value` form). Unknown tokens are silently skipped.fn parse_publish_overrides(raw: &[String]) -> std::collections::BTreeMap<String, String> { let mut out = std::collections::BTreeMap::new(); let mut i = 0; while i < raw.len() { let tok = &raw[i]; if let Some(rest) = tok.strip_prefix("--") { let (key, value) = if let Some(eq) = rest.find('=') { (rest[..eq].to_string(), rest[eq + 1..].to_string()) } else if i + 1 < raw.len() { let v = raw[i + 1].clone(); i += 1; (rest.to_string(), v) } else { i += 1; continue; }; out.insert(key.replace('-', "_"), value); } i += 1; } out}
fn parse_field_args(raw: &[String]) -> Vec<(String, String)> { let mut out = Vec::new(); let mut i = 0; while i < raw.len() { let tok = &raw[i]; if let Some(rest) = tok.strip_prefix("--") { if let Some(eq) = rest.find('=') { let (k, v) = rest.split_at(eq); out.push((k.replace('-', "_"), v[1..].to_string())); } else if i + 1 < raw.len() { let k = rest.replace('-', "_"); out.push((k, raw[i + 1].clone())); i += 1; } } i += 1; } out}