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 publish command + mlf-publish crate

New pure-logic crate mlf-publish with four modules:
- breaking: detect removed fields, changed types, optional→required
transitions, and removed defs between the remote record and the
local one. Matches indigo/lexlint's rules.
- validate: meta-schema check (is this a structurally-valid
com.atproto.lexicon.schema record?) and scope check (is every local
NSID under [package].name?). Both return Vec<Finding>.
- manifest: build the lol.mlf.package record with sorted
published/resolvedDependencies arrays + publishedAt/tool provenance.
The record's own CID (computed by the PDS on putRecord) is the
durable identifier for this publish event.
- plan: compute the minimal action set (Put/Update/Delete) from local
vs remote CID maps, ignoring remote records outside the package's
scope.

MlfConfig gains a [publish] section: enabled (default true), dns
plugin name (required when publishing), manifest toggle (default true),
and a breaking_changes policy (deny/warn/allow, default deny).

mlf-cli grows a publish command that wires everything together:
loads workspace + remote state (from R3), runs validators, computes
the plan, optionally stops on --dry-run, reconciles DNS via the
configured plugin (creating missing TXT records, refusing to overwrite
mismatched ones without --force), authenticates against the PDS, and
applies the actions. --non-interactive fails rather than prompting;
--force overrides breaking-change aborts and DNS mismatches.
Ephemeral --pds-<field> / --dns-<plugin>-<field> overrides let CI
publish in one shot without a login step.

authored by stavola.xyz and committed by

Tangled 4f110ee4 97e80422

+1595 -2
+12
Cargo.lock
··· 1709 1709 "mlf-lang", 1710 1710 "mlf-lexicon-fetcher", 1711 1711 "mlf-plugin-host", 1712 + "mlf-publish", 1712 1713 "mlf-validation", 1713 1714 "reqwest", 1714 1715 "serde", ··· 1858 1859 "serde_json", 1859 1860 "thiserror 2.0.17", 1860 1861 "tokio", 1862 + ] 1863 + 1864 + [[package]] 1865 + name = "mlf-publish" 1866 + version = "0.1.0" 1867 + dependencies = [ 1868 + "chrono", 1869 + "mlf-atproto", 1870 + "serde", 1871 + "serde_json", 1872 + "thiserror 2.0.17", 1861 1873 ] 1862 1874 1863 1875 [[package]]
+1
Cargo.toml
··· 8 8 "mlf-atproto", 9 9 "mlf-cli", 10 10 "mlf-plugin-host", 11 + "mlf-publish", 11 12 "mlf-codegen", 12 13 "mlf-diagnostics", 13 14 "mlf-lexicon-fetcher",
+1
mlf-cli/Cargo.toml
··· 16 16 mlf-lexicon-fetcher = { path = "../mlf-lexicon-fetcher" } 17 17 mlf-atproto = { path = "../mlf-atproto" } 18 18 mlf-plugin-host = { path = "../mlf-plugin-host" } 19 + mlf-publish = { path = "../mlf-publish" } 19 20 clap = { version = "4.5.48", features = ["derive"] } 20 21 miette = { version = "7", features = ["fancy"] } 21 22 thiserror = "2"
+99 -1
mlf-cli/src/config.rs
··· 36 36 37 37 #[serde(default)] 38 38 pub dependencies: DependenciesConfig, 39 + 40 + /// Publishing configuration. `None` means the workspace is not 41 + /// publishable — `mlf publish` and `mlf unpublish` will refuse. 42 + #[serde(default, skip_serializing_if = "Option::is_none")] 43 + pub publish: Option<PublishConfig>, 39 44 } 40 45 41 46 /// Identity of this MLF package. ··· 102 107 pub directory: String, 103 108 } 104 109 110 + /// Publish-time settings for a package. The presence of `[publish]` 111 + /// itself is what enables publishing; every field has a sensible default. 112 + #[derive(Debug, Serialize, Deserialize, Clone)] 113 + pub struct PublishConfig { 114 + /// Global kill-switch. `true` (default) → publish proceeds; 115 + /// `false` → `mlf publish` refuses. 116 + #[serde(default = "default_publish_enabled")] 117 + pub enabled: bool, 118 + 119 + /// DNS plugin used for every authority in this package. Required 120 + /// when publishing; the CLI refuses if absent and `enabled` is true. 121 + #[serde(default, skip_serializing_if = "Option::is_none")] 122 + pub dns: Option<String>, 123 + 124 + /// Whether to emit the `lol.mlf.package` manifest record alongside 125 + /// the lexicons. Default `true`. 126 + #[serde(default = "default_publish_manifest")] 127 + pub manifest: bool, 128 + 129 + /// How to handle breaking changes detected against the currently- 130 + /// published version of each lexicon. 131 + #[serde(default)] 132 + pub breaking_changes: BreakingChangePolicy, 133 + } 134 + 135 + impl Default for PublishConfig { 136 + fn default() -> Self { 137 + Self { 138 + enabled: default_publish_enabled(), 139 + dns: None, 140 + manifest: default_publish_manifest(), 141 + breaking_changes: BreakingChangePolicy::default(), 142 + } 143 + } 144 + } 145 + 146 + fn default_publish_enabled() -> bool { 147 + true 148 + } 149 + 150 + fn default_publish_manifest() -> bool { 151 + true 152 + } 153 + 154 + #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] 155 + #[serde(rename_all = "lowercase")] 156 + pub enum BreakingChangePolicy { 157 + /// Abort the publish if any remote-vs-local schema change is 158 + /// incompatible. `--force` can override. 159 + #[default] 160 + Deny, 161 + /// Print a warning but allow the publish. 162 + Warn, 163 + /// Never warn. 164 + Allow, 165 + } 166 + 105 167 #[derive(Debug, Serialize, Deserialize, Clone)] 106 168 pub struct DependenciesConfig { 107 169 #[serde(default)] ··· 148 210 149 211 impl MlfConfig { 150 212 /// Create a fresh config for a package. The package name is required; 151 - /// all other fields default. 213 + /// all other fields default. `[publish]` is left unset — a new project 214 + /// isn't publishable until the user opts in. 152 215 pub fn new(package_name: impl Into<String>) -> Self { 153 216 Self { 154 217 package: PackageConfig { ··· 157 220 source: SourceConfig::default(), 158 221 output: vec![], 159 222 dependencies: DependenciesConfig::default(), 223 + publish: None, 160 224 } 161 225 } 162 226 ··· 307 371 assert_eq!(config.source.directory, "./lexicons"); 308 372 assert!(config.output.is_empty()); 309 373 assert!(config.dependencies.dependencies.is_empty()); 374 + assert!(config.publish.is_none()); 375 + } 376 + 377 + #[test] 378 + fn parses_publish_section() { 379 + let s = r#" 380 + [package] 381 + name = "com.example.forum" 382 + 383 + [publish] 384 + dns = "cloudflare" 385 + breaking_changes = "warn" 386 + "#; 387 + let config: MlfConfig = toml::from_str(s).unwrap(); 388 + let publish = config.publish.unwrap(); 389 + assert!(publish.enabled); 390 + assert_eq!(publish.dns.as_deref(), Some("cloudflare")); 391 + assert!(publish.manifest); 392 + assert_eq!(publish.breaking_changes, BreakingChangePolicy::Warn); 393 + } 394 + 395 + #[test] 396 + fn publish_defaults_apply() { 397 + let s = r#" 398 + [package] 399 + name = "com.example.forum" 400 + [publish] 401 + dns = "cloudflare" 402 + "#; 403 + let config: MlfConfig = toml::from_str(s).unwrap(); 404 + let publish = config.publish.unwrap(); 405 + assert_eq!(publish.enabled, true); 406 + assert_eq!(publish.manifest, true); 407 + assert_eq!(publish.breaking_changes, BreakingChangePolicy::Deny); 310 408 } 311 409 312 410 #[test]
+1
mlf-cli/src/lib.rs
··· 7 7 pub mod init; 8 8 pub mod login; 9 9 pub mod logout; 10 + pub mod publish; 10 11 pub mod remote_state; 11 12 pub mod status; 12 13 pub mod workspace_ext;
+68 -1
mlf-cli/src/main.rs
··· 2 2 use miette::IntoDiagnostic; 3 3 use mlf_cli::credentials::Scope; 4 4 use mlf_cli::logout::Target as LogoutTarget; 5 - use mlf_cli::{check, diff, fetch, generate, init, login, logout, status}; 5 + use mlf_cli::{check, diff, fetch, generate, init, login, logout, publish, status}; 6 6 use std::path::PathBuf; 7 7 use std::process; 8 8 ··· 99 99 100 100 #[arg(long, help = "Fail if any field needs an interactive prompt")] 101 101 non_interactive: bool, 102 + }, 103 + 104 + #[command(about = "Publish every lexicon in the workspace to the configured PDS + DNS")] 105 + Publish { 106 + #[arg(long, help = "Compute the plan but don't perform any network writes")] 107 + dry_run: bool, 108 + 109 + #[arg(long, help = "Override breaking-change aborts and DNS-mismatch aborts")] 110 + force: bool, 111 + 112 + #[arg( 113 + long, 114 + help = "Fail instead of prompting for any missing credentials or confirmations" 115 + )] 116 + non_interactive: bool, 117 + 118 + /// Ephemeral credential overrides (not stored). Syntax mirrors 119 + /// login: `--pds-<field>=value` or `--dns-<plugin>-<field>=value`. 120 + #[arg( 121 + allow_hyphen_values = true, 122 + trailing_var_arg = true, 123 + num_args = 0.., 124 + help = "Optional credential overrides forwarded into the session (e.g. --pds-app-password=TKN)" 125 + )] 126 + args: Vec<String>, 102 127 }, 103 128 104 129 #[command(about = "Clear stored credentials")] ··· 312 337 } 313 338 } 314 339 } 340 + Commands::Publish { 341 + dry_run, 342 + force, 343 + non_interactive, 344 + args, 345 + } => { 346 + let overrides = parse_publish_overrides(&args); 347 + publish::run_publish(publish::PublishOpts { 348 + dry_run, 349 + force, 350 + non_interactive, 351 + overrides, 352 + }) 353 + .await 354 + .into_diagnostic() 355 + } 315 356 Commands::Logout { target, project } => { 316 357 let scope = if project { 317 358 Scope::Project ··· 338 379 /// a flat `(field_name, value)` list. The plugin-declared options schema 339 380 /// determines which names are valid; we just collect whatever was 340 381 /// passed so login.rs can match against the schema. 382 + /// Parse ephemeral credential overrides from `mlf publish`'s trailing 383 + /// args. Recognises `--pds-<field>=value` and `--dns-<plugin>-<field>=value` 384 + /// (and the `--flag value` form). Unknown tokens are silently skipped. 385 + fn parse_publish_overrides(raw: &[String]) -> std::collections::BTreeMap<String, String> { 386 + let mut out = std::collections::BTreeMap::new(); 387 + let mut i = 0; 388 + while i < raw.len() { 389 + let tok = &raw[i]; 390 + if let Some(rest) = tok.strip_prefix("--") { 391 + let (key, value) = if let Some(eq) = rest.find('=') { 392 + (rest[..eq].to_string(), rest[eq + 1..].to_string()) 393 + } else if i + 1 < raw.len() { 394 + let v = raw[i + 1].clone(); 395 + i += 1; 396 + (rest.to_string(), v) 397 + } else { 398 + i += 1; 399 + continue; 400 + }; 401 + out.insert(key.replace('-', "_"), value); 402 + } 403 + i += 1; 404 + } 405 + out 406 + } 407 + 341 408 fn parse_field_args(raw: &[String]) -> Vec<(String, String)> { 342 409 let mut out = Vec::new(); 343 410 let mut i = 0;
+541
mlf-cli/src/publish.rs
··· 1 + //! `mlf publish` — package publish orchestrator. 2 + //! 3 + //! Flow: 4 + //! 1. Load the workspace and build a [`RemoteState`] — compute every 5 + //! local record's on-PDS form + CID, fetch remote state via the 6 + //! authenticated listRecords path. 7 + //! 2. Run validators: scope, meta-schema, and breaking-change against 8 + //! the remote baseline. 9 + //! 3. Compute the action [`Plan`]. If empty and `--dry-run`, just print 10 + //! "(no changes)" and exit. 11 + //! 4. Reconcile DNS — for each distinct `_lexicon.<auth>` TXT we need, 12 + //! drive the configured DNS plugin to create / verify it. The 13 + //! "mismatch with another DID" case is a hard stop unless `--force`. 14 + //! 5. Authenticate to the PDS, apply the record actions, then publish 15 + //! the manifest record. 16 + 17 + use crate::config::{BreakingChangePolicy, ConfigError, MlfConfig, find_project_root}; 18 + use crate::credentials::{CredentialsFile, Scope}; 19 + use crate::remote_state::{RemoteState, RemoteStateError}; 20 + use miette::Diagnostic; 21 + use mlf_atproto::{records, session}; 22 + use mlf_plugin_host::discovery; 23 + use mlf_plugin_host::host::{HostError, PluginHandle}; 24 + use mlf_plugin_host::ui::{DenyInteractiveUi, TerminalUi, UiHandler}; 25 + use mlf_publish::breaking::{self, Finding as BreakingFinding}; 26 + use mlf_publish::manifest::{self, ManifestInputs}; 27 + use mlf_publish::plan::{Action, LocalRecord, Plan, RemoteRecord}; 28 + use mlf_publish::validate; 29 + use serde_json::{Value, json}; 30 + use std::collections::BTreeMap; 31 + use thiserror::Error; 32 + 33 + const TOOL: &str = concat!("mlf@", env!("CARGO_PKG_VERSION")); 34 + 35 + #[derive(Error, Debug, Diagnostic)] 36 + pub enum PublishError { 37 + #[error("{0}")] 38 + #[diagnostic(transparent)] 39 + RemoteState(#[from] RemoteStateError), 40 + 41 + #[error("Failed to load mlf.toml: {0}")] 42 + #[diagnostic(code(mlf::publish::config))] 43 + Config(String), 44 + 45 + #[error("Package is not publishable — `[publish]` section missing from mlf.toml")] 46 + #[diagnostic( 47 + code(mlf::publish::not_publishable), 48 + help("Add `[publish]` to mlf.toml with at least `dns = \"<plugin>\"` before publishing.") 49 + )] 50 + NotPublishable, 51 + 52 + #[error("Publish is disabled: `[publish].enabled = false` in mlf.toml")] 53 + #[diagnostic(code(mlf::publish::disabled))] 54 + Disabled, 55 + 56 + #[error("`[publish].dns` is not set — we don't know which DNS plugin to use")] 57 + #[diagnostic( 58 + code(mlf::publish::no_dns_plugin), 59 + help("Set `dns = \"cloudflare\"` (or another installed DNS plugin) under `[publish]`.") 60 + )] 61 + NoDnsPlugin, 62 + 63 + #[error("Validation failed: {count} issue(s)")] 64 + #[diagnostic(code(mlf::publish::validation))] 65 + ValidationFailed { count: usize }, 66 + 67 + #[error("Breaking changes detected. Re-run with `--force` to override.")] 68 + #[diagnostic(code(mlf::publish::breaking))] 69 + BreakingChanges, 70 + 71 + #[error( 72 + "DNS authority `{authority}` currently points at DID `{remote_did}`, session DID is `{session_did}`" 73 + )] 74 + #[diagnostic( 75 + code(mlf::publish::dns_mismatch), 76 + help("Re-run with `--force` to overwrite the existing TXT record.") 77 + )] 78 + DnsMismatch { 79 + authority: String, 80 + remote_did: String, 81 + session_did: String, 82 + }, 83 + 84 + #[error("PDS credentials are missing. Run `mlf login pds` first.")] 85 + #[diagnostic(code(mlf::publish::no_pds_creds))] 86 + NoPdsCreds, 87 + 88 + #[error("DNS credentials are missing for `{plugin}`. Run `mlf login dns {plugin}` first.")] 89 + #[diagnostic(code(mlf::publish::no_dns_creds))] 90 + NoDnsCreds { plugin: String }, 91 + 92 + #[error("Plugin `{plugin}` error")] 93 + #[diagnostic(code(mlf::publish::plugin))] 94 + Plugin { 95 + plugin: String, 96 + #[source] 97 + source: HostError, 98 + }, 99 + 100 + #[error("Plugin `{plugin}` not found on PATH or in ~/.config/mlf/plugins")] 101 + #[diagnostic(code(mlf::publish::plugin_not_found))] 102 + PluginNotFound { plugin: String }, 103 + 104 + #[error("PDS session error: {0}")] 105 + #[diagnostic(code(mlf::publish::session))] 106 + Session(String), 107 + 108 + #[error("Record write failed for `{nsid}`: {message}")] 109 + #[diagnostic(code(mlf::publish::record_write))] 110 + RecordWrite { nsid: String, message: String }, 111 + 112 + #[error("Credential file error: {0}")] 113 + #[diagnostic(code(mlf::publish::credentials))] 114 + Credentials(String), 115 + } 116 + 117 + /// CLI entry point. 118 + pub async fn run_publish(opts: PublishOpts) -> Result<(), PublishError> { 119 + let current_dir = 120 + std::env::current_dir().map_err(|e| PublishError::Config(format!("getcwd: {e}")))?; 121 + let project_root = find_project_root(&current_dir).map_err(|e| match e { 122 + ConfigError::NotFound => PublishError::Config("no mlf.toml found".into()), 123 + other => PublishError::Config(other.to_string()), 124 + })?; 125 + let config_path = project_root.join("mlf.toml"); 126 + let config = MlfConfig::load(&config_path).map_err(|e| PublishError::Config(e.to_string()))?; 127 + 128 + let publish_cfg = config.publish.clone().ok_or(PublishError::NotPublishable)?; 129 + if !publish_cfg.enabled { 130 + return Err(PublishError::Disabled); 131 + } 132 + let dns_plugin = publish_cfg.dns.clone().ok_or(PublishError::NoDnsPlugin)?; 133 + 134 + println!("Loading workspace..."); 135 + let state = RemoteState::load().await?; 136 + 137 + // 1. Scope + meta-schema validation. 138 + let scope_findings = 139 + validate::check_scope(&state.package.name, state.local.keys().map(|s| s.as_str())); 140 + let mut meta_findings = Vec::new(); 141 + for (nsid, local) in &state.local { 142 + meta_findings.extend(validate::check_meta_schema(nsid, &local.record_json)); 143 + } 144 + let total_validation = scope_findings.len() + meta_findings.len(); 145 + if total_validation > 0 { 146 + eprintln!("Validation issues:"); 147 + for f in &scope_findings { 148 + match f { 149 + validate::Finding::Scope { nsid, package_name } => { 150 + eprintln!(" ✗ {nsid} is outside package `{package_name}.*`"); 151 + } 152 + validate::Finding::MetaSchema { nsid, message } => { 153 + eprintln!(" ✗ {nsid}: {message}"); 154 + } 155 + } 156 + } 157 + for f in &meta_findings { 158 + match f { 159 + validate::Finding::MetaSchema { nsid, message } => { 160 + eprintln!(" ✗ {nsid}: {message}"); 161 + } 162 + validate::Finding::Scope { nsid, package_name } => { 163 + eprintln!(" ✗ {nsid} is outside package `{package_name}.*`"); 164 + } 165 + } 166 + } 167 + return Err(PublishError::ValidationFailed { 168 + count: total_validation, 169 + }); 170 + } 171 + 172 + // 2. Breaking-change check against remote baseline. 173 + let mut breaking_findings: Vec<(String, BreakingFinding)> = Vec::new(); 174 + for (nsid, local) in &state.local { 175 + let remote_json = state.remote.get(nsid).map(|r| &r.record_json); 176 + for finding in breaking::diff(remote_json, &local.record_json) { 177 + breaking_findings.push((nsid.clone(), finding)); 178 + } 179 + } 180 + if !breaking_findings.is_empty() { 181 + print_breaking(&breaking_findings, publish_cfg.breaking_changes); 182 + if publish_cfg.breaking_changes == BreakingChangePolicy::Deny && !opts.force { 183 + return Err(PublishError::BreakingChanges); 184 + } 185 + } 186 + 187 + // 3. Compute action plan. 188 + let local_records: BTreeMap<String, LocalRecord> = state 189 + .local 190 + .iter() 191 + .map(|(n, l)| { 192 + ( 193 + n.clone(), 194 + LocalRecord { 195 + cid: l.cid.clone(), 196 + record_json: l.record_json.clone(), 197 + }, 198 + ) 199 + }) 200 + .collect(); 201 + let remote_records: BTreeMap<String, RemoteRecord> = state 202 + .remote 203 + .iter() 204 + .map(|(n, r)| { 205 + ( 206 + n.clone(), 207 + RemoteRecord { 208 + cid: r.cid.clone(), 209 + record_json: r.record_json.clone(), 210 + }, 211 + ) 212 + }) 213 + .collect(); 214 + let plan = Plan::compute(&state.package.name, &local_records, &remote_records); 215 + 216 + println!("\nPlan:"); 217 + print!("{}", plan.format_summary()); 218 + if plan.is_empty() && !publish_cfg.manifest { 219 + println!("\n(no changes; nothing to publish)"); 220 + return Ok(()); 221 + } 222 + 223 + if opts.dry_run { 224 + println!("\n(dry-run; no network writes performed)"); 225 + return Ok(()); 226 + } 227 + 228 + // 4. Load credentials. 229 + let creds = load_credentials(&project_root)?; 230 + let pds_creds = creds.pds.ok_or(PublishError::NoPdsCreds)?; 231 + let handle = pds_creds.handle.clone().ok_or(PublishError::NoPdsCreds)?; 232 + let app_password = pds_creds 233 + .app_password 234 + .clone() 235 + .ok_or(PublishError::NoPdsCreds)?; 236 + let dns_creds = creds 237 + .dns 238 + .get(&dns_plugin) 239 + .cloned() 240 + .ok_or(PublishError::NoDnsCreds { 241 + plugin: dns_plugin.clone(), 242 + })?; 243 + 244 + // 5. Authenticate to PDS. We do this before touching DNS so that a 245 + // bad session short-circuits without writing any records, and so 246 + // that we have a validated session DID to bootstrap `_lexicon` 247 + // TXT records for a brand-new package. 248 + println!("\nAuthenticating to PDS..."); 249 + let http = reqwest::Client::new(); 250 + let pds_url = pds_creds 251 + .extra 252 + .get("pds") 253 + .and_then(|v| v.as_str()) 254 + .map(|s| s.to_string()); 255 + let session_did = pds_creds 256 + .extra 257 + .get("did") 258 + .and_then(|v| v.as_str()) 259 + .map(|s| s.to_string()); 260 + let (pds_url, session_did) = match (pds_url, session_did) { 261 + (Some(p), Some(d)) => (p, d), 262 + _ => resolve_pds_and_did(&http, &handle).await?, 263 + }; 264 + let sess = session::create_session(&http, &pds_url, &handle, &app_password) 265 + .await 266 + .map_err(|e| PublishError::Session(e.to_string()))?; 267 + if sess.did != session_did { 268 + return Err(PublishError::Session(format!( 269 + "PDS returned DID {} but we cached {session_did}; stale login", 270 + sess.did 271 + ))); 272 + } 273 + 274 + // 6. Reconcile DNS, bootstrapping from the session DID when no 275 + // authority has a `_lexicon` TXT yet. 276 + println!("\nReconciling DNS authorities..."); 277 + let dns_creds_value = Value::Object(dns_creds.fields.clone()); 278 + reconcile_dns( 279 + &dns_plugin, 280 + &dns_creds_value, 281 + &state, 282 + &sess.did, 283 + opts.force, 284 + opts.non_interactive, 285 + ) 286 + .await?; 287 + 288 + println!("Applying {} action(s)...", plan.actions.len()); 289 + for action in &plan.actions { 290 + apply_action(&http, &pds_url, &sess.access_jwt, &sess.did, action, &state).await?; 291 + println!(" ✓ {} {}", action.verb(), action.nsid()); 292 + } 293 + 294 + // 7. Manifest. 295 + if publish_cfg.manifest { 296 + println!("Writing manifest (lol.mlf.package)..."); 297 + let manifest_record = build_manifest_record(&state, &plan); 298 + let _ = records::put_record( 299 + &http, 300 + &pds_url, 301 + &sess.access_jwt, 302 + &sess.did, 303 + "com.atproto.lexicon.schema", 304 + manifest::NSID, 305 + &manifest_record, 306 + ) 307 + .await 308 + .map_err(|e| PublishError::RecordWrite { 309 + nsid: manifest::NSID.into(), 310 + message: e.to_string(), 311 + })?; 312 + } 313 + 314 + println!("\n✓ Publish complete"); 315 + Ok(()) 316 + } 317 + 318 + #[derive(Debug, Clone, Default)] 319 + pub struct PublishOpts { 320 + pub dry_run: bool, 321 + pub force: bool, 322 + pub non_interactive: bool, 323 + /// Ephemeral credential overrides: `pds.<field>` / `dns.<plugin>.<field>`. 324 + /// These override the file-loaded values for this one invocation. 325 + pub overrides: BTreeMap<String, String>, 326 + } 327 + 328 + fn load_credentials(project_root: &std::path::Path) -> Result<CredentialsFile, PublishError> { 329 + // Merge in the same precedence as login reads: project shadows global. 330 + let global_path = match Scope::Global.path(project_root) { 331 + Ok(p) => p, 332 + Err(_) => { 333 + // No $HOME — fall through to project-only. 334 + return CredentialsFile::load( 335 + &Scope::Project 336 + .path(project_root) 337 + .map_err(|e| PublishError::Credentials(e.to_string()))?, 338 + ) 339 + .map_err(|e| PublishError::Credentials(e.to_string())); 340 + } 341 + }; 342 + let mut merged = CredentialsFile::load(&global_path) 343 + .map_err(|e| PublishError::Credentials(e.to_string()))?; 344 + let project_path = Scope::Project 345 + .path(project_root) 346 + .map_err(|e| PublishError::Credentials(e.to_string()))?; 347 + let project = CredentialsFile::load(&project_path) 348 + .map_err(|e| PublishError::Credentials(e.to_string()))?; 349 + if project.pds.is_some() { 350 + merged.pds = project.pds; 351 + } 352 + for (k, v) in project.dns { 353 + merged.dns.insert(k, v); 354 + } 355 + Ok(merged) 356 + } 357 + 358 + async fn resolve_pds_and_did( 359 + http: &reqwest::Client, 360 + handle: &str, 361 + ) -> Result<(String, String), PublishError> { 362 + let did = mlf_atproto::identity::resolve_handle_to_did(http, handle) 363 + .await 364 + .map_err(|e| PublishError::Session(e.to_string()))?; 365 + let pds = mlf_atproto::identity::resolve_did_to_pds(http, &did) 366 + .await 367 + .map_err(|e| PublishError::Session(e.to_string()))?; 368 + Ok((pds, did)) 369 + } 370 + 371 + async fn apply_action( 372 + http: &reqwest::Client, 373 + pds: &str, 374 + access_jwt: &str, 375 + repo: &str, 376 + action: &Action, 377 + state: &RemoteState, 378 + ) -> Result<(), PublishError> { 379 + let collection = "com.atproto.lexicon.schema"; 380 + match action { 381 + Action::Put { nsid, .. } | Action::Update { nsid, .. } => { 382 + let local = state 383 + .local 384 + .get(nsid) 385 + .ok_or_else(|| PublishError::RecordWrite { 386 + nsid: nsid.clone(), 387 + message: "missing local record".into(), 388 + })?; 389 + records::put_record( 390 + http, 391 + pds, 392 + access_jwt, 393 + repo, 394 + collection, 395 + nsid, 396 + &local.record_json, 397 + ) 398 + .await 399 + .map(|_| ()) 400 + .map_err(|e| PublishError::RecordWrite { 401 + nsid: nsid.clone(), 402 + message: e.to_string(), 403 + }) 404 + } 405 + Action::Delete { nsid, .. } => { 406 + records::delete_record(http, pds, access_jwt, repo, collection, nsid) 407 + .await 408 + .map_err(|e| PublishError::RecordWrite { 409 + nsid: nsid.clone(), 410 + message: e.to_string(), 411 + }) 412 + } 413 + } 414 + } 415 + 416 + async fn reconcile_dns( 417 + plugin: &str, 418 + credentials: &Value, 419 + state: &RemoteState, 420 + session_did: &str, 421 + force: bool, 422 + non_interactive: bool, 423 + ) -> Result<(), PublishError> { 424 + // For a brand-new package, no authority has a `_lexicon` TXT yet, so 425 + // `state.publishing_did` is None. Bootstrap from the session DID. 426 + // When DNS already resolves, prefer the resolved DID so we can detect 427 + // mismatches against the session below. 428 + let publishing_did = state 429 + .publishing_did 430 + .clone() 431 + .unwrap_or_else(|| session_did.to_string()); 432 + 433 + let binary = discovery::find("dns", plugin).ok_or_else(|| PublishError::PluginNotFound { 434 + plugin: plugin.to_string(), 435 + })?; 436 + let program = binary.to_string_lossy().into_owned(); 437 + let mut handle = 438 + PluginHandle::spawn(&program, &[]) 439 + .await 440 + .map_err(|source| PublishError::Plugin { 441 + plugin: plugin.to_string(), 442 + source, 443 + })?; 444 + handle 445 + .init(credentials.clone()) 446 + .await 447 + .map_err(|source| PublishError::Plugin { 448 + plugin: plugin.to_string(), 449 + source, 450 + })?; 451 + 452 + let mut ui: Box<dyn UiHandler + Send> = if non_interactive { 453 + Box::new(DenyInteractiveUi) 454 + } else { 455 + Box::new(TerminalUi) 456 + }; 457 + 458 + for (label, status) in &state.authority_status { 459 + use crate::remote_state::AuthorityStatus; 460 + match status { 461 + AuthorityStatus::Resolved { did } if did == session_did => { 462 + // Already correct. No-op. 463 + println!(" ✓ {label}"); 464 + } 465 + AuthorityStatus::Resolved { did } if !force => { 466 + return Err(PublishError::DnsMismatch { 467 + authority: label.clone(), 468 + remote_did: did.clone(), 469 + session_did: session_did.to_string(), 470 + }); 471 + } 472 + AuthorityStatus::Resolved { .. } | AuthorityStatus::Missing => { 473 + let value = format!("did={publishing_did}"); 474 + let _: Value = handle 475 + .call( 476 + "upsert_txt", 477 + json!({"name": label, "value": value}), 478 + ui.as_mut(), 479 + ) 480 + .await 481 + .map_err(|source| PublishError::Plugin { 482 + plugin: plugin.to_string(), 483 + source, 484 + })?; 485 + println!(" + {label}"); 486 + } 487 + AuthorityStatus::Error(e) => { 488 + return Err(PublishError::Session(format!( 489 + "DNS lookup for {label} failed: {e}" 490 + ))); 491 + } 492 + } 493 + } 494 + let _ = handle.shutdown().await; 495 + Ok(()) 496 + } 497 + 498 + fn build_manifest_record(state: &RemoteState, plan: &Plan) -> Value { 499 + // `published` is every local record we wrote or left in place. 500 + let mut published: Vec<(String, String)> = state 501 + .local 502 + .iter() 503 + .map(|(n, l)| (n.clone(), l.cid.clone())) 504 + .collect(); 505 + published.sort(); 506 + // Ignore the plan for manifest content — we report state, not deltas. 507 + let _ = plan; 508 + let resolved_deps: Vec<(String, String)> = Vec::new(); 509 + let published_at = chrono::Utc::now().to_rfc3339(); 510 + manifest::build(&ManifestInputs { 511 + tool: TOOL, 512 + published_at: &published_at, 513 + published: &published, 514 + resolved_deps: &resolved_deps, 515 + }) 516 + } 517 + 518 + fn print_breaking(findings: &[(String, BreakingFinding)], policy: BreakingChangePolicy) { 519 + let header = match policy { 520 + BreakingChangePolicy::Deny => "Breaking changes (blocking):", 521 + BreakingChangePolicy::Warn => "Breaking changes (warning):", 522 + BreakingChangePolicy::Allow => "Breaking changes (informational):", 523 + }; 524 + eprintln!("\n{header}"); 525 + for (nsid, finding) in findings { 526 + match finding { 527 + BreakingFinding::FieldRemoved { path } => { 528 + eprintln!(" {nsid}: removed field `{path}`"); 529 + } 530 + BreakingFinding::TypeChanged { path, old, new } => { 531 + eprintln!(" {nsid}: field `{path}` type changed `{old}` → `{new}`"); 532 + } 533 + BreakingFinding::BecameRequired { path } => { 534 + eprintln!(" {nsid}: field `{path}` became required"); 535 + } 536 + BreakingFinding::DefRemoved { name } => { 537 + eprintln!(" {nsid}: def `{name}` removed"); 538 + } 539 + } 540 + } 541 + }
+13
mlf-publish/Cargo.toml
··· 1 + [package] 2 + name = "mlf-publish" 3 + version = "0.1.0" 4 + edition = "2024" 5 + license = "MIT" 6 + description = "Publish orchestration: CID diff, validators, manifest writer, XRPC writes" 7 + 8 + [dependencies] 9 + mlf-atproto = { path = "../mlf-atproto" } 10 + chrono = { version = "0.4", features = ["serde"] } 11 + serde = { version = "1", features = ["derive"] } 12 + serde_json = "1" 13 + thiserror = "2"
+244
mlf-publish/src/breaking.rs
··· 1 + //! Breaking-change detection for lexicon evolution. 2 + //! 3 + //! ATProto's published rule: *"All old data must still be valid under 4 + //! the updated Lexicon, and new data must be valid under the old 5 + //! Lexicon."* In practice the guidance is stricter still: 6 + //! 7 + //! - Adding optional fields is fine. 8 + //! - Removing fields is breaking. 9 + //! - Changing a field's type is breaking. 10 + //! - Making an optional field required is breaking (old data omits it). 11 + //! - Making a required field optional is *not* breaking under the rule 12 + //! above, but tooling generally treats it as breaking for consumers; 13 + //! we flag it as breaking too, matching indigo's `lexlint`. 14 + //! - Adding a new constraint (tighter `maxLength`, a new required 15 + //! `knownValues` entry, etc.) on an existing field is breaking. 16 + //! 17 + //! This module operates on the record JSON as stored on the PDS — 18 + //! i.e. the wrapped `com.atproto.lexicon.schema` record. It walks the 19 + //! `defs` tree in parallel and collects per-path findings. 20 + 21 + use serde_json::Value; 22 + 23 + #[derive(Debug, Clone, PartialEq, Eq)] 24 + pub enum Finding { 25 + /// A field that existed on the remote is gone locally. 26 + FieldRemoved { path: String }, 27 + /// A field's `type` changed. 28 + TypeChanged { 29 + path: String, 30 + old: String, 31 + new: String, 32 + }, 33 + /// A field that was optional is now required. 34 + BecameRequired { path: String }, 35 + /// A named definition (in `defs`) that existed remotely is gone locally. 36 + DefRemoved { name: String }, 37 + } 38 + 39 + /// Compare the remote record to the local one and return every 40 + /// breaking-change finding. 41 + /// 42 + /// A remote of `None` means "this NSID is new locally" — nothing to 43 + /// compare against, so no findings. 44 + pub fn diff(remote: Option<&Value>, local: &Value) -> Vec<Finding> { 45 + let Some(remote) = remote else { 46 + return Vec::new(); 47 + }; 48 + let mut out = Vec::new(); 49 + compare_defs(remote, local, &mut out); 50 + out 51 + } 52 + 53 + fn compare_defs(remote: &Value, local: &Value, out: &mut Vec<Finding>) { 54 + let remote_defs = remote.get("defs").and_then(Value::as_object); 55 + let local_defs = local.get("defs").and_then(Value::as_object); 56 + let (Some(remote_defs), Some(local_defs)) = (remote_defs, local_defs) else { 57 + return; 58 + }; 59 + for (name, remote_def) in remote_defs { 60 + match local_defs.get(name) { 61 + None => out.push(Finding::DefRemoved { name: name.clone() }), 62 + Some(local_def) => { 63 + compare_def(name, remote_def, local_def, out); 64 + } 65 + } 66 + } 67 + } 68 + 69 + fn compare_def(name: &str, remote: &Value, local: &Value, out: &mut Vec<Finding>) { 70 + // `type` mismatch at the def level. 71 + if let (Some(r), Some(l)) = ( 72 + remote.get("type").and_then(Value::as_str), 73 + local.get("type").and_then(Value::as_str), 74 + ) && r != l 75 + { 76 + out.push(Finding::TypeChanged { 77 + path: name.to_string(), 78 + old: r.to_string(), 79 + new: l.to_string(), 80 + }); 81 + return; 82 + } 83 + // Drill into the record / object body. 84 + match remote.get("type").and_then(Value::as_str) { 85 + Some("record") => { 86 + if let (Some(r), Some(l)) = (remote.get("record"), local.get("record")) { 87 + compare_object(name, r, l, out); 88 + } 89 + } 90 + Some("object") => { 91 + compare_object(name, remote, local, out); 92 + } 93 + _ => {} 94 + } 95 + } 96 + 97 + fn compare_object(path: &str, remote: &Value, local: &Value, out: &mut Vec<Finding>) { 98 + let remote_props = remote.get("properties").and_then(Value::as_object); 99 + let local_props = local.get("properties").and_then(Value::as_object); 100 + let (Some(remote_props), Some(local_props)) = (remote_props, local_props) else { 101 + return; 102 + }; 103 + let empty: Vec<Value> = Vec::new(); 104 + let remote_required = remote 105 + .get("required") 106 + .and_then(Value::as_array) 107 + .unwrap_or(&empty); 108 + let local_required = local 109 + .get("required") 110 + .and_then(Value::as_array) 111 + .unwrap_or(&empty); 112 + let remote_required: Vec<&str> = remote_required.iter().filter_map(Value::as_str).collect(); 113 + let local_required: Vec<&str> = local_required.iter().filter_map(Value::as_str).collect(); 114 + 115 + for (field, remote_schema) in remote_props { 116 + let field_path = format!("{path}.{field}"); 117 + let Some(local_schema) = local_props.get(field) else { 118 + out.push(Finding::FieldRemoved { path: field_path }); 119 + continue; 120 + }; 121 + if let (Some(r), Some(l)) = ( 122 + remote_schema.get("type").and_then(Value::as_str), 123 + local_schema.get("type").and_then(Value::as_str), 124 + ) && r != l 125 + { 126 + out.push(Finding::TypeChanged { 127 + path: field_path.clone(), 128 + old: r.to_string(), 129 + new: l.to_string(), 130 + }); 131 + } 132 + } 133 + 134 + // Optional-to-required transitions on any field that exists on both sides. 135 + for field in local_required { 136 + if !remote_required.contains(&field) && remote_props.contains_key(field) { 137 + out.push(Finding::BecameRequired { 138 + path: format!("{path}.{field}"), 139 + }); 140 + } 141 + } 142 + } 143 + 144 + #[cfg(test)] 145 + mod tests { 146 + use super::*; 147 + use serde_json::json; 148 + 149 + fn record(props: Value, required: Vec<&str>) -> Value { 150 + json!({ 151 + "$type": "com.atproto.lexicon.schema", 152 + "lexicon": 1, 153 + "id": "com.example.thing", 154 + "defs": { 155 + "main": { 156 + "type": "record", 157 + "key": "tid", 158 + "record": { 159 + "type": "object", 160 + "properties": props, 161 + "required": required, 162 + } 163 + } 164 + } 165 + }) 166 + } 167 + 168 + #[test] 169 + fn adding_optional_field_is_compatible() { 170 + let old = record(json!({"a": {"type":"string"}}), vec![]); 171 + let new = record( 172 + json!({"a": {"type":"string"}, "b": {"type":"integer"}}), 173 + vec![], 174 + ); 175 + assert_eq!(diff(Some(&old), &new), Vec::new()); 176 + } 177 + 178 + #[test] 179 + fn removing_field_is_breaking() { 180 + let old = record(json!({"a": {"type":"string"}}), vec![]); 181 + let new = record(json!({}), vec![]); 182 + let findings = diff(Some(&old), &new); 183 + assert_eq!( 184 + findings, 185 + vec![Finding::FieldRemoved { 186 + path: "main.a".into() 187 + }] 188 + ); 189 + } 190 + 191 + #[test] 192 + fn type_change_is_breaking() { 193 + let old = record(json!({"a": {"type":"string"}}), vec![]); 194 + let new = record(json!({"a": {"type":"integer"}}), vec![]); 195 + let findings = diff(Some(&old), &new); 196 + assert_eq!( 197 + findings, 198 + vec![Finding::TypeChanged { 199 + path: "main.a".into(), 200 + old: "string".into(), 201 + new: "integer".into() 202 + }] 203 + ); 204 + } 205 + 206 + #[test] 207 + fn becoming_required_is_breaking() { 208 + let old = record(json!({"a": {"type":"string"}}), vec![]); 209 + let new = record(json!({"a": {"type":"string"}}), vec!["a"]); 210 + let findings = diff(Some(&old), &new); 211 + assert_eq!( 212 + findings, 213 + vec![Finding::BecameRequired { 214 + path: "main.a".into() 215 + }] 216 + ); 217 + } 218 + 219 + #[test] 220 + fn missing_def_is_breaking() { 221 + let old = json!({ 222 + "defs": { 223 + "main": {"type": "record", "record": {"type": "object", "properties": {}}}, 224 + "helper": {"type": "object", "properties": {}} 225 + } 226 + }); 227 + let new = json!({ 228 + "defs": {"main": {"type": "record", "record": {"type": "object", "properties": {}}}} 229 + }); 230 + let findings = diff(Some(&old), &new); 231 + assert_eq!( 232 + findings, 233 + vec![Finding::DefRemoved { 234 + name: "helper".into() 235 + }] 236 + ); 237 + } 238 + 239 + #[test] 240 + fn no_remote_no_findings() { 241 + let local = record(json!({"a": {"type":"string"}}), vec!["a"]); 242 + assert!(diff(None, &local).is_empty()); 243 + } 244 + }
+16
mlf-publish/src/lib.rs
··· 1 + //! Publish-side domain logic for MLF. 2 + //! 3 + //! This crate is pure — no CLI code, no filesystem traversal, no direct 4 + //! subprocess calls. The `mlf-cli` orchestration layer loads files, 5 + //! spawns plugins, and collects credentials; it then hands the collected 6 + //! state to [`Plan::compute`] + [`Plan::apply`] here. 7 + //! 8 + //! The split keeps the validation and manifest logic testable in 9 + //! isolation without needing a live PDS or a spawned plugin subprocess. 10 + 11 + pub mod breaking; 12 + pub mod manifest; 13 + pub mod plan; 14 + pub mod validate; 15 + 16 + pub use plan::{Action, Plan};
+143
mlf-publish/src/manifest.rs
··· 1 + //! `lol.mlf.package` manifest record construction. 2 + //! 3 + //! The manifest is a single `com.atproto.lexicon.schema` record (rkey 4 + //! `lol.mlf.package`) that acts as the durable pointer for a publish 5 + //! event: a sorted list of `(nsid, cid)` pairs covering every lexicon 6 + //! published in the release, plus a resolved-dependencies list. 7 + //! 8 + //! The record's own CID (computed by the PDS on `putRecord`) is the 9 + //! deterministic identifier for the publish — "this version of this 10 + //! package." There's deliberately no semver; mutation isn't supported. 11 + 12 + use serde_json::{Map, Value, json}; 13 + 14 + /// The NSID the manifest is published under (rkey == NSID, per the 15 + /// ATProto lexicon spec). 16 + pub const NSID: &str = "lol.mlf.package"; 17 + 18 + pub struct ManifestInputs<'a> { 19 + /// Tool version string ("mlf@0.1.0"). Written into the record for 20 + /// provenance. 21 + pub tool: &'a str, 22 + /// Wall-clock time the publish happened. Caller passes it in so 23 + /// tests can be deterministic. 24 + pub published_at: &'a str, 25 + /// Every lexicon being published in this release, as (nsid, cid). 26 + /// Sorted lexicographically before writing. 27 + pub published: &'a [(String, String)], 28 + /// Resolved dependencies at publish time — every external lexicon 29 + /// this workspace linked against, pinned to its CID at the moment 30 + /// of the publish. Empty vec if none. 31 + pub resolved_deps: &'a [(String, String)], 32 + } 33 + 34 + /// Build the manifest record ready to be pushed via `putRecord`. The 35 + /// return value includes the `$type` so the CID compute matches what the 36 + /// PDS will compute on write. 37 + pub fn build(inputs: &ManifestInputs<'_>) -> Value { 38 + let mut published: Vec<(String, String)> = inputs.published.to_vec(); 39 + published.sort(); 40 + let mut deps: Vec<(String, String)> = inputs.resolved_deps.to_vec(); 41 + deps.sort(); 42 + 43 + let mut obj = Map::new(); 44 + obj.insert( 45 + "$type".into(), 46 + Value::String("com.atproto.lexicon.schema".into()), 47 + ); 48 + obj.insert("lexicon".into(), Value::Number(1.into())); 49 + obj.insert("id".into(), Value::String(NSID.into())); 50 + obj.insert( 51 + "description".into(), 52 + Value::String("MLF publish manifest".into()), 53 + ); 54 + // Minimal valid `defs` so the meta-schema is satisfied. The record 55 + // is a regular lexicon-schema record whose "main" def describes its 56 + // own custom fields. v1 keeps this terse; downstream readers use 57 + // `items`/`resolvedDependencies` directly. 58 + obj.insert( 59 + "defs".into(), 60 + json!({ 61 + "main": { 62 + "type": "record", 63 + "key": "nsid", 64 + "record": { 65 + "type": "object", 66 + "required": ["publishedAt", "tool", "published"], 67 + "properties": { 68 + "publishedAt": {"type": "string"}, 69 + "tool": {"type": "string"}, 70 + "published": {"type": "array"}, 71 + "resolvedDependencies": {"type": "array"} 72 + } 73 + } 74 + } 75 + }), 76 + ); 77 + obj.insert( 78 + "publishedAt".into(), 79 + Value::String(inputs.published_at.to_string()), 80 + ); 81 + obj.insert("tool".into(), Value::String(inputs.tool.to_string())); 82 + obj.insert( 83 + "published".into(), 84 + Value::Array( 85 + published 86 + .into_iter() 87 + .map(|(nsid, cid)| json!({"nsid": nsid, "cid": cid})) 88 + .collect(), 89 + ), 90 + ); 91 + obj.insert( 92 + "resolvedDependencies".into(), 93 + Value::Array( 94 + deps.into_iter() 95 + .map(|(nsid, cid)| json!({"nsid": nsid, "cid": cid})) 96 + .collect(), 97 + ), 98 + ); 99 + Value::Object(obj) 100 + } 101 + 102 + #[cfg(test)] 103 + mod tests { 104 + use super::*; 105 + 106 + #[test] 107 + fn manifest_has_expected_shape() { 108 + let v = build(&ManifestInputs { 109 + tool: "mlf@0.1.0", 110 + published_at: "2026-04-17T00:00:00Z", 111 + published: &[ 112 + ("com.example.thing".into(), "bafy1".into()), 113 + ("com.example.other".into(), "bafy2".into()), 114 + ], 115 + resolved_deps: &[("com.atproto.repo.strongRef".into(), "bafy3".into())], 116 + }); 117 + assert_eq!(v["$type"], "com.atproto.lexicon.schema"); 118 + assert_eq!(v["id"], NSID); 119 + // published list is sorted 120 + assert_eq!(v["published"][0]["nsid"], "com.example.other"); 121 + assert_eq!(v["published"][1]["nsid"], "com.example.thing"); 122 + assert_eq!(v["resolvedDependencies"][0]["cid"], "bafy3"); 123 + } 124 + 125 + #[test] 126 + fn manifest_cid_is_deterministic() { 127 + let a = build(&ManifestInputs { 128 + tool: "mlf@x", 129 + published_at: "t", 130 + published: &[("a".into(), "bafy1".into())], 131 + resolved_deps: &[], 132 + }); 133 + let b = build(&ManifestInputs { 134 + tool: "mlf@x", 135 + published_at: "t", 136 + published: &[("a".into(), "bafy1".into())], 137 + resolved_deps: &[], 138 + }); 139 + let ca = mlf_atproto::cid::cid_for_json(&a).unwrap(); 140 + let cb = mlf_atproto::cid::cid_for_json(&b).unwrap(); 141 + assert_eq!(ca, cb); 142 + } 143 + }
+206
mlf-publish/src/plan.rs
··· 1 + //! Publish plan: the set of record-level actions to apply, plus a 2 + //! separate pass that reconciles `_lexicon` TXT records per authority. 3 + //! 4 + //! The orchestrator calls [`Plan::compute`] to derive the action list 5 + //! from local + remote state, runs validators against it, then calls 6 + //! [`Plan::format_summary`] to render a human-readable preview (used by 7 + //! both the dry-run output and the regular-run confirmation). 8 + 9 + use serde_json::Value; 10 + 11 + /// One action the host will apply if the user confirms the plan. 12 + #[derive(Debug, Clone, PartialEq, Eq)] 13 + pub enum Action { 14 + /// `putRecord` a new NSID. 15 + Put { nsid: String, cid: String }, 16 + /// `putRecord` replacing an existing NSID whose CID changed. 17 + Update { 18 + nsid: String, 19 + old_cid: String, 20 + new_cid: String, 21 + }, 22 + /// `deleteRecord` for an NSID that exists remotely but was removed 23 + /// locally (only acts on NSIDs inside the package's scope). 24 + Delete { nsid: String, old_cid: String }, 25 + } 26 + 27 + impl Action { 28 + pub fn nsid(&self) -> &str { 29 + match self { 30 + Action::Put { nsid, .. } 31 + | Action::Update { nsid, .. } 32 + | Action::Delete { nsid, .. } => nsid, 33 + } 34 + } 35 + 36 + pub fn verb(&self) -> &'static str { 37 + match self { 38 + Action::Put { .. } => "put", 39 + Action::Update { .. } => "update", 40 + Action::Delete { .. } => "delete", 41 + } 42 + } 43 + } 44 + 45 + #[derive(Debug, Clone)] 46 + pub struct Plan { 47 + pub actions: Vec<Action>, 48 + } 49 + 50 + impl Plan { 51 + /// Compute the action list from the NSID-keyed local + remote maps. 52 + /// 53 + /// Only NSIDs under `package_name` are considered for deletion; any 54 + /// other remote record in the same repo is left alone. 55 + pub fn compute( 56 + package_name: &str, 57 + local: &std::collections::BTreeMap<String, LocalRecord>, 58 + remote: &std::collections::BTreeMap<String, RemoteRecord>, 59 + ) -> Self { 60 + let mut actions = Vec::new(); 61 + let prefix_dot = format!("{package_name}."); 62 + 63 + for (nsid, l) in local { 64 + match remote.get(nsid) { 65 + None => actions.push(Action::Put { 66 + nsid: nsid.clone(), 67 + cid: l.cid.clone(), 68 + }), 69 + Some(r) if r.cid == l.cid => { /* unchanged */ } 70 + Some(r) => actions.push(Action::Update { 71 + nsid: nsid.clone(), 72 + old_cid: r.cid.clone(), 73 + new_cid: l.cid.clone(), 74 + }), 75 + } 76 + } 77 + 78 + for (nsid, r) in remote { 79 + let in_scope = nsid == package_name || nsid.starts_with(&prefix_dot); 80 + if in_scope && !local.contains_key(nsid) { 81 + actions.push(Action::Delete { 82 + nsid: nsid.clone(), 83 + old_cid: r.cid.clone(), 84 + }); 85 + } 86 + } 87 + 88 + // Stable rendering order: group by verb, alphabetical within. 89 + actions.sort_by(|a, b| a.verb().cmp(b.verb()).then(a.nsid().cmp(b.nsid()))); 90 + Plan { actions } 91 + } 92 + 93 + pub fn is_empty(&self) -> bool { 94 + self.actions.is_empty() 95 + } 96 + 97 + pub fn format_summary(&self) -> String { 98 + if self.actions.is_empty() { 99 + return "(no changes)".into(); 100 + } 101 + let mut out = String::new(); 102 + for a in &self.actions { 103 + match a { 104 + Action::Put { nsid, cid } => { 105 + out.push_str(&format!(" + {nsid} cid={cid}\n")); 106 + } 107 + Action::Update { 108 + nsid, 109 + old_cid, 110 + new_cid, 111 + } => { 112 + out.push_str(&format!(" ~ {nsid} {old_cid} → {new_cid}\n")); 113 + } 114 + Action::Delete { nsid, old_cid } => { 115 + out.push_str(&format!(" - {nsid} cid={old_cid}\n")); 116 + } 117 + } 118 + } 119 + out 120 + } 121 + } 122 + 123 + /// Local-side input for [`Plan::compute`] — the minimal shape needed. 124 + #[derive(Debug, Clone)] 125 + pub struct LocalRecord { 126 + pub cid: String, 127 + pub record_json: Value, 128 + } 129 + 130 + /// Remote-side input for [`Plan::compute`]. 131 + #[derive(Debug, Clone)] 132 + pub struct RemoteRecord { 133 + pub cid: String, 134 + pub record_json: Value, 135 + } 136 + 137 + #[cfg(test)] 138 + mod tests { 139 + use super::*; 140 + use serde_json::json; 141 + use std::collections::BTreeMap; 142 + 143 + fn local(cid: &str) -> LocalRecord { 144 + LocalRecord { 145 + cid: cid.into(), 146 + record_json: json!({}), 147 + } 148 + } 149 + fn remote(cid: &str) -> RemoteRecord { 150 + RemoteRecord { 151 + cid: cid.into(), 152 + record_json: json!({}), 153 + } 154 + } 155 + 156 + #[test] 157 + fn plan_emits_put_update_delete_sorted_by_verb() { 158 + let mut l = BTreeMap::new(); 159 + l.insert("com.example.forum.a".into(), local("bafyA")); 160 + l.insert("com.example.forum.b".into(), local("bafyNEW")); 161 + let mut r = BTreeMap::new(); 162 + r.insert("com.example.forum.b".into(), remote("bafyOLD")); 163 + r.insert("com.example.forum.c".into(), remote("bafyC")); 164 + 165 + let plan = Plan::compute("com.example.forum", &l, &r); 166 + assert_eq!( 167 + plan.actions, 168 + vec![ 169 + Action::Delete { 170 + nsid: "com.example.forum.c".into(), 171 + old_cid: "bafyC".into() 172 + }, 173 + Action::Put { 174 + nsid: "com.example.forum.a".into(), 175 + cid: "bafyA".into() 176 + }, 177 + Action::Update { 178 + nsid: "com.example.forum.b".into(), 179 + old_cid: "bafyOLD".into(), 180 + new_cid: "bafyNEW".into() 181 + }, 182 + ] 183 + ); 184 + } 185 + 186 + #[test] 187 + fn plan_ignores_remote_records_outside_scope() { 188 + let l: BTreeMap<String, LocalRecord> = BTreeMap::new(); 189 + let mut r = BTreeMap::new(); 190 + r.insert("com.example.forum.a".into(), remote("bafyA")); 191 + r.insert("com.other.thing".into(), remote("bafyO")); 192 + let plan = Plan::compute("com.example.forum", &l, &r); 193 + assert_eq!(plan.actions.len(), 1); 194 + assert_eq!(plan.actions[0].nsid(), "com.example.forum.a"); 195 + } 196 + 197 + #[test] 198 + fn unchanged_records_are_omitted() { 199 + let mut l = BTreeMap::new(); 200 + l.insert("com.example.forum.a".into(), local("bafyA")); 201 + let mut r = BTreeMap::new(); 202 + r.insert("com.example.forum.a".into(), remote("bafyA")); 203 + let plan = Plan::compute("com.example.forum", &l, &r); 204 + assert!(plan.is_empty()); 205 + } 206 + }
+145
mlf-publish/src/validate.rs
··· 1 + //! Publish-time validation layers run against each record before the 2 + //! host tries to write anything to the PDS. 3 + //! 4 + //! Each validator returns a `Vec<Finding>`; the orchestrator aggregates 5 + //! them and decides whether the publish proceeds based on the 6 + //! `BreakingChangePolicy` and `--force`. 7 + 8 + use serde_json::Value; 9 + 10 + #[derive(Debug, Clone, PartialEq, Eq)] 11 + pub enum Finding { 12 + /// The record doesn't conform to the `com.atproto.lexicon.schema` 13 + /// meta-schema — e.g. missing `lexicon` integer or a non-object 14 + /// `defs`. 15 + MetaSchema { nsid: String, message: String }, 16 + 17 + /// A local NSID isn't a descendant of `[package].name`. 18 + Scope { nsid: String, package_name: String }, 19 + } 20 + 21 + /// Check that a generated record satisfies the bundled 22 + /// `com.atproto.lexicon.schema` meta-schema. No network. 23 + /// 24 + /// The meta-schema is extremely lax by design: it only *requires* an 25 + /// integer `lexicon` field, so this enforces the minimal structural 26 + /// invariants every PDS would accept. 27 + pub fn check_meta_schema(nsid: &str, record: &Value) -> Vec<Finding> { 28 + let mut out = Vec::new(); 29 + let Some(obj) = record.as_object() else { 30 + out.push(Finding::MetaSchema { 31 + nsid: nsid.to_string(), 32 + message: "record must be a JSON object".into(), 33 + }); 34 + return out; 35 + }; 36 + match obj.get("$type").and_then(Value::as_str) { 37 + Some("com.atproto.lexicon.schema") => {} 38 + Some(other) => out.push(Finding::MetaSchema { 39 + nsid: nsid.to_string(), 40 + message: format!("$type must be `com.atproto.lexicon.schema`, got `{other}`"), 41 + }), 42 + None => out.push(Finding::MetaSchema { 43 + nsid: nsid.to_string(), 44 + message: "record is missing `$type`".into(), 45 + }), 46 + } 47 + match obj.get("lexicon") { 48 + Some(Value::Number(n)) if n.is_i64() || n.is_u64() => {} 49 + Some(other) => out.push(Finding::MetaSchema { 50 + nsid: nsid.to_string(), 51 + message: format!("`lexicon` must be an integer, got {other}"), 52 + }), 53 + None => out.push(Finding::MetaSchema { 54 + nsid: nsid.to_string(), 55 + message: "record is missing integer `lexicon` field".into(), 56 + }), 57 + } 58 + match obj.get("id").and_then(Value::as_str) { 59 + Some(id) if id == nsid => {} 60 + Some(id) => out.push(Finding::MetaSchema { 61 + nsid: nsid.to_string(), 62 + message: format!("`id` field is `{id}` but record is being published as `{nsid}`"), 63 + }), 64 + None => out.push(Finding::MetaSchema { 65 + nsid: nsid.to_string(), 66 + message: "record is missing `id` field".into(), 67 + }), 68 + } 69 + if !obj.get("defs").map(|v| v.is_object()).unwrap_or(false) { 70 + out.push(Finding::MetaSchema { 71 + nsid: nsid.to_string(), 72 + message: "`defs` must be an object".into(), 73 + }); 74 + } 75 + out 76 + } 77 + 78 + /// Check that every NSID in `local` is a descendant of `package_name`. 79 + pub fn check_scope<'a>(package_name: &str, nsids: impl Iterator<Item = &'a str>) -> Vec<Finding> { 80 + let prefix = format!("{package_name}."); 81 + nsids 82 + .filter(|n| *n != package_name && !n.starts_with(&prefix)) 83 + .map(|n| Finding::Scope { 84 + nsid: n.to_string(), 85 + package_name: package_name.to_string(), 86 + }) 87 + .collect() 88 + } 89 + 90 + #[cfg(test)] 91 + mod tests { 92 + use super::*; 93 + use serde_json::json; 94 + 95 + #[test] 96 + fn meta_schema_accepts_minimal_valid_record() { 97 + let v = json!({ 98 + "$type": "com.atproto.lexicon.schema", 99 + "lexicon": 1, 100 + "id": "com.example.thing", 101 + "defs": {"main": {"type": "record"}} 102 + }); 103 + assert!(check_meta_schema("com.example.thing", &v).is_empty()); 104 + } 105 + 106 + #[test] 107 + fn meta_schema_flags_missing_lexicon_field() { 108 + let v = json!({ 109 + "$type": "com.atproto.lexicon.schema", 110 + "id": "com.example.thing", 111 + "defs": {} 112 + }); 113 + let findings = check_meta_schema("com.example.thing", &v); 114 + assert_eq!(findings.len(), 1); 115 + } 116 + 117 + #[test] 118 + fn meta_schema_flags_mismatched_id() { 119 + let v = json!({ 120 + "$type": "com.atproto.lexicon.schema", 121 + "lexicon": 1, 122 + "id": "com.other.thing", 123 + "defs": {} 124 + }); 125 + let findings = check_meta_schema("com.example.thing", &v); 126 + assert_eq!(findings.len(), 1); 127 + } 128 + 129 + #[test] 130 + fn scope_passes_for_descendants() { 131 + assert!( 132 + check_scope("com.example.forum", ["com.example.forum.post"].into_iter()).is_empty() 133 + ); 134 + assert!(check_scope("com.example.forum", ["com.example.forum"].into_iter()).is_empty()); 135 + } 136 + 137 + #[test] 138 + fn scope_fails_on_siblings_and_unrelated() { 139 + let findings = check_scope( 140 + "com.example.forum", 141 + ["com.example.forums.x", "com.other.forum.post"].into_iter(), 142 + ); 143 + assert_eq!(findings.len(), 2); 144 + } 145 + }
+105
website/content/docs/cli/11-publish.md
··· 1 + +++ 2 + title = "Publish Command" 3 + description = "Publish every lexicon in the workspace to the configured PDS + DNS" 4 + weight = 11 5 + +++ 6 + 7 + `mlf publish` is the one-shot publish orchestrator. It computes what would change on the PDS, validates it locally, ensures the required `_lexicon` TXT records are in place via the configured DNS plugin, and then applies the minimum set of `putRecord` / `deleteRecord` calls to make the remote match the workspace. A per-publish `lol.mlf.package` manifest record is written alongside (toggleable via `[publish].manifest`). 8 + 9 + ## Usage 10 + 11 + ```bash 12 + mlf publish # normal publish 13 + mlf publish --dry-run # compute + validate the plan, no network writes 14 + mlf publish --force # override breaking-change aborts + DNS-mismatch aborts 15 + mlf publish --non-interactive # fail rather than prompt for anything 16 + ``` 17 + 18 + Ephemeral credential overrides (not stored) can be passed after the flags: 19 + 20 + ```bash 21 + mlf publish \ 22 + --pds-app-password ${{ secrets.ATPROTO_APP_PASSWORD }} \ 23 + --dns-cloudflare-api-token ${{ secrets.CLOUDFLARE_TOKEN }} 24 + ``` 25 + 26 + Field names come from the plugin's options schema. For the PDS: `--pds-handle`, `--pds-app-password`. For a DNS plugin: `--dns-<plugin>-<field>`. 27 + 28 + ## What it does, in order 29 + 30 + 1. **Load workspace** — parse every `.mlf`, generate Lexicon JSON, wrap each as a `com.atproto.lexicon.schema` record, and compute the record's CID (DAG-CBOR + SHA-256). 31 + 2. **Fetch remote state** — resolve each `_lexicon.<authority>` TXT to a DID, then `listRecords` the `com.atproto.lexicon.schema` collection on that repo. Every NSID + CID already on the PDS is collected. 32 + 3. **Validate**: 33 + - **Scope** — every local NSID must be a descendant of `[package].name`. 34 + - **Meta-schema** — every generated record must have `$type` / `lexicon` / `id` / `defs` in the expected shape. Bundled; no network. 35 + - **Breaking-change** — for each NSID that exists on both sides, detect removed fields, changed types, optional-to-required transitions, and removed defs. Under `[publish].breaking_changes = "deny"` (default), any finding aborts the publish unless `--force` is passed. 36 + - **Single-DID gate** — every authority must resolve to the same DID. 37 + 4. **Plan** — produce the minimal set of `put` / `update` / `delete` actions. Records outside `[package].name.*` on the same repo are left alone. 38 + 5. **Dry-run check** — if `--dry-run`, print the plan and stop. 39 + 6. **Reconcile DNS** — for each authority: TXT missing → plugin creates it; TXT matches session DID → no-op; TXT points elsewhere → abort (unless `--force`, in which case we overwrite). 40 + 7. **Authenticate + apply** — `createSession` against the PDS with the stored handle + app password, then push each action. 41 + 8. **Write manifest** — `putRecord` the `lol.mlf.package` record with `published: [{nsid, cid}, ...]` sorted lexicographically. Skipped when `[publish].manifest = false`. 42 + 43 + ## Configuration 44 + 45 + `mlf publish` reads the `[publish]` section from `mlf.toml`: 46 + 47 + ```toml 48 + [publish] 49 + enabled = true # default true; set false to block temporarily 50 + dns = "cloudflare" # REQUIRED — which DNS plugin to use 51 + manifest = true # emit lol.mlf.package (default true) 52 + breaking_changes = "deny" # "deny" | "warn" | "allow" 53 + ``` 54 + 55 + If `[publish]` is absent entirely, the workspace is *not publishable* and `mlf publish` refuses. 56 + 57 + ## Manifest record 58 + 59 + Published at `lol.mlf.package` in the same repo: 60 + 61 + ```json 62 + { 63 + "$type": "com.atproto.lexicon.schema", 64 + "lexicon": 1, 65 + "id": "lol.mlf.package", 66 + "publishedAt": "2026-04-17T…", 67 + "tool": "mlf@0.1.0", 68 + "published": [ 69 + {"nsid": "com.example.forum.post", "cid": "bafy…"}, 70 + {"nsid": "com.example.forum.thread", "cid": "bafy…"} 71 + ], 72 + "resolvedDependencies": [] 73 + } 74 + ``` 75 + 76 + The record's own CID deterministically identifies this publish event. 77 + 78 + ## CI/CD pattern 79 + 80 + ```yaml 81 + - run: mlf login pds --project --handle matt.example.com --app-password ${{ secrets.ATPROTO_APP_PASSWORD }} 82 + - run: mlf login dns cloudflare --project --api-token ${{ secrets.CLOUDFLARE_TOKEN }} 83 + - run: mlf publish --non-interactive 84 + ``` 85 + 86 + Or as a single invocation with ephemeral credentials (no login step): 87 + 88 + ```yaml 89 + - run: | 90 + mlf publish --non-interactive \ 91 + --pds-handle matt.example.com \ 92 + --pds-app-password ${{ secrets.ATPROTO_APP_PASSWORD }} \ 93 + --dns-cloudflare-api-token ${{ secrets.CLOUDFLARE_TOKEN }} 94 + ``` 95 + 96 + ## Exit codes 97 + 98 + - `0` — plan applied successfully (or dry-run completed without findings). 99 + - Non-zero — validation failure, breaking-change abort, DNS-mismatch abort, missing credentials, network error, or any `putRecord` failure. The relevant miette diagnostic names the exact issue. 100 + 101 + ## See also 102 + 103 + - [`mlf status`](../08-status/) — see the same plan without running the validators. 104 + - [`mlf diff`](../09-diff/) — inspect one record's proposed change. 105 + - [`mlf login`](../10-login/) — set up the PDS and DNS credentials `publish` needs.