diff --git a/Cargo.lock b/Cargo.lock index 2f1a4c1..67dab06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1674,6 +1674,7 @@ dependencies = [ "clap", "glob", "miette", + "mlf-atproto", "mlf-codegen", "mlf-codegen-go", "mlf-codegen-rust", @@ -1686,6 +1687,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "similar", "thiserror 2.0.17", "tokio", "toml", diff --git a/mlf-atproto/src/identity.rs b/mlf-atproto/src/identity.rs index 560f7f3..5540f1d 100644 --- a/mlf-atproto/src/identity.rs +++ b/mlf-atproto/src/identity.rs @@ -10,6 +10,7 @@ use async_trait::async_trait; use hickory_resolver::TokioAsyncResolver; use hickory_resolver::config::{ResolverConfig, ResolverOpts}; +use hickory_resolver::error::ResolveErrorKind; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -84,6 +85,39 @@ pub fn construct_dns_name(authority: &str, name_segments: &str) -> String { } } +// --------------------------------------------------------------------------- +// Authority (spec-compliant) +// --------------------------------------------------------------------------- + +/// Compute the authority domain of an NSID per the ATProto lexicon spec. +/// +/// "Take the NSID, drop the final name segment; the remaining segments, +/// still in NSID order, identify the publishing authority." The returned +/// string is in NSID order (e.g. `com.example.forum`), not reversed DNS +/// order — call [`authority_dns_name`] to get the `_lexicon.<…>` label. +/// +/// - `com.example.forum.post` → `com.example.forum` +/// - `app.bsky.feed.post` → `app.bsky.feed` +/// - `edu.university.dept.lab.blogging.getBlogPost` → `edu.university.dept.lab.blogging` +pub fn authority_of_nsid(nsid: &str) -> Result { + let parts: Vec<&str> = nsid.split('.').collect(); + if parts.len() < 3 { + return Err(IdentityError::InvalidNsid(format!( + "NSID must have at least 3 segments (authority + name): {nsid}" + ))); + } + Ok(parts[..parts.len() - 1].join(".")) +} + +/// Convert an NSID-order authority (e.g. `com.example.forum`) to the +/// `_lexicon.` DNS label used for the TXT lookup. +/// +/// `com.example.forum` → `_lexicon.forum.example.com`. +pub fn authority_dns_name(authority: &str) -> String { + let reversed: Vec<&str> = authority.split('.').rev().collect(); + format!("_lexicon.{}", reversed.join(".")) +} + // --------------------------------------------------------------------------- // DnsResolver trait // --------------------------------------------------------------------------- @@ -129,22 +163,46 @@ impl DnsResolver for RealDnsResolver { name_segments: &str, ) -> Result { let dns_name = construct_dns_name(authority, name_segments); - let response = self.resolver.txt_lookup(&dns_name).await.map_err(|e| { + resolve_did_at(&self.resolver, &dns_name).await + } +} + +impl RealDnsResolver { + /// Resolve `_lexicon.` TXT for an NSID-order + /// authority domain — the spec-compliant lookup for a publishing + /// authority that covers every NSID descended from it. + pub async fn resolve_authority_did(&self, authority: &str) -> Result { + let dns_name = authority_dns_name(authority); + resolve_did_at(&self.resolver, &dns_name).await + } +} + +async fn resolve_did_at( + resolver: &TokioAsyncResolver, + dns_name: &str, +) -> Result { + let response = resolver.txt_lookup(dns_name).await.map_err(|e| { + // NXDOMAIN / "no records" means the authority hasn't been set + // up yet — map to NoDidInTxt so callers treat it as missing + // (bootstrappable) rather than an unrecoverable DNS failure. + if matches!(e.kind(), ResolveErrorKind::NoRecordsFound { .. }) { + IdentityError::NoDidInTxt(dns_name.to_string()) + } else { IdentityError::DnsLookupFailed { - domain: dns_name.clone(), + domain: dns_name.to_string(), error: e.to_string(), } - })?; - for txt in response.iter() { - for data in txt.txt_data() { - let text = String::from_utf8_lossy(data); - if let Some(did) = text.strip_prefix("did=") { - return Ok(did.trim().to_string()); - } + } + })?; + for txt in response.iter() { + for data in txt.txt_data() { + let text = String::from_utf8_lossy(data); + if let Some(did) = text.strip_prefix("did=") { + return Ok(did.trim().to_string()); } } - Err(IdentityError::NoDidInTxt(dns_name)) } + Err(IdentityError::NoDidInTxt(dns_name.to_string())) } /// In-memory DNS resolver for tests. @@ -343,4 +401,38 @@ mod tests { let doc = serde_json::json!({"service": []}); assert!(extract_pds_endpoint(&doc, "did:plc:x").is_err()); } + + #[test] + fn test_authority_of_nsid() { + assert_eq!( + authority_of_nsid("com.example.forum.post").unwrap(), + "com.example.forum" + ); + assert_eq!( + authority_of_nsid("app.bsky.feed.post").unwrap(), + "app.bsky.feed" + ); + assert_eq!( + authority_of_nsid("edu.university.dept.lab.blogging.getBlogPost").unwrap(), + "edu.university.dept.lab.blogging" + ); + assert!(authority_of_nsid("com.example").is_err()); + assert!(authority_of_nsid("one").is_err()); + } + + #[test] + fn test_authority_dns_name() { + assert_eq!( + authority_dns_name("com.example.forum"), + "_lexicon.forum.example.com" + ); + assert_eq!( + authority_dns_name("app.bsky.feed"), + "_lexicon.feed.bsky.app" + ); + assert_eq!( + authority_dns_name("edu.university.dept.lab.blogging"), + "_lexicon.blogging.lab.dept.university.edu" + ); + } } diff --git a/mlf-cli/Cargo.toml b/mlf-cli/Cargo.toml index 3c6ecbc..ba78c06 100644 --- a/mlf-cli/Cargo.toml +++ b/mlf-cli/Cargo.toml @@ -14,6 +14,7 @@ mlf-validation = { path = "../mlf-validation" } mlf-codegen = { path = "../mlf-codegen" } mlf-diagnostics = { path = "../mlf-diagnostics" } mlf-lexicon-fetcher = { path = "../mlf-lexicon-fetcher" } +mlf-atproto = { path = "../mlf-atproto" } clap = { version = "4.5.48", features = ["derive"] } miette = { version = "7", features = ["fancy"] } thiserror = "2" @@ -25,6 +26,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] } reqwest = { version = "0.12", features = ["json"] } chrono = { version = "0.4", features = ["serde"] } sha2 = "0.10" +similar = "2" # Optional code generator plugins mlf-codegen-typescript = { path = "../codegen-plugins/mlf-codegen-typescript", optional = true } diff --git a/mlf-cli/src/check.rs b/mlf-cli/src/check.rs index 99b26c9..e66b852 100644 --- a/mlf-cli/src/check.rs +++ b/mlf-cli/src/check.rs @@ -376,6 +376,20 @@ pub fn validate(lexicon_path: PathBuf, record_path: PathBuf) -> Result<(), Check } } +/// Publicly-callable alias used by other commands that need to walk +/// the workspace source tree. +pub fn collect_mlf_files_pub(dir: &std::path::Path) -> Result, CheckError> { + collect_mlf_files(dir) +} + +/// Publicly-callable alias used by other commands. +pub fn extract_namespace_pub( + file_path: &std::path::Path, + root_dir: &std::path::Path, +) -> Result { + extract_namespace(file_path, root_dir) +} + /// Recursively collect all .mlf files from a directory fn collect_mlf_files(dir: &std::path::Path) -> Result, CheckError> { let mut files = Vec::new(); diff --git a/mlf-cli/src/diff.rs b/mlf-cli/src/diff.rs new file mode 100644 index 0000000..5ec88e2 --- /dev/null +++ b/mlf-cli/src/diff.rs @@ -0,0 +1,86 @@ +//! `mlf diff [NSID]` — print a unified diff between the local Lexicon +//! JSON and the currently-published record for one NSID (or every +//! changed / new / removed NSID in the package). + +use crate::remote_state::{Classification, RemoteState, RemoteStateError, classify}; +use similar::{ChangeTag, TextDiff}; + +pub async fn run_diff(nsid: Option) -> Result<(), RemoteStateError> { + let state = RemoteState::load().await?; + let classifications = classify(&state); + + let targets: Vec = match nsid { + Some(ref n) => vec![n.clone()], + None => classifications + .iter() + .filter(|(_, c)| !matches!(c, Classification::Unchanged)) + .map(|(n, _)| n.clone()) + .collect(), + }; + + if targets.is_empty() { + println!("No differences."); + return Ok(()); + } + + for (i, nsid) in targets.iter().enumerate() { + if i > 0 { + println!(); + } + let cls = classifications.get(nsid); + print_diff(&state, nsid, cls); + } + + Ok(()) +} + +fn print_diff(state: &RemoteState, nsid: &str, cls: Option<&Classification>) { + let local_json = state + .local + .get(nsid) + .map(|l| pretty_json(&l.record_json)) + .unwrap_or_default(); + let remote_json = state + .remote + .get(nsid) + .map(|r| pretty_json(&r.record_json)) + .unwrap_or_default(); + + let header = match cls { + Some(Classification::Unchanged) => format!("= {nsid} (unchanged)"), + Some(Classification::New) => format!("+ {nsid} (new, not yet on PDS)"), + Some(Classification::Removed) => format!("- {nsid} (removed locally, still on PDS)"), + Some(Classification::Changed { + local_cid, + remote_cid, + }) => format!("~ {nsid} {remote_cid} → {local_cid}"), + None => format!("? {nsid} (not found locally or remotely)"), + }; + println!("{header}"); + println!("{}", "-".repeat(header.chars().count())); + + let diff = TextDiff::from_lines(&remote_json, &local_json); + for change in diff.iter_all_changes() { + let (sign, text) = match change.tag() { + ChangeTag::Delete => ("-", change.value()), + ChangeTag::Insert => ("+", change.value()), + ChangeTag::Equal => (" ", change.value()), + }; + // Trim trailing newline — `value()` returns `"line\n"` from the + // source; we supply our own newline via println. + let text = text.strip_suffix('\n').unwrap_or(text); + println!("{sign} {text}"); + } +} + +fn pretty_json(value: &serde_json::Value) -> String { + // `to_string_pretty` preserves insertion order; serde_json uses + // `preserve_order` via the `IndexMap` feature by default in this + // workspace? If not, key order is lexicographic. Either is fine for + // a human-facing diff; the CID comparison happens elsewhere. + let mut s = serde_json::to_string_pretty(value).unwrap_or_default(); + if !s.ends_with('\n') { + s.push('\n'); + } + s +} diff --git a/mlf-cli/src/lib.rs b/mlf-cli/src/lib.rs index d125e7a..ff4d49e 100644 --- a/mlf-cli/src/lib.rs +++ b/mlf-cli/src/lib.rs @@ -1,6 +1,9 @@ pub mod check; pub mod config; +pub mod diff; pub mod fetch; pub mod generate; pub mod init; +pub mod remote_state; +pub mod status; pub mod workspace_ext; diff --git a/mlf-cli/src/main.rs b/mlf-cli/src/main.rs index 31e2eb7..51f4afd 100644 --- a/mlf-cli/src/main.rs +++ b/mlf-cli/src/main.rs @@ -1,6 +1,6 @@ use clap::{Parser, Subcommand}; use miette::IntoDiagnostic; -use mlf_cli::{check, fetch, generate, init}; +use mlf_cli::{check, diff, fetch, generate, init, status}; use std::path::PathBuf; use std::process; @@ -74,6 +74,15 @@ enum Commands { #[arg(long, help = "Require lockfile and fail if dependencies need updating")] locked: bool, }, + + #[command(about = "Show the publish status of each lexicon in the package")] + Status, + + #[command(about = "Diff local lexicons against what's currently published")] + Diff { + #[arg(help = "NSID to diff. If omitted, diffs every changed, new, or removed NSID.")] + nsid: Option, + }, } #[derive(Subcommand)] @@ -195,6 +204,8 @@ async fn main() { } => fetch::run_fetch(nsid, save, update, locked) .await .into_diagnostic(), + Commands::Status => status::run_status().await.into_diagnostic(), + Commands::Diff { nsid } => diff::run_diff(nsid).await.into_diagnostic(), }; if let Err(e) = result { diff --git a/mlf-cli/src/remote_state.rs b/mlf-cli/src/remote_state.rs new file mode 100644 index 0000000..e5b7ad7 --- /dev/null +++ b/mlf-cli/src/remote_state.rs @@ -0,0 +1,550 @@ +//! Shared machinery for reading the current remote state of a package. +//! +//! Both `mlf status` and `mlf diff` (and later `mlf publish`) need to: +//! +//! 1. Load the workspace and generate Lexicon JSON for every local module. +//! 2. Compute the on-chain CID for each local record. +//! 3. Resolve the package's publishing DID via `_lexicon.` TXT. +//! 4. Fetch the current `com.atproto.lexicon.schema` records from the PDS. +//! 5. Diff local vs remote by (NSID, CID). +//! +//! This module exposes the first three as a single read-only operation +//! that produces a [`RemoteState`] the command-specific code can render. + +use crate::check::collect_mlf_files_pub as collect_mlf_files; +use crate::check::extract_namespace_pub as extract_namespace; +use crate::config::{MlfConfig, PackageConfig, find_project_root}; +use crate::workspace_ext::workspace_with_std_and_cache; +use miette::Diagnostic; +use mlf_atproto::identity::{self, IdentityError, RealDnsResolver}; +use mlf_atproto::records::{self, RecordError}; +use mlf_lang::Workspace; +use serde_json::Value; +use std::collections::{BTreeMap, HashSet}; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +/// Collection name the AT Protocol uses for lexicon schemas. +pub const LEXICON_COLLECTION: &str = "com.atproto.lexicon.schema"; + +#[derive(Error, Debug, Diagnostic)] +pub enum RemoteStateError { + #[error("No mlf.toml found in current or parent directories")] + #[diagnostic( + code(mlf::remote_state::no_project), + help("Run `mlf init` first to set up a project.") + )] + NoProject, + + #[error("Failed to load mlf.toml: {0}")] + #[diagnostic(code(mlf::remote_state::config))] + Config(String), + + #[error("Failed to read source files: {0}")] + #[diagnostic(code(mlf::remote_state::io))] + Io(String), + + #[error("Failed to parse {file}")] + #[diagnostic(code(mlf::remote_state::parse))] + Parse { file: String }, + + #[error("Workspace resolution failed")] + #[diagnostic(code(mlf::remote_state::resolve))] + Resolve, + + #[error("Lexicon `{namespace}` (in {file}) is outside package scope `{package_name}.*`")] + #[diagnostic( + code(mlf::remote_state::scope_violation), + help("Move the file or update `[package].name` in mlf.toml.") + )] + ScopeViolation { + file: String, + namespace: String, + package_name: String, + }, + + #[error("Failed to generate Lexicon JSON for `{namespace}`: {reason}")] + #[diagnostic(code(mlf::remote_state::codegen))] + Codegen { namespace: String, reason: String }, + + #[error("CID computation failed for `{namespace}`: {reason}")] + #[diagnostic(code(mlf::remote_state::cid))] + Cid { namespace: String, reason: String }, + + #[error("Identity resolution failed")] + #[diagnostic(code(mlf::remote_state::identity))] + Identity(#[source] IdentityError), + + #[error("Record fetch failed")] + #[diagnostic(code(mlf::remote_state::records))] + Records(#[source] RecordError), + + #[error( + "Package authorities resolve to multiple DIDs: {first_authority} → {first_did}, {second_authority} → {second_did}" + )] + #[diagnostic( + code(mlf::remote_state::multiple_dids), + help( + "Every authority under `[package].name` must resolve to the same DID. This package spans two. Either fix the `_lexicon` TXT records, or split into separate workspaces." + ) + )] + MultipleDids { + first_authority: String, + first_did: String, + second_authority: String, + second_did: String, + }, +} + +/// Fully-resolved view of the package's local state and the remote state +/// it would compare against on publish. +pub struct RemoteState { + pub project_root: PathBuf, + pub package: PackageConfig, + + /// The DID every authority under `[package].name` resolves to. + /// `None` if we couldn't resolve any authority (e.g. no TXT records yet + /// — a brand-new package). + pub publishing_did: Option, + + /// The PDS endpoint for [`publishing_did`]. `None` when the DID is. + pub pds: Option, + + /// Per-authority TXT lookup result. Keyed by authority (the reverse- + /// DNS prefix of the NSID), so `[package.foo, package.bar]` under + /// `com.example` both show up as `com.example` in the map — the + /// authority covers every NSID that starts with it. + pub authority_status: BTreeMap, + + /// Every lexicon defined locally, keyed by NSID. + pub local: BTreeMap, + + /// Every `com.atproto.lexicon.schema` record currently in the + /// publishing DID's repo, keyed by NSID. Empty if we couldn't + /// resolve a DID. + pub remote: BTreeMap, +} + +pub struct LocalLexicon { + pub namespace: String, + pub file: String, + /// The JSON as it would be written to the PDS (with `$type`). + pub record_json: Value, + /// CID of [`record_json`] — comparable to whatever listRecords returns. + pub cid: String, +} + +pub struct RemoteLexicon { + pub nsid: String, + pub cid: String, + pub record_json: Value, +} + +#[derive(Debug, Clone)] +pub enum AuthorityStatus { + /// `_lexicon.` TXT resolved to a DID. + Resolved { did: String }, + /// `_lexicon.` TXT was missing or empty. + Missing, + /// Lookup failed for some other reason (network, parse, etc.). + Error(String), +} + +impl RemoteState { + /// Build a [`RemoteState`] by walking the workspace, generating JSON, + /// and performing the necessary network lookups. + pub async fn load() -> Result { + let current_dir = + std::env::current_dir().map_err(|e| RemoteStateError::Io(e.to_string()))?; + let project_root = + find_project_root(¤t_dir).map_err(|_| RemoteStateError::NoProject)?; + let config_path = project_root.join("mlf.toml"); + let config = + MlfConfig::load(&config_path).map_err(|e| RemoteStateError::Config(e.to_string()))?; + let package = config.package.clone(); + let source_dir = project_root.join(&config.source.directory); + + // 1. Load every .mlf file, parse it, add to a resolver workspace. + let local = collect_local(&source_dir, &project_root, &package)?; + + // 2. Group NSIDs into their reverse-DNS authorities. + let authorities = authorities_for_nsids(local.keys().map(|s| s.as_str()))?; + + // 3. Resolve each authority via DNS; enforce single-DID gate. + let resolver = RealDnsResolver::new().map_err(RemoteStateError::Identity)?; + let (publishing_did, authority_status) = + resolve_authorities(&resolver, &authorities).await?; + + // 4. If we have a DID, resolve its PDS and fetch the lexicon records. + let (pds, remote) = if let Some(did) = publishing_did.as_deref() { + let client = reqwest::Client::new(); + let pds = identity::resolve_did_to_pds(&client, did) + .await + .map_err(RemoteStateError::Identity)?; + let records = records::list_all_records(&client, &pds, did, LEXICON_COLLECTION) + .await + .map_err(RemoteStateError::Records)?; + let mut remote_map: BTreeMap = BTreeMap::new(); + for r in records { + let nsid = record_nsid(&r).unwrap_or_else(|| rkey_from_uri(&r.uri)); + let cid = r.cid.clone().unwrap_or_default(); + remote_map.insert( + nsid.clone(), + RemoteLexicon { + nsid, + cid, + record_json: r.value, + }, + ); + } + (Some(pds), remote_map) + } else { + (None, BTreeMap::new()) + }; + + Ok(Self { + project_root, + package, + publishing_did, + pds, + authority_status, + local, + remote, + }) + } +} + +/// Compute what a publish run would do given a fully-loaded [`RemoteState`]. +pub fn classify(state: &RemoteState) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut seen: HashSet<&String> = HashSet::new(); + + for (nsid, local) in &state.local { + seen.insert(nsid); + let cls = match state.remote.get(nsid) { + None => Classification::New, + Some(remote) if remote.cid == local.cid => Classification::Unchanged, + Some(remote) => Classification::Changed { + local_cid: local.cid.clone(), + remote_cid: remote.cid.clone(), + }, + }; + out.insert(nsid.clone(), cls); + } + + for nsid in state.remote.keys() { + if state.package.namespace_is_in_scope(nsid) && !seen.contains(nsid) { + // We only flag remote-only records that fall under this package's + // scope. Anything else in the repo belongs to a different package + // hosted on the same account and isn't ours to touch. + out.insert(nsid.clone(), Classification::Removed); + } + } + + out +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Classification { + Unchanged, + New, + Changed { + local_cid: String, + remote_cid: String, + }, + Removed, +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +fn collect_local( + source_dir: &Path, + project_root: &Path, + package: &PackageConfig, +) -> Result, RemoteStateError> { + let cache_dir = crate::config::get_mlf_cache_dir(project_root); + let mut workspace = workspace_with_std_and_cache(Some(&cache_dir)) + .map_err(|e| RemoteStateError::Io(format!("loading workspace: {e}")))?; + + let files = collect_mlf_files(source_dir) + .map_err(|e| RemoteStateError::Io(format!("collecting .mlf files: {e}")))?; + + let mut source_order: Vec<(String, String)> = Vec::new(); + + for file in &files { + let source = std::fs::read_to_string(file) + .map_err(|e| RemoteStateError::Io(format!("reading {}: {e}", file.display())))?; + let namespace = extract_namespace(file, source_dir) + .map_err(|e| RemoteStateError::Io(format!("namespace for {}: {e}", file.display())))?; + if !package.namespace_is_in_scope(&namespace) { + return Err(RemoteStateError::ScopeViolation { + file: file.display().to_string(), + namespace, + package_name: package.name.clone(), + }); + } + let lexicon = mlf_lang::parse_lexicon(&source).map_err(|_| RemoteStateError::Parse { + file: file.display().to_string(), + })?; + workspace + .add_module(namespace.clone(), lexicon) + .map_err(|_| RemoteStateError::Resolve)?; + source_order.push((namespace, file.display().to_string())); + } + + workspace.resolve().map_err(|_| RemoteStateError::Resolve)?; + + // Produce JSON + CID for each module in the order we loaded them. + let mut out = BTreeMap::new(); + for (namespace, file) in source_order { + let lexicon = workspace_module(&workspace, &namespace)?; + let codegen_out = mlf_codegen::generate_lexicon(&namespace, lexicon, &workspace); + let record_json = wrap_as_schema_record(codegen_out.json); + let cid = + mlf_atproto::cid::cid_for_json(&record_json).map_err(|e| RemoteStateError::Cid { + namespace: namespace.clone(), + reason: e.to_string(), + })?; + out.insert( + namespace.clone(), + LocalLexicon { + namespace: namespace.clone(), + file, + record_json, + cid, + }, + ); + } + + Ok(out) +} + +fn workspace_module<'a>( + workspace: &'a Workspace, + namespace: &str, +) -> Result<&'a mlf_lang::Lexicon, RemoteStateError> { + workspace + .get_lexicon(namespace) + .ok_or_else(|| RemoteStateError::Codegen { + namespace: namespace.to_string(), + reason: "module not present in workspace after resolve".to_string(), + }) +} + +/// Wrap a generated Lexicon JSON with the `$type` required on a PDS record. +fn wrap_as_schema_record(mut lexicon_json: Value) -> Value { + if let Value::Object(ref mut map) = lexicon_json { + map.insert( + "$type".to_string(), + Value::String(LEXICON_COLLECTION.to_string()), + ); + } + lexicon_json +} + +/// Take every NSID and return the unique set of authorities covering +/// them per the ATProto lexicon spec: drop the final name segment and +/// use the remainder as the authority. +/// +/// NSIDs `com.example.forum.post` and `com.example.forum.thread` share +/// authority `com.example.forum`. `com.example.directory.listing` adds +/// a second authority `com.example.directory`. +fn authorities_for_nsids<'a>( + nsids: impl Iterator, +) -> Result, RemoteStateError> { + nsids + .map(|nsid| identity::authority_of_nsid(nsid).map_err(RemoteStateError::Identity)) + .collect() +} + +async fn resolve_authorities( + resolver: &RealDnsResolver, + authorities: &HashSet, +) -> Result<(Option, BTreeMap), RemoteStateError> { + let mut statuses: BTreeMap = BTreeMap::new(); + let mut dids: Vec<(String, String)> = Vec::new(); + + for authority in authorities { + let label = identity::authority_dns_name(authority); + let res = resolver.resolve_authority_did(authority).await; + match res { + Ok(did) => { + statuses.insert( + label.clone(), + AuthorityStatus::Resolved { did: did.clone() }, + ); + dids.push((label, did)); + } + Err(IdentityError::NoDidInTxt(_)) => { + statuses.insert(label, AuthorityStatus::Missing); + } + Err(IdentityError::DnsLookupFailed { error, .. }) => { + statuses.insert(label, AuthorityStatus::Error(error)); + } + Err(e) => return Err(RemoteStateError::Identity(e)), + } + } + + // Enforce single-DID gate: every resolved authority must point at the + // same DID. Multiple DIDs is a v1 error per the plan. + let mut publishing_did: Option = None; + if let Some((first_auth, first_did)) = dids.first().cloned() { + for (auth, did) in dids.iter().skip(1) { + if did != &first_did { + return Err(RemoteStateError::MultipleDids { + first_authority: first_auth, + first_did, + second_authority: auth.clone(), + second_did: did.clone(), + }); + } + } + publishing_did = Some(first_did); + } + + Ok((publishing_did, statuses)) +} + +fn record_nsid(record: &records::Record) -> Option { + record + .value + .get("id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +fn rkey_from_uri(uri: &str) -> String { + uri.rsplit('/').next().unwrap_or(uri).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn mk_local(nsid: &str, cid: &str) -> LocalLexicon { + LocalLexicon { + namespace: nsid.to_string(), + file: format!("{nsid}.mlf"), + record_json: json!({"id": nsid}), + cid: cid.to_string(), + } + } + + fn mk_remote(nsid: &str, cid: &str) -> RemoteLexicon { + RemoteLexicon { + nsid: nsid.to_string(), + cid: cid.to_string(), + record_json: json!({"id": nsid}), + } + } + + fn state_with( + package: &str, + local: Vec, + remote: Vec, + ) -> RemoteState { + let mut local_map = BTreeMap::new(); + for l in local { + local_map.insert(l.namespace.clone(), l); + } + let mut remote_map = BTreeMap::new(); + for r in remote { + remote_map.insert(r.nsid.clone(), r); + } + RemoteState { + project_root: PathBuf::from("/tmp"), + package: PackageConfig { + name: package.to_string(), + }, + publishing_did: Some("did:plc:x".to_string()), + pds: Some("https://pds".to_string()), + authority_status: BTreeMap::new(), + local: local_map, + remote: remote_map, + } + } + + #[test] + fn classify_detects_new_changed_unchanged_removed() { + let state = state_with( + "com.example.forum", + vec![ + mk_local("com.example.forum.post", "bafyA"), // unchanged + mk_local("com.example.forum.thread", "bafyNEW"), // changed + mk_local("com.example.forum.reply", "bafyR"), // new + ], + vec![ + mk_remote("com.example.forum.post", "bafyA"), + mk_remote("com.example.forum.thread", "bafyOLD"), + mk_remote("com.example.forum.orphan", "bafyO"), // removed + ], + ); + + let classes = classify(&state); + + assert_eq!( + classes.get("com.example.forum.post"), + Some(&Classification::Unchanged) + ); + assert_eq!( + classes.get("com.example.forum.thread"), + Some(&Classification::Changed { + local_cid: "bafyNEW".into(), + remote_cid: "bafyOLD".into(), + }), + ); + assert_eq!( + classes.get("com.example.forum.reply"), + Some(&Classification::New) + ); + assert_eq!( + classes.get("com.example.forum.orphan"), + Some(&Classification::Removed) + ); + } + + #[test] + fn classify_ignores_remote_records_outside_package_scope() { + // The PDS may hold records for other packages under the same + // account. We must never touch records whose NSID isn't under + // `[package].name`. + let state = state_with( + "com.example.forum", + vec![mk_local("com.example.forum.post", "bafyA")], + vec![ + mk_remote("com.example.forum.post", "bafyA"), + // Under the same account but a different package — leave it alone. + mk_remote("com.example.directory.listing", "bafyD"), + ], + ); + + let classes = classify(&state); + assert_eq!(classes.len(), 1); + assert!(classes.contains_key("com.example.forum.post")); + assert!(!classes.contains_key("com.example.directory.listing")); + } + + #[test] + fn authorities_collapse_by_full_prefix() { + let auths = authorities_for_nsids( + ["com.example.forum.post", "com.example.forum.thread"].into_iter(), + ) + .unwrap(); + assert_eq!(auths.len(), 1); + assert!(auths.contains("com.example.forum")); + } + + #[test] + fn authorities_split_on_different_sub_authority() { + let auths = authorities_for_nsids( + ["com.example.forum.post", "com.example.directory.listing"].into_iter(), + ) + .unwrap(); + assert_eq!(auths.len(), 2); + assert!(auths.contains("com.example.forum")); + assert!(auths.contains("com.example.directory")); + } +} diff --git a/mlf-cli/src/status.rs b/mlf-cli/src/status.rs new file mode 100644 index 0000000..f377e16 --- /dev/null +++ b/mlf-cli/src/status.rs @@ -0,0 +1,127 @@ +//! `mlf status` — show each NSID's state (new / changed / unchanged / +//! removed) relative to what's currently published on the PDS, plus the +//! DNS readiness of each authority in the package. + +use crate::remote_state::{ + AuthorityStatus, Classification, RemoteState, RemoteStateError, classify, +}; + +pub async fn run_status() -> Result<(), RemoteStateError> { + let state = RemoteState::load().await?; + + println!("Package: {}", state.package.name); + match state.publishing_did.as_deref() { + Some(did) => println!("Publishing DID: {did}"), + None => println!("Publishing DID: (not yet set — _lexicon TXT records missing)"), + } + if let Some(pds) = state.pds.as_deref() { + println!("PDS: {pds}"); + } + println!(); + + print_dns_section(&state); + println!(); + print_lexicon_section(&state); + + Ok(()) +} + +fn print_dns_section(state: &RemoteState) { + println!("DNS authority readiness:"); + if state.authority_status.is_empty() { + println!(" (no authorities found — is your workspace empty?)"); + return; + } + for (label, status) in &state.authority_status { + match status { + AuthorityStatus::Resolved { did } => { + let matches_package = state + .publishing_did + .as_deref() + .map(|p| p == did) + .unwrap_or(false); + let marker = if matches_package { "✓" } else { "≠" }; + println!(" {marker} {label} → did={did}"); + } + AuthorityStatus::Missing => { + println!(" ✗ {label} → (TXT record missing)"); + } + AuthorityStatus::Error(e) => { + println!(" ! {label} → (lookup failed: {e})"); + } + } + } +} + +fn print_lexicon_section(state: &RemoteState) { + let classifications = classify(state); + + if classifications.is_empty() { + println!("No local lexicons found."); + return; + } + + let mut new = Vec::new(); + let mut changed = Vec::new(); + let mut removed = Vec::new(); + let mut unchanged = Vec::new(); + for (nsid, cls) in &classifications { + match cls { + Classification::New => new.push(nsid), + Classification::Changed { .. } => changed.push(nsid), + Classification::Removed => removed.push(nsid), + Classification::Unchanged => unchanged.push(nsid), + } + } + + println!( + "Lexicons: {} total ({} unchanged, {} changed, {} new, {} removed)", + classifications.len(), + unchanged.len(), + changed.len(), + new.len(), + removed.len(), + ); + println!(); + + if !new.is_empty() { + println!(" New (will be published):"); + for nsid in new { + if let Some(local) = state.local.get(nsid) { + println!(" + {nsid} cid={}", local.cid); + } + } + println!(); + } + + if !changed.is_empty() { + println!(" Changed (will be republished):"); + for nsid in changed { + if let Some(Classification::Changed { + local_cid, + remote_cid, + }) = classifications.get(nsid) + { + println!(" ~ {nsid} {remote_cid} → {local_cid}"); + } + } + println!(); + } + + if !removed.is_empty() { + println!(" Removed (will be unpublished):"); + for nsid in removed { + if let Some(remote) = state.remote.get(nsid) { + println!(" - {nsid} cid={}", remote.cid); + } + } + println!(); + } + + if !unchanged.is_empty() { + println!(" Unchanged:"); + for nsid in unchanged { + println!(" = {nsid}"); + } + } +} diff --git a/website/content/docs/cli/08-status.md b/website/content/docs/cli/08-status.md new file mode 100644 index 0000000..4415bb8 --- /dev/null +++ b/website/content/docs/cli/08-status.md @@ -0,0 +1,63 @@ ++++ +title = "Status Command" +description = "Show what's changed since the last publish" +weight = 8 ++++ + +`mlf status` compares every lexicon in your workspace against whatever's currently published on your PDS and reports, per NSID, whether it's **unchanged**, **changed**, **new**, or **removed**. It also summarises the DNS readiness of every authority under `[package].name`. + +The command is read-only — no credentials needed, no writes performed. It exercises the same pipeline a `mlf publish` run would, minus the network writes. + +## Usage + +```bash +mlf status +``` + +No flags yet. The command reads `mlf.toml` from the current directory or a parent, generates Lexicon JSON for every `.mlf` file under `[source].directory`, computes each record's CID, resolves each `_lexicon.` TXT to a DID, and fetches the current `com.atproto.lexicon.schema` records from the resulting PDS. + +## Output + +``` +Package: com.example.forum +Publishing DID: did:plc:abcd1234 +PDS: https://pds.example.com + +DNS authority readiness: + ✓ _lexicon.forum.example.com → did=did:plc:abcd1234 + +Lexicons: 3 total (1 unchanged, 1 changed, 1 new, 0 removed) + + New (will be published): + + com.example.forum.reply cid=bafyLOCAL + + Changed (will be republished): + ~ com.example.forum.thread bafyREMOTE → bafyLOCAL + + Unchanged: + = com.example.forum.post +``` + +Symbols: +- `✓` — authority's TXT resolves to the package's DID. +- `≠` — TXT resolves to a *different* DID than the package's. Publish will refuse unless you fix the TXT or pass `--force`. +- `✗` — TXT is missing. Publish will create it (if a DNS plugin is configured for this authority). +- `!` — lookup failed for another reason (network, DNSSEC, etc.). + +Lexicon classifications: +- `=` **Unchanged** — local CID matches the record currently on the PDS. +- `~` **Changed** — local CID differs; publish will replace the record. +- `+` **New** — no record under this NSID on the PDS yet; publish will create one. +- `-` **Removed** — a record exists on the PDS under an NSID your workspace no longer defines; publish will delete it. + +Records whose NSID isn't under `[package].name` are left alone — even if they sit in the same PDS repo, they belong to a different package and aren't this workspace's concern. + +## Exit codes + +- `0` — status fetched and printed (no error even if there are differences; status is informational). +- Non-zero — could not load the workspace, resolve DNS, or reach the PDS. + +## See also + +- [`mlf diff`](../09-diff/) — show the actual JSON diff for a changed lexicon. +- [Configuration → Package Identity](../02-configuration/#package-identity) — what controls which NSIDs are in-scope. diff --git a/website/content/docs/cli/09-diff.md b/website/content/docs/cli/09-diff.md new file mode 100644 index 0000000..fc611d4 --- /dev/null +++ b/website/content/docs/cli/09-diff.md @@ -0,0 +1,53 @@ ++++ +title = "Diff Command" +description = "Show what would change on publish" +weight = 9 ++++ + +`mlf diff` prints a line-based unified diff between the Lexicon JSON your workspace currently produces and the record already published on the PDS, for one NSID or every changed lexicon in the package. + +Read-only. No credentials needed. + +## Usage + +```bash +# Diff every changed / new / removed NSID +mlf diff + +# Diff just one +mlf diff com.example.forum.thread +``` + +## Output + +``` +~ com.example.forum.thread bafyREMOTE → bafyLOCAL +---------------------------------------------------- + { + "$type": "com.atproto.lexicon.schema", + "lexicon": 1, + "id": "com.example.forum.thread", + "defs": { + "main": { + "type": "record", +- "description": "A thread" ++ "description": "A thread (v2)" + } + } + } +``` + +Header symbols match [`mlf status`](../08-status/): +- `~` — changed (CID differs; both sides present) +- `+` — new (no remote side yet) +- `-` — removed (no local side, record still on PDS) +- `=` — unchanged (only rendered when you explicitly target the NSID) + +## Notes + +- The diff shows the **record** as it would be stored on the PDS — that is, the Lexicon JSON wrapped with `"$type": "com.atproto.lexicon.schema"`. The CID header is the content-hash of that exact shape. +- Key order inside JSON objects is serialiser-defined (alphabetical by default). CID comparison is independent of display order; the DAG-CBOR canonical form is what the hash is taken over. + +## See also + +- [`mlf status`](../08-status/) — overview of every lexicon's state at a glance.