//! `mlf unpublish` — delete every lexicon in the package's latest //! manifest, plus every manifest record in the publish log. Manifests //! live in collection `lol.mlf.package` with TID rkeys; we read the //! most-recent one to learn what NSIDs belong to us. If no manifest //! exists we refuse — we'd be guessing which records to delete. use crate::config::{ConfigError, MlfConfig, find_project_root}; use crate::credentials::{CredentialsFile, Scope}; use crate::remote_state::{RemoteStateError}; use dialoguer::{Confirm, theme::ColorfulTheme}; use miette::Diagnostic; use mlf_atproto::records::{self, RecordError}; use mlf_atproto::{identity, session}; use mlf_publish::manifest; use std::collections::BTreeSet; use thiserror::Error; #[derive(Error, Debug, Diagnostic)] pub enum UnpublishError { #[error("{0}")] #[diagnostic(transparent)] RemoteState(#[from] RemoteStateError), #[error("Failed to load mlf.toml: {0}")] #[diagnostic(code(mlf::unpublish::config))] Config(String), #[error("Package is not publishable — `[publish]` section missing from mlf.toml")] #[diagnostic(code(mlf::unpublish::not_publishable))] NotPublishable, #[error( "No manifest records found in `lol.mlf.package` collection — refusing to guess which records belong to this workspace" )] #[diagnostic( code(mlf::unpublish::no_manifest), help("Publish once first to create a manifest we can read back.") )] NoManifest, #[error("PDS credentials are missing. Run `mlf login pds` first.")] #[diagnostic(code(mlf::unpublish::no_pds_creds))] NoPdsCreds, #[error("PDS session error: {0}")] #[diagnostic(code(mlf::unpublish::session))] Session(String), #[error("Record delete failed for `{nsid}`: {message}")] #[diagnostic(code(mlf::unpublish::record_delete))] RecordDelete { nsid: String, message: String }, #[error("Credential file error: {0}")] #[diagnostic(code(mlf::unpublish::credentials))] Credentials(String), #[error("Cancelled by user")] #[diagnostic(code(mlf::unpublish::cancelled))] Cancelled, } #[derive(Debug, Clone, Default)] pub struct UnpublishOpts { /// Skip the interactive confirmation prompt. pub yes: bool, } pub async fn run_unpublish(opts: UnpublishOpts) -> Result<(), UnpublishError> { let current_dir = std::env::current_dir().map_err(|e| UnpublishError::Config(format!("getcwd: {e}")))?; let project_root = find_project_root(¤t_dir).map_err(|e| match e { ConfigError::NotFound => UnpublishError::Config("no mlf.toml found".into()), other => UnpublishError::Config(other.to_string()), })?; let config_path = project_root.join("mlf.toml"); let config = MlfConfig::load(&config_path).map_err(|e| UnpublishError::Config(e.to_string()))?; if config.publish.is_none() { return Err(UnpublishError::NotPublishable); } let package = config.package.clone(); // Credentials + session. We need the session before we can read // the publish log, which lives in the authed repo. let creds = load_credentials(&project_root)?; let pds_creds = creds.pds.ok_or(UnpublishError::NoPdsCreds)?; let handle = pds_creds.handle.clone().ok_or(UnpublishError::NoPdsCreds)?; let app_password = pds_creds .app_password .clone() .ok_or(UnpublishError::NoPdsCreds)?; let http = reqwest::Client::new(); let pds_url = match pds_creds.extra.get("pds").and_then(|v| v.as_str()) { Some(url) => url.to_string(), None => { let did = identity::resolve_handle_to_did(&http, &handle) .await .map_err(|e| UnpublishError::Session(e.to_string()))?; identity::resolve_did_to_pds(&http, &did) .await .map_err(|e| UnpublishError::Session(e.to_string()))? } }; let sess = session::create_session(&http, &pds_url, &handle, &app_password) .await .map_err(|e| UnpublishError::Session(e.to_string()))?; // List every manifest record (sorted newest-first by TID rkey). println!("Reading publish log..."); let manifest_records = records::list_all_records(&http, &pds_url, &sess.did, manifest::NSID) .await .map_err(|e| UnpublishError::Session(e.to_string()))?; if manifest_records.is_empty() { return Err(UnpublishError::NoManifest); } let (latest_rkey, latest) = latest_manifest(&manifest_records); let to_delete = manifest_items(&latest.value); if to_delete.is_empty() { println!("Latest manifest lists zero records."); } else { println!( "Latest manifest ({latest_rkey}) lists {} record(s) published under `{}`:", to_delete.len(), package.name ); for nsid in &to_delete { println!(" - {nsid}"); } } if manifest_records.len() > 1 { println!( "Publish log has {} manifest record(s) total — all will be removed.", manifest_records.len() ); } if !opts.yes && !confirm()? { return Err(UnpublishError::Cancelled); } // Delete the schema records named in the latest manifest. for nsid in &to_delete { // Defence in depth: only ever delete records inside this // package's scope. A corrupted or hand-edited manifest pointing // at foreign NSIDs gets ignored. if !package.namespace_is_in_scope(nsid) { eprintln!("Skipping out-of-scope record `{nsid}`"); continue; } delete_one( &http, &pds_url, &sess.access_jwt, &sess.did, "com.atproto.lexicon.schema", nsid, ) .await?; println!(" ✓ deleted {nsid}"); } // Delete every manifest record — the entire publish log for this repo. for r in &manifest_records { let rkey = rkey_from_uri(&r.uri); delete_one( &http, &pds_url, &sess.access_jwt, &sess.did, manifest::NSID, &rkey, ) .await?; println!(" ✓ deleted {}/{rkey} (manifest)", manifest::NSID); } println!("\n✓ Unpublish complete"); Ok(()) } /// Pick the newest manifest. Manifest rkeys are TIDs, which sort /// lexicographically in chronological order — so the highest rkey wins. fn latest_manifest(records: &[records::Record]) -> (String, &records::Record) { let mut best_idx = 0usize; let mut best_rkey = rkey_from_uri(&records[0].uri); for (i, r) in records.iter().enumerate().skip(1) { let rkey = rkey_from_uri(&r.uri); if rkey > best_rkey { best_rkey = rkey; best_idx = i; } } (best_rkey, &records[best_idx]) } fn rkey_from_uri(uri: &str) -> String { uri.rsplit('/').next().unwrap_or(uri).to_string() } fn manifest_items(record: &serde_json::Value) -> BTreeSet { let Some(items) = record.get("published").and_then(|v| v.as_array()) else { return BTreeSet::new(); }; items .iter() .filter_map(|item| { item.get("nsid") .and_then(|v| v.as_str()) .map(str::to_string) }) .collect() } fn load_credentials(project_root: &std::path::Path) -> Result { let global_path = match Scope::Global.path(project_root) { Ok(p) => p, Err(_) => { return CredentialsFile::load( &Scope::Project .path(project_root) .map_err(|e| UnpublishError::Credentials(e.to_string()))?, ) .map_err(|e| UnpublishError::Credentials(e.to_string())); } }; let mut merged = CredentialsFile::load(&global_path) .map_err(|e| UnpublishError::Credentials(e.to_string()))?; let project_path = Scope::Project .path(project_root) .map_err(|e| UnpublishError::Credentials(e.to_string()))?; let project = CredentialsFile::load(&project_path) .map_err(|e| UnpublishError::Credentials(e.to_string()))?; if project.pds.is_some() { merged.pds = project.pds; } for (k, v) in project.dns { merged.dns.insert(k, v); } Ok(merged) } fn confirm() -> Result { // Non-TTY treat as "yes skipped" semantically — the caller should // pass --yes in that case. Here we err on the safe side and abort. if !std::io::IsTerminal::is_terminal(&std::io::stdin()) { return Err(UnpublishError::Cancelled); } Confirm::with_theme(&ColorfulTheme::default()) .with_prompt("Proceed with unpublish?") .default(false) .interact() .map_err(|e| UnpublishError::Session(e.to_string())) } async fn delete_one( http: &reqwest::Client, pds: &str, access_jwt: &str, repo: &str, collection: &str, rkey: &str, ) -> Result<(), UnpublishError> { match records::delete_record(http, pds, access_jwt, repo, collection, rkey).await { Ok(()) => Ok(()), Err(RecordError::NotFound { .. }) => { // Already gone — fine. Happens if manifest lists something the // user deleted out-of-band. Ok(()) } Err(e) => Err(UnpublishError::RecordDelete { nsid: rkey.to_string(), message: e.to_string(), }), } }