A human-friendly DSL for ATProto Lexicons
27
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add mlf login / mlf logout commands + credentials file

Plaintext TOML credential storage at ~/.config/mlf/credentials.toml
(global) and <workspace>/.mlf/credentials.toml (project-local, opt-in
via --project, meant for CI). Files are written mode 0600 on Unix.
One [pds] section carrying handle + app_password + did/pds cache; one
[dns.<plugin>] section per configured DNS provider whose fields come
from the plugin's options_schema and are preserved on round-trip.

mlf login [pds|dns <plugin>] validates credentials before writing —
PDS login runs handle→DID→PDS resolution via mlf-atproto::identity
(adding resolve_handle_to_did with DNS+well-known fallback) and calls
createSession; DNS plugin login spawns the plugin binary, collects
schema fields via --flag=value → prompt fallback, forwards them via
init, and asks the plugin to validate via its login op. Credentials
returned from login replace whatever we collected so plugins can
normalise them.

mlf logout [pds|dns [<plugin>]] mirrors the login tree and rewrites
the credentials file minus the targeted sections. --non-interactive
on login errors with the exact flag name for any missing field
instead of prompting.

authored by stavola.xyz and committed by

Tangled 97e80422 afee6ed9

+1056 -1
+3
Cargo.lock
··· 1697 1697 dependencies = [ 1698 1698 "chrono", 1699 1699 "clap", 1700 + "dialoguer", 1700 1701 "glob", 1701 1702 "miette", 1702 1703 "mlf-atproto", ··· 1707 1708 "mlf-diagnostics", 1708 1709 "mlf-lang", 1709 1710 "mlf-lexicon-fetcher", 1711 + "mlf-plugin-host", 1710 1712 "mlf-validation", 1711 1713 "reqwest", 1712 1714 "serde", 1713 1715 "serde_json", 1714 1716 "sha2 0.10.9", 1715 1717 "similar", 1718 + "tempfile", 1716 1719 "thiserror 2.0.17", 1717 1720 "tokio", 1718 1721 "toml",
+68
mlf-atproto/src/identity.rs
··· 251 251 } 252 252 253 253 // --------------------------------------------------------------------------- 254 + // Handle → DID resolution (for PDS login) 255 + // --------------------------------------------------------------------------- 256 + 257 + /// Resolve an AT Protocol handle (e.g. `matt.example.com`) to its DID. 258 + /// 259 + /// Per the spec, handles can be resolved two ways; we try both and use 260 + /// the first that succeeds: 261 + /// 1. DNS TXT record at `_atproto.<handle>` with value `did=did:...` 262 + /// 2. HTTPS fetch of `https://<handle>/.well-known/atproto-did` whose 263 + /// response body is the DID string. 264 + pub async fn resolve_handle_to_did( 265 + client: &reqwest::Client, 266 + handle: &str, 267 + ) -> Result<String, IdentityError> { 268 + // Try DNS first. 269 + match resolve_handle_via_dns(handle).await { 270 + Ok(did) => return Ok(did), 271 + Err(IdentityError::DnsLookupFailed { .. }) | Err(IdentityError::NoDidInTxt(_)) => { 272 + // Fall through to HTTPS. 273 + } 274 + Err(e) => return Err(e), 275 + } 276 + resolve_handle_via_well_known(client, handle).await 277 + } 278 + 279 + async fn resolve_handle_via_dns(handle: &str) -> Result<String, IdentityError> { 280 + let dns_name = format!("_atproto.{handle}"); 281 + let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()); 282 + resolve_did_at(&resolver, &dns_name).await 283 + } 284 + 285 + async fn resolve_handle_via_well_known( 286 + client: &reqwest::Client, 287 + handle: &str, 288 + ) -> Result<String, IdentityError> { 289 + let url = format!("https://{handle}/.well-known/atproto-did"); 290 + let resp = client 291 + .get(&url) 292 + .send() 293 + .await 294 + .map_err(|e| IdentityError::DidResolutionFailed { 295 + did: handle.to_string(), 296 + error: format!("well-known fetch failed: {e}"), 297 + })?; 298 + if !resp.status().is_success() { 299 + return Err(IdentityError::DidResolutionFailed { 300 + did: handle.to_string(), 301 + error: format!("well-known returned HTTP {}", resp.status()), 302 + }); 303 + } 304 + let body = resp 305 + .text() 306 + .await 307 + .map_err(|e| IdentityError::DidResolutionFailed { 308 + did: handle.to_string(), 309 + error: format!("well-known read failed: {e}"), 310 + })?; 311 + let did = body.trim(); 312 + if !did.starts_with("did:") { 313 + return Err(IdentityError::DidResolutionFailed { 314 + did: handle.to_string(), 315 + error: format!("well-known body was not a DID: `{did}`"), 316 + }); 317 + } 318 + Ok(did.to_string()) 319 + } 320 + 321 + // --------------------------------------------------------------------------- 254 322 // DID → PDS endpoint resolution 255 323 // --------------------------------------------------------------------------- 256 324
+5
mlf-cli/Cargo.toml
··· 15 15 mlf-diagnostics = { path = "../mlf-diagnostics" } 16 16 mlf-lexicon-fetcher = { path = "../mlf-lexicon-fetcher" } 17 17 mlf-atproto = { path = "../mlf-atproto" } 18 + mlf-plugin-host = { path = "../mlf-plugin-host" } 18 19 clap = { version = "4.5.48", features = ["derive"] } 19 20 miette = { version = "7", features = ["fancy"] } 20 21 thiserror = "2" ··· 27 28 chrono = { version = "0.4", features = ["serde"] } 28 29 sha2 = "0.10" 29 30 similar = "2" 31 + dialoguer = { version = "0.11", default-features = false, features = ["password"] } 30 32 31 33 # Optional code generator plugins 32 34 mlf-codegen-typescript = { path = "../codegen-plugins/mlf-codegen-typescript", optional = true } 33 35 mlf-codegen-go = { path = "../codegen-plugins/mlf-codegen-go", optional = true } 34 36 mlf-codegen-rust = { path = "../codegen-plugins/mlf-codegen-rust", optional = true } 37 + 38 + [dev-dependencies] 39 + tempfile = "3" 35 40 36 41 [features] 37 42 default = ["typescript", "go", "rust"]
+286
mlf-cli/src/credentials.rs
··· 1 + //! Plaintext credential storage — same shape as cargo's 2 + //! `~/.cargo/credentials.toml` and npm's `.npmrc`. A global file under 3 + //! `~/.config/mlf/credentials.toml` holds defaults; an optional 4 + //! project-local `.mlf/credentials.toml` (gitignored) shadows entries 5 + //! per field. 6 + //! 7 + //! All files are written with mode `0o600` on Unix. 8 + 9 + use serde::{Deserialize, Serialize}; 10 + use std::path::{Path, PathBuf}; 11 + use thiserror::Error; 12 + 13 + #[derive(Error, Debug)] 14 + pub enum CredentialsError { 15 + #[error("Failed to read credentials file {path}: {source}")] 16 + Read { 17 + path: PathBuf, 18 + #[source] 19 + source: std::io::Error, 20 + }, 21 + 22 + #[error("Failed to write credentials file {path}: {source}")] 23 + Write { 24 + path: PathBuf, 25 + #[source] 26 + source: std::io::Error, 27 + }, 28 + 29 + #[error("Failed to parse credentials file {path}: {source}")] 30 + Parse { 31 + path: PathBuf, 32 + #[source] 33 + source: toml::de::Error, 34 + }, 35 + 36 + #[error("Home directory not available (set HOME)")] 37 + NoHomeDir, 38 + } 39 + 40 + /// On-disk shape. Any `[dns.<plugin>]` section shows up as a `DnsEntry` 41 + /// keyed by `<plugin>` in `dns`. Unknown top-level or per-plugin fields 42 + /// are preserved on round-trip so we don't clobber anything an older 43 + /// (or newer) MLF wrote. 44 + #[derive(Debug, Default, Clone, Serialize, Deserialize)] 45 + pub struct CredentialsFile { 46 + #[serde(default, skip_serializing_if = "Option::is_none")] 47 + pub pds: Option<PdsEntry>, 48 + 49 + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] 50 + pub dns: std::collections::BTreeMap<String, DnsEntry>, 51 + } 52 + 53 + /// One PDS credential entry. Fields match the publishing account. 54 + #[derive(Debug, Default, Clone, Serialize, Deserialize)] 55 + pub struct PdsEntry { 56 + #[serde(default, skip_serializing_if = "Option::is_none")] 57 + pub handle: Option<String>, 58 + #[serde(default, skip_serializing_if = "Option::is_none")] 59 + pub app_password: Option<String>, 60 + /// Extra fields (e.g. did or pds URL the login resolved to) are 61 + /// preserved; the cli uses them as a cache. 62 + #[serde(flatten)] 63 + pub extra: serde_json::Map<String, serde_json::Value>, 64 + } 65 + 66 + /// One DNS plugin's credential entry. Arbitrary key/value because each 67 + /// plugin declares its own schema; we store whatever the plugin's `login` 68 + /// op returned. 69 + #[derive(Debug, Default, Clone, Serialize, Deserialize)] 70 + pub struct DnsEntry { 71 + #[serde(flatten)] 72 + pub fields: serde_json::Map<String, serde_json::Value>, 73 + } 74 + 75 + impl DnsEntry { 76 + pub fn from_object(obj: serde_json::Map<String, serde_json::Value>) -> Self { 77 + Self { fields: obj } 78 + } 79 + 80 + pub fn is_empty(&self) -> bool { 81 + self.fields.is_empty() 82 + } 83 + 84 + pub fn get_str(&self, key: &str) -> Option<&str> { 85 + self.fields.get(key).and_then(|v| v.as_str()) 86 + } 87 + } 88 + 89 + impl CredentialsFile { 90 + pub fn load(path: &Path) -> Result<Self, CredentialsError> { 91 + if !path.exists() { 92 + return Ok(Self::default()); 93 + } 94 + let content = std::fs::read_to_string(path).map_err(|source| CredentialsError::Read { 95 + path: path.to_path_buf(), 96 + source, 97 + })?; 98 + toml::from_str(&content).map_err(|source| CredentialsError::Parse { 99 + path: path.to_path_buf(), 100 + source, 101 + }) 102 + } 103 + 104 + pub fn save(&self, path: &Path) -> Result<(), CredentialsError> { 105 + if let Some(parent) = path.parent() 106 + && !parent.as_os_str().is_empty() 107 + { 108 + std::fs::create_dir_all(parent).map_err(|source| CredentialsError::Write { 109 + path: path.to_path_buf(), 110 + source, 111 + })?; 112 + } 113 + let content = toml::to_string_pretty(self).map_err(|e| CredentialsError::Write { 114 + path: path.to_path_buf(), 115 + source: std::io::Error::other(e.to_string()), 116 + })?; 117 + write_private(path, content.as_bytes())?; 118 + Ok(()) 119 + } 120 + } 121 + 122 + fn write_private(path: &Path, bytes: &[u8]) -> Result<(), CredentialsError> { 123 + std::fs::write(path, bytes).map_err(|source| CredentialsError::Write { 124 + path: path.to_path_buf(), 125 + source, 126 + })?; 127 + #[cfg(unix)] 128 + { 129 + use std::os::unix::fs::PermissionsExt; 130 + let perms = std::fs::Permissions::from_mode(0o600); 131 + std::fs::set_permissions(path, perms).map_err(|source| CredentialsError::Write { 132 + path: path.to_path_buf(), 133 + source, 134 + })?; 135 + } 136 + Ok(()) 137 + } 138 + 139 + // --------------------------------------------------------------------------- 140 + // Scope resolution 141 + // --------------------------------------------------------------------------- 142 + 143 + /// Which file `mlf login` reads and writes. 144 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 145 + pub enum Scope { 146 + /// `~/.config/mlf/credentials.toml`. The default for local dev. 147 + Global, 148 + /// `<project>/.mlf/credentials.toml`. The choice for CI, monorepos, 149 + /// and anywhere you want creds to live alongside the project. 150 + Project, 151 + } 152 + 153 + impl Scope { 154 + pub fn path(&self, project_root: &Path) -> Result<PathBuf, CredentialsError> { 155 + match self { 156 + Scope::Global => Ok(global_credentials_path()?), 157 + Scope::Project => Ok(project_root.join(".mlf").join("credentials.toml")), 158 + } 159 + } 160 + } 161 + 162 + pub fn global_credentials_path() -> Result<PathBuf, CredentialsError> { 163 + let home = std::env::var_os("HOME") 164 + .or_else(|| std::env::var_os("USERPROFILE")) 165 + .ok_or(CredentialsError::NoHomeDir)?; 166 + Ok(PathBuf::from(home) 167 + .join(".config") 168 + .join("mlf") 169 + .join("credentials.toml")) 170 + } 171 + 172 + /// Merge a global and project file into a single effective view. 173 + /// Project entries override global ones at the section level. 174 + #[derive(Debug, Default)] 175 + pub struct Merged { 176 + pub pds: Option<PdsEntry>, 177 + pub dns: std::collections::BTreeMap<String, DnsEntry>, 178 + } 179 + 180 + impl Merged { 181 + pub fn load(project_root: &Path) -> Result<Self, CredentialsError> { 182 + let global = match global_credentials_path() { 183 + Ok(p) => CredentialsFile::load(&p)?, 184 + Err(CredentialsError::NoHomeDir) => CredentialsFile::default(), 185 + Err(e) => return Err(e), 186 + }; 187 + let project_path = project_root.join(".mlf").join("credentials.toml"); 188 + let project = CredentialsFile::load(&project_path)?; 189 + let mut merged = Merged::default(); 190 + merged.pds = project.pds.or(global.pds); 191 + merged.dns = global.dns; 192 + for (k, v) in project.dns { 193 + merged.dns.insert(k, v); 194 + } 195 + Ok(merged) 196 + } 197 + } 198 + 199 + #[cfg(test)] 200 + mod tests { 201 + use super::*; 202 + use tempfile::TempDir; 203 + 204 + #[test] 205 + fn round_trip_preserves_fields() { 206 + let dir = TempDir::new().unwrap(); 207 + let path = dir.path().join("creds.toml"); 208 + 209 + let mut file = CredentialsFile::default(); 210 + file.pds = Some(PdsEntry { 211 + handle: Some("matt.example.com".into()), 212 + app_password: Some("xxxx".into()), 213 + ..Default::default() 214 + }); 215 + file.dns.insert( 216 + "cloudflare".into(), 217 + DnsEntry::from_object( 218 + serde_json::json!({"api_token": "tkn"}) 219 + .as_object() 220 + .unwrap() 221 + .clone(), 222 + ), 223 + ); 224 + file.save(&path).unwrap(); 225 + 226 + let loaded = CredentialsFile::load(&path).unwrap(); 227 + assert_eq!( 228 + loaded.pds.as_ref().unwrap().handle.as_deref(), 229 + Some("matt.example.com") 230 + ); 231 + assert_eq!( 232 + loaded.dns.get("cloudflare").unwrap().get_str("api_token"), 233 + Some("tkn") 234 + ); 235 + } 236 + 237 + #[test] 238 + fn load_missing_file_returns_default() { 239 + let dir = TempDir::new().unwrap(); 240 + let path = dir.path().join("nope.toml"); 241 + let loaded = CredentialsFile::load(&path).unwrap(); 242 + assert!(loaded.pds.is_none()); 243 + assert!(loaded.dns.is_empty()); 244 + } 245 + 246 + #[cfg(unix)] 247 + #[test] 248 + fn save_sets_0600_permissions() { 249 + use std::os::unix::fs::PermissionsExt; 250 + let dir = TempDir::new().unwrap(); 251 + let path = dir.path().join("creds.toml"); 252 + CredentialsFile::default().save(&path).unwrap(); 253 + let perms = std::fs::metadata(&path).unwrap().permissions(); 254 + assert_eq!(perms.mode() & 0o777, 0o600); 255 + } 256 + 257 + #[test] 258 + fn merged_project_shadows_global() { 259 + let dir = TempDir::new().unwrap(); 260 + let project_root = dir.path(); 261 + std::fs::create_dir_all(project_root.join(".mlf")).unwrap(); 262 + 263 + // Seed project. 264 + let mut project_file = CredentialsFile::default(); 265 + project_file.dns.insert( 266 + "cloudflare".into(), 267 + DnsEntry::from_object( 268 + serde_json::json!({"api_token": "project-token"}) 269 + .as_object() 270 + .unwrap() 271 + .clone(), 272 + ), 273 + ); 274 + project_file 275 + .save(&project_root.join(".mlf").join("credentials.toml")) 276 + .unwrap(); 277 + 278 + // We don't have a writable $HOME in the test sandbox, so just 279 + // check project-only loading works. 280 + let merged = Merged::load(project_root).unwrap(); 281 + assert_eq!( 282 + merged.dns.get("cloudflare").unwrap().get_str("api_token"), 283 + Some("project-token"), 284 + ); 285 + } 286 + }
+3
mlf-cli/src/lib.rs
··· 1 1 pub mod check; 2 2 pub mod config; 3 + pub mod credentials; 3 4 pub mod diff; 4 5 pub mod fetch; 5 6 pub mod generate; 6 7 pub mod init; 8 + pub mod login; 9 + pub mod logout; 7 10 pub mod remote_state; 8 11 pub mod status; 9 12 pub mod workspace_ext;
+381
mlf-cli/src/login.rs
··· 1 + //! `mlf login` — interactive auth for the PDS and any DNS plugin the 2 + //! workspace references. Writes into a plaintext TOML credentials file 3 + //! (global by default, project-local under `--project`). 4 + 5 + use crate::config::{ConfigError, MlfConfig, find_project_root}; 6 + use crate::credentials::{CredentialsError, CredentialsFile, DnsEntry, PdsEntry, Scope}; 7 + use dialoguer::{Input, Password, theme::ColorfulTheme}; 8 + use miette::Diagnostic; 9 + use mlf_atproto::identity::{self, IdentityError}; 10 + use mlf_atproto::session::{self, SessionError}; 11 + use mlf_plugin_host::discovery; 12 + use mlf_plugin_host::host::{HostError, PluginHandle}; 13 + use mlf_plugin_host::protocol::OptionField; 14 + use mlf_plugin_host::ui::{DenyInteractiveUi, TerminalUi}; 15 + use serde_json::{Value, json}; 16 + use std::path::PathBuf; 17 + use thiserror::Error; 18 + 19 + #[derive(Error, Debug, Diagnostic)] 20 + pub enum LoginError { 21 + #[error("No mlf.toml found in current or parent directories")] 22 + #[diagnostic( 23 + code(mlf::login::no_project), 24 + help("Run `mlf init` first to set up a project.") 25 + )] 26 + NoProject, 27 + 28 + #[error("Failed to load mlf.toml: {0}")] 29 + #[diagnostic(code(mlf::login::config))] 30 + Config(String), 31 + 32 + #[error("Credentials file error")] 33 + #[diagnostic(code(mlf::login::credentials))] 34 + Credentials(#[source] CredentialsError), 35 + 36 + #[error("Identity resolution failed")] 37 + #[diagnostic(code(mlf::login::identity))] 38 + Identity(#[source] IdentityError), 39 + 40 + #[error("PDS session error")] 41 + #[diagnostic(code(mlf::login::session))] 42 + Session(#[source] SessionError), 43 + 44 + #[error("Plugin `{plugin}` not found on PATH or in ~/.config/mlf/plugins")] 45 + #[diagnostic( 46 + code(mlf::login::plugin_not_found), 47 + help( 48 + "Install the plugin binary (e.g. `cargo install mlf-dns-cloudflare`) and ensure it's on PATH." 49 + ) 50 + )] 51 + PluginNotFound { plugin: String }, 52 + 53 + #[error("Plugin `{plugin}` error")] 54 + #[diagnostic(code(mlf::login::plugin))] 55 + Plugin { 56 + plugin: String, 57 + #[source] 58 + source: HostError, 59 + }, 60 + 61 + #[error("DNS plugin `{plugin}` isn't referenced by this workspace's `[publish].dns`")] 62 + #[diagnostic( 63 + code(mlf::login::unused_plugin), 64 + help( 65 + "Add `dns = \"{plugin}\"` under `[publish]` in mlf.toml, or pick a different plugin." 66 + ) 67 + )] 68 + PluginNotReferenced { plugin: String }, 69 + 70 + #[error("Prompt I/O error: {0}")] 71 + #[diagnostic(code(mlf::login::prompt))] 72 + Prompt(String), 73 + 74 + #[error("`[publish]` section missing from mlf.toml — the workspace isn't marked publishable")] 75 + #[diagnostic( 76 + code(mlf::login::not_publishable), 77 + help( 78 + "Add `[publish]` to mlf.toml with at least `dns = \"<plugin>\"` before running login." 79 + ) 80 + )] 81 + NotPublishable, 82 + } 83 + 84 + /// What login needs to know for each kind of call. 85 + pub struct LoginArgs { 86 + pub scope: Scope, 87 + pub non_interactive: bool, 88 + /// Pre-supplied field values, keyed by the schema field name 89 + /// (e.g. `api_token` / `app_password`). Populated from CLI flags. 90 + pub field_overrides: std::collections::BTreeMap<String, String>, 91 + } 92 + 93 + impl Default for LoginArgs { 94 + fn default() -> Self { 95 + Self { 96 + scope: Scope::Global, 97 + non_interactive: false, 98 + field_overrides: Default::default(), 99 + } 100 + } 101 + } 102 + 103 + /// `mlf login` — walk every credential the workspace needs, log in to 104 + /// whichever aren't already stored. Idempotent. 105 + pub async fn run_login_all(args: LoginArgs) -> Result<(), LoginError> { 106 + let ctx = Context::load()?; 107 + let creds_path = args 108 + .scope 109 + .path(&ctx.project_root) 110 + .map_err(LoginError::Credentials)?; 111 + let mut file = CredentialsFile::load(&creds_path).map_err(LoginError::Credentials)?; 112 + 113 + if file 114 + .pds 115 + .as_ref() 116 + .and_then(|p| p.app_password.as_ref()) 117 + .is_none() 118 + { 119 + println!("Logging in to PDS (your publishing account)..."); 120 + login_pds(&ctx, &mut file, &args).await?; 121 + } else { 122 + println!("PDS: already logged in"); 123 + } 124 + 125 + for plugin in referenced_dns_plugins(&ctx.config) { 126 + if file.dns.contains_key(&plugin) { 127 + println!("DNS `{plugin}`: already logged in"); 128 + continue; 129 + } 130 + println!("Logging in to DNS plugin `{plugin}`..."); 131 + login_dns(&plugin, &mut file, &args).await?; 132 + } 133 + 134 + file.save(&creds_path).map_err(LoginError::Credentials)?; 135 + Ok(()) 136 + } 137 + 138 + /// `mlf login pds` — collect handle + app password, validate against 139 + /// the PDS, write to credentials file. 140 + pub async fn run_login_pds(args: LoginArgs) -> Result<(), LoginError> { 141 + let ctx = Context::load()?; 142 + let creds_path = args 143 + .scope 144 + .path(&ctx.project_root) 145 + .map_err(LoginError::Credentials)?; 146 + let mut file = CredentialsFile::load(&creds_path).map_err(LoginError::Credentials)?; 147 + login_pds(&ctx, &mut file, &args).await?; 148 + file.save(&creds_path).map_err(LoginError::Credentials)?; 149 + Ok(()) 150 + } 151 + 152 + /// `mlf login dns <plugin>` — spawn the plugin, collect the options 153 + /// schema, prompt/override-fill secrets, ask the plugin to validate, 154 + /// write the returned credentials to file. 155 + pub async fn run_login_dns(plugin: &str, args: LoginArgs) -> Result<(), LoginError> { 156 + let ctx = Context::load()?; 157 + let referenced = referenced_dns_plugins(&ctx.config); 158 + if !referenced.is_empty() && !referenced.iter().any(|p| p == plugin) { 159 + // Only enforce the "referenced" check once we can actually parse 160 + // [publish] out of mlf.toml. Until R6 that list is always empty, 161 + // meaning `mlf login dns <any-plugin>` is permitted so users can 162 + // exercise the plugin today. 163 + return Err(LoginError::PluginNotReferenced { 164 + plugin: plugin.to_string(), 165 + }); 166 + } 167 + let creds_path = args 168 + .scope 169 + .path(&ctx.project_root) 170 + .map_err(LoginError::Credentials)?; 171 + let mut file = CredentialsFile::load(&creds_path).map_err(LoginError::Credentials)?; 172 + login_dns(plugin, &mut file, &args).await?; 173 + file.save(&creds_path).map_err(LoginError::Credentials)?; 174 + Ok(()) 175 + } 176 + 177 + // --------------------------------------------------------------------------- 178 + // Inner helpers 179 + // --------------------------------------------------------------------------- 180 + 181 + struct Context { 182 + project_root: PathBuf, 183 + config: MlfConfig, 184 + } 185 + 186 + impl Context { 187 + fn load() -> Result<Self, LoginError> { 188 + let current_dir = std::env::current_dir().map_err(|e| LoginError::Config(e.to_string()))?; 189 + let project_root = find_project_root(&current_dir).map_err(|e| match e { 190 + ConfigError::NotFound => LoginError::NoProject, 191 + other => LoginError::Config(other.to_string()), 192 + })?; 193 + let config_path = project_root.join("mlf.toml"); 194 + let config = 195 + MlfConfig::load(&config_path).map_err(|e| LoginError::Config(e.to_string()))?; 196 + Ok(Self { 197 + project_root, 198 + config, 199 + }) 200 + } 201 + } 202 + 203 + fn referenced_dns_plugins(_config: &MlfConfig) -> Vec<String> { 204 + // [publish] parsing arrives in R6. Until then we don't know which 205 + // DNS plugin the workspace uses, so the auto-login "all" path only 206 + // handles the PDS. A `mlf login dns <plugin>` call still works 207 + // because it names the plugin explicitly. 208 + Vec::new() 209 + } 210 + 211 + async fn login_pds( 212 + _ctx: &Context, 213 + file: &mut CredentialsFile, 214 + args: &LoginArgs, 215 + ) -> Result<(), LoginError> { 216 + let existing = file.pds.clone().unwrap_or_default(); 217 + let handle = resolve_field( 218 + "handle", 219 + "Handle (e.g. matt.example.com)", 220 + false, 221 + &args.field_overrides, 222 + existing.handle.as_deref(), 223 + args.non_interactive, 224 + )?; 225 + let app_password = resolve_field( 226 + "app_password", 227 + "App password", 228 + true, 229 + &args.field_overrides, 230 + None, // never pre-fill a secret from the existing record for re-login 231 + args.non_interactive, 232 + )?; 233 + 234 + // Validate by resolving the handle → DID → PDS and calling createSession. 235 + let client = reqwest::Client::new(); 236 + let did = identity::resolve_handle_to_did(&client, &handle) 237 + .await 238 + .map_err(LoginError::Identity)?; 239 + let pds = identity::resolve_did_to_pds(&client, &did) 240 + .await 241 + .map_err(LoginError::Identity)?; 242 + let session = session::create_session(&client, &pds, &handle, &app_password) 243 + .await 244 + .map_err(LoginError::Session)?; 245 + 246 + let mut extra = serde_json::Map::new(); 247 + extra.insert("did".into(), Value::String(session.did.clone())); 248 + extra.insert("pds".into(), Value::String(pds.clone())); 249 + 250 + file.pds = Some(PdsEntry { 251 + handle: Some(session.handle.clone()), 252 + app_password: Some(app_password), 253 + extra, 254 + }); 255 + println!("✓ Logged in as {} ({})", session.handle, session.did); 256 + Ok(()) 257 + } 258 + 259 + async fn login_dns( 260 + plugin: &str, 261 + file: &mut CredentialsFile, 262 + args: &LoginArgs, 263 + ) -> Result<(), LoginError> { 264 + let binary = discovery::find("dns", plugin).ok_or_else(|| LoginError::PluginNotFound { 265 + plugin: plugin.to_string(), 266 + })?; 267 + let program = binary.to_string_lossy().into_owned(); 268 + let mut handle = 269 + PluginHandle::spawn(&program, &[]) 270 + .await 271 + .map_err(|source| LoginError::Plugin { 272 + plugin: plugin.to_string(), 273 + source, 274 + })?; 275 + 276 + // 1. Collect each schema field: flag override → existing stored 277 + // value → interactive prompt. 278 + let schema = handle.hello().options_schema.clone(); 279 + let existing = file 280 + .dns 281 + .get(plugin) 282 + .map(|d| d.fields.clone()) 283 + .unwrap_or_default(); 284 + let mut credentials = serde_json::Map::new(); 285 + for field in &schema { 286 + let existing_str = if field.secret { 287 + None // don't prefill secrets from disk 288 + } else { 289 + existing.get(&field.name).and_then(|v| v.as_str()) 290 + }; 291 + let value = resolve_field( 292 + &field.name, 293 + label_for(field), 294 + field.secret, 295 + &args.field_overrides, 296 + existing_str, 297 + args.non_interactive, 298 + )?; 299 + credentials.insert(field.name.clone(), Value::String(value)); 300 + } 301 + 302 + // 2. Push them to the plugin for validation. 303 + handle 304 + .init(Value::Object(credentials.clone())) 305 + .await 306 + .map_err(|source| LoginError::Plugin { 307 + plugin: plugin.to_string(), 308 + source, 309 + })?; 310 + let mut ui: Box<dyn mlf_plugin_host::ui::UiHandler + Send> = if args.non_interactive { 311 + Box::new(DenyInteractiveUi) 312 + } else { 313 + Box::new(TerminalUi) 314 + }; 315 + let resp: Value = handle 316 + .call("login", json!({}), ui.as_mut()) 317 + .await 318 + .map_err(|source| LoginError::Plugin { 319 + plugin: plugin.to_string(), 320 + source, 321 + })?; 322 + 323 + // 3. Persist whatever the plugin returned as `credentials`. 324 + // Fall back to the values we collected if the plugin didn't round-trip them. 325 + let returned = resp 326 + .get("credentials") 327 + .and_then(|v| v.as_object()) 328 + .cloned() 329 + .unwrap_or(credentials); 330 + let display = resp 331 + .get("display_name") 332 + .and_then(|v| v.as_str()) 333 + .unwrap_or(plugin); 334 + file.dns 335 + .insert(plugin.to_string(), DnsEntry::from_object(returned)); 336 + println!("✓ Logged in to DNS plugin `{plugin}` ({display})"); 337 + let _ = handle.shutdown().await; 338 + Ok(()) 339 + } 340 + 341 + fn label_for(field: &OptionField) -> &str { 342 + if field.label.is_empty() { 343 + &field.name 344 + } else { 345 + &field.label 346 + } 347 + } 348 + 349 + fn resolve_field( 350 + name: &str, 351 + label: &str, 352 + secret: bool, 353 + overrides: &std::collections::BTreeMap<String, String>, 354 + existing: Option<&str>, 355 + non_interactive: bool, 356 + ) -> Result<String, LoginError> { 357 + if let Some(v) = overrides.get(name) { 358 + return Ok(v.clone()); 359 + } 360 + if let Some(v) = existing { 361 + return Ok(v.to_string()); 362 + } 363 + if non_interactive { 364 + return Err(LoginError::Prompt(format!( 365 + "missing `{name}` in non-interactive mode (pass --{}=... )", 366 + name.replace('_', "-") 367 + ))); 368 + } 369 + let theme = ColorfulTheme::default(); 370 + if secret { 371 + Password::with_theme(&theme) 372 + .with_prompt(label) 373 + .interact() 374 + .map_err(|e| LoginError::Prompt(e.to_string())) 375 + } else { 376 + Input::with_theme(&theme) 377 + .with_prompt(label) 378 + .interact_text() 379 + .map_err(|e| LoginError::Prompt(e.to_string())) 380 + } 381 + }
+70
mlf-cli/src/logout.rs
··· 1 + //! `mlf logout` — clear credentials from the project-local and 2 + //! (by default) the global credentials file. 3 + 4 + use crate::config::{ConfigError, find_project_root}; 5 + use crate::credentials::{CredentialsError, CredentialsFile, Scope}; 6 + use miette::Diagnostic; 7 + use thiserror::Error; 8 + 9 + #[derive(Error, Debug, Diagnostic)] 10 + pub enum LogoutError { 11 + #[error("No mlf.toml found in current or parent directories")] 12 + #[diagnostic(code(mlf::logout::no_project))] 13 + NoProject, 14 + 15 + #[error("Credentials file error")] 16 + #[diagnostic(code(mlf::logout::credentials))] 17 + Credentials(#[source] CredentialsError), 18 + } 19 + 20 + #[derive(Debug, Clone, Copy)] 21 + pub enum Target { 22 + /// Clear everything — `[pds]` and every `[dns.*]`. 23 + All, 24 + /// Clear `[pds]`. 25 + Pds, 26 + /// Clear every `[dns.*]`. 27 + AllDns, 28 + /// Clear a specific `[dns.<plugin>]`. 29 + Dns, 30 + } 31 + 32 + pub fn run_logout(target: Target, plugin: Option<&str>, scope: Scope) -> Result<(), LogoutError> { 33 + let current_dir = std::env::current_dir().map_err(|_| LogoutError::NoProject)?; 34 + let project_root = find_project_root(&current_dir).map_err(|e| match e { 35 + ConfigError::NotFound => LogoutError::NoProject, 36 + _ => LogoutError::NoProject, 37 + })?; 38 + let path = scope 39 + .path(&project_root) 40 + .map_err(LogoutError::Credentials)?; 41 + let mut file = CredentialsFile::load(&path).map_err(LogoutError::Credentials)?; 42 + 43 + match target { 44 + Target::All => { 45 + file.pds = None; 46 + file.dns.clear(); 47 + println!("✓ Cleared all credentials"); 48 + } 49 + Target::Pds => { 50 + file.pds = None; 51 + println!("✓ Cleared [pds]"); 52 + } 53 + Target::AllDns => { 54 + let n = file.dns.len(); 55 + file.dns.clear(); 56 + println!("✓ Cleared {n} DNS credential(s)"); 57 + } 58 + Target::Dns => { 59 + let plugin = plugin.expect("Target::Dns requires a plugin name"); 60 + if file.dns.remove(plugin).is_some() { 61 + println!("✓ Cleared [dns.{plugin}]"); 62 + } else { 63 + println!("No credentials stored for `{plugin}`"); 64 + } 65 + } 66 + } 67 + 68 + file.save(&path).map_err(LogoutError::Credentials)?; 69 + Ok(()) 70 + }
+145 -1
mlf-cli/src/main.rs
··· 1 1 use clap::{Parser, Subcommand}; 2 2 use miette::IntoDiagnostic; 3 - use mlf_cli::{check, diff, fetch, generate, init, status}; 3 + use mlf_cli::credentials::Scope; 4 + use mlf_cli::logout::Target as LogoutTarget; 5 + use mlf_cli::{check, diff, fetch, generate, init, login, logout, status}; 4 6 use std::path::PathBuf; 5 7 use std::process; 6 8 ··· 83 85 #[arg(help = "NSID to diff. If omitted, diffs every changed, new, or removed NSID.")] 84 86 nsid: Option<String>, 85 87 }, 88 + 89 + #[command(about = "Log in to the PDS and/or a DNS plugin")] 90 + Login { 91 + #[command(subcommand)] 92 + target: Option<LoginTargetCmd>, 93 + 94 + #[arg( 95 + long, 96 + help = "Write to the project-local credentials file instead of global" 97 + )] 98 + project: bool, 99 + 100 + #[arg(long, help = "Fail if any field needs an interactive prompt")] 101 + non_interactive: bool, 102 + }, 103 + 104 + #[command(about = "Clear stored credentials")] 105 + Logout { 106 + #[command(subcommand)] 107 + target: Option<LogoutTargetCmd>, 108 + 109 + #[arg( 110 + long, 111 + help = "Clear from the project-local credentials file instead of global" 112 + )] 113 + project: bool, 114 + }, 115 + } 116 + 117 + #[derive(Subcommand)] 118 + enum LoginTargetCmd { 119 + #[command(about = "Log in as the publishing account (handle + app password)")] 120 + Pds { 121 + #[arg(long, help = "Handle (e.g. matt.example.com)")] 122 + handle: Option<String>, 123 + 124 + #[arg(long, help = "App password")] 125 + app_password: Option<String>, 126 + }, 127 + #[command(about = "Log in to a DNS plugin")] 128 + Dns { 129 + #[arg(help = "Plugin name, e.g. cloudflare")] 130 + plugin: String, 131 + 132 + /// Trailing `--field value` pairs are parsed against the plugin's 133 + /// options schema after the handshake. We use `allow_hyphen_values` 134 + /// + `trailing_var_arg` so clap doesn't reject unknown flags. 135 + #[arg( 136 + allow_hyphen_values = true, 137 + trailing_var_arg = true, 138 + num_args = 0.., 139 + help = "Plugin-specific --<field>=<value> or --<field> <value> arguments" 140 + )] 141 + args: Vec<String>, 142 + }, 143 + } 144 + 145 + #[derive(Subcommand)] 146 + enum LogoutTargetCmd { 147 + Pds, 148 + Dns { 149 + #[arg(help = "Plugin name. If omitted, every [dns.*] entry is cleared.")] 150 + plugin: Option<String>, 151 + }, 86 152 } 87 153 88 154 #[derive(Subcommand)] ··· 206 272 .into_diagnostic(), 207 273 Commands::Status => status::run_status().await.into_diagnostic(), 208 274 Commands::Diff { nsid } => diff::run_diff(nsid).await.into_diagnostic(), 275 + Commands::Login { 276 + target, 277 + project, 278 + non_interactive, 279 + } => { 280 + let scope = if project { 281 + Scope::Project 282 + } else { 283 + Scope::Global 284 + }; 285 + let mut args = login::LoginArgs { 286 + scope, 287 + non_interactive, 288 + ..Default::default() 289 + }; 290 + match target { 291 + None => login::run_login_all(args).await.into_diagnostic(), 292 + Some(LoginTargetCmd::Pds { 293 + handle, 294 + app_password, 295 + }) => { 296 + if let Some(h) = handle { 297 + args.field_overrides.insert("handle".into(), h); 298 + } 299 + if let Some(p) = app_password { 300 + args.field_overrides.insert("app_password".into(), p); 301 + } 302 + login::run_login_pds(args).await.into_diagnostic() 303 + } 304 + Some(LoginTargetCmd::Dns { 305 + plugin, 306 + args: raw_args, 307 + }) => { 308 + for (k, v) in parse_field_args(&raw_args) { 309 + args.field_overrides.insert(k, v); 310 + } 311 + login::run_login_dns(&plugin, args).await.into_diagnostic() 312 + } 313 + } 314 + } 315 + Commands::Logout { target, project } => { 316 + let scope = if project { 317 + Scope::Project 318 + } else { 319 + Scope::Global 320 + }; 321 + let (tgt, name) = match target { 322 + None => (LogoutTarget::All, None), 323 + Some(LogoutTargetCmd::Pds) => (LogoutTarget::Pds, None), 324 + Some(LogoutTargetCmd::Dns { plugin: None }) => (LogoutTarget::AllDns, None), 325 + Some(LogoutTargetCmd::Dns { plugin: Some(p) }) => (LogoutTarget::Dns, Some(p)), 326 + }; 327 + logout::run_logout(tgt, name.as_deref(), scope).into_diagnostic() 328 + } 209 329 }; 210 330 211 331 if let Err(e) = result { ··· 213 333 process::exit(1); 214 334 } 215 335 } 336 + 337 + /// Parse plugin-forwarded `--field value` / `--field=value` pairs into 338 + /// a flat `(field_name, value)` list. The plugin-declared options schema 339 + /// determines which names are valid; we just collect whatever was 340 + /// passed so login.rs can match against the schema. 341 + fn parse_field_args(raw: &[String]) -> Vec<(String, String)> { 342 + let mut out = Vec::new(); 343 + let mut i = 0; 344 + while i < raw.len() { 345 + let tok = &raw[i]; 346 + if let Some(rest) = tok.strip_prefix("--") { 347 + if let Some(eq) = rest.find('=') { 348 + let (k, v) = rest.split_at(eq); 349 + out.push((k.replace('-', "_"), v[1..].to_string())); 350 + } else if i + 1 < raw.len() { 351 + let k = rest.replace('-', "_"); 352 + out.push((k, raw[i + 1].clone())); 353 + i += 1; 354 + } 355 + } 356 + i += 1; 357 + } 358 + out 359 + }
+95
website/content/docs/cli/10-login.md
··· 1 + +++ 2 + title = "Login / Logout Commands" 3 + description = "Manage PDS and DNS plugin credentials" 4 + weight = 10 5 + +++ 6 + 7 + `mlf login` stores credentials for the publishing PDS and for each DNS provider plugin the workspace uses. `mlf logout` clears them. Credentials live in a plaintext TOML file — `~/.config/mlf/credentials.toml` by default — written with mode `0600`. Same pattern cargo and npm use. Pass `--project` to write to a workspace-local `.mlf/credentials.toml` instead (gitignored; useful in CI). 8 + 9 + ## Usage 10 + 11 + ### Login 12 + 13 + ```bash 14 + # Walk every credential the workspace needs; log in to whichever aren't already stored. 15 + mlf login 16 + 17 + # Log in to the PDS only. Fields: handle + app password. 18 + mlf login pds 19 + mlf login pds --handle matt.example.com --app-password $APP_PASSWORD 20 + 21 + # Log in to a DNS plugin. Per-plugin fields are forwarded from --flag=value 22 + # pairs after the plugin name; they're validated against the plugin's 23 + # options schema after the handshake. 24 + mlf login dns cloudflare 25 + mlf login dns cloudflare --api-token $CF_TOKEN 26 + ``` 27 + 28 + Flags (apply to every `login` variant): 29 + - `--project` — write to `<workspace>/.mlf/credentials.toml` instead of the global file. 30 + - `--non-interactive` — error out instead of prompting for any missing field. Useful in CI. 31 + 32 + ### Logout 33 + 34 + ```bash 35 + # Clear everything (both [pds] and every [dns.*]). 36 + mlf logout 37 + 38 + # Clear just [pds]. 39 + mlf logout pds 40 + 41 + # Clear every DNS credential. 42 + mlf logout dns 43 + 44 + # Clear one specific DNS credential. 45 + mlf logout dns cloudflare 46 + ``` 47 + 48 + `--project` applies the same way — targets the project-local file. 49 + 50 + ## What's stored 51 + 52 + ```toml 53 + # ~/.config/mlf/credentials.toml 54 + [pds] 55 + handle = "matt.example.com" 56 + app_password = "xxxx-xxxx-xxxx-xxxx" 57 + did = "did:plc:abc…" # cached from resolution, used as a hint 58 + pds = "https://pds.example.com" 59 + 60 + [dns.cloudflare] 61 + api_token = "…" 62 + ``` 63 + 64 + Field names under `[dns.<plugin>]` come from the plugin's options schema — whatever the plugin asked the host to collect on login. DNS plugins can add arbitrary keys; we preserve everything on round-trip. 65 + 66 + ## How credentials are resolved 67 + 68 + When a command needs a credential field, it walks this order and uses the first match: 69 + 70 + 1. CLI flag on the current command (e.g. `--api-token` for schema-driven plugins, `--app-password` for PDS). 71 + 2. Project credentials file (`.mlf/credentials.toml` in the workspace). 72 + 3. Global credentials file (`~/.config/mlf/credentials.toml`). 73 + 4. Interactive prompt — skipped in `--non-interactive` mode and when stdin isn't a TTY. 74 + 75 + Missing required field + non-interactive → error names the exact flag to set. 76 + 77 + ## CI patterns 78 + 79 + **One-shot login + publish:** 80 + ```yaml 81 + - run: mlf login pds --handle matt.example.com --app-password ${{ secrets.ATPROTO_APP_PASSWORD }} --project 82 + - run: mlf login dns cloudflare --api-token ${{ secrets.CLOUDFLARE_TOKEN }} --project 83 + - run: mlf publish --non-interactive 84 + ``` 85 + 86 + `--project` writes to `.mlf/credentials.toml` in the runner — ephemeral, no keychain needed, the subsequent `mlf publish` picks them up. (The file is protected with mode `0600` and should never be committed.) 87 + 88 + ## Exit codes 89 + 90 + - `0` — credentials validated and stored. 91 + - Non-zero — login rejected (e.g. 401 from `createSession`), plugin binary missing, or a required field was unanswered in non-interactive mode. 92 + 93 + ## See also 94 + 95 + - [Configuration → Package Identity](../02-configuration/#package-identity) — what declares which DNS plugins your workspace uses.