A human-friendly DSL for ATProto Lexicons
26
fork

Configure Feed

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

Add mlf unpublish command

Deletes every lexicon the workspace published, using the lol.mlf.package
manifest as the list of NSIDs — so we never guess which records on the
PDS belong to this workspace. Refuses to proceed if no manifest exists.
After record deletes, the manifest record itself is removed.

Interactive confirmation by default; --yes skips. Each deleteRecord
is idempotent (already-gone records are logged and skipped). Records
whose NSID isn't a descendant of [package].name are skipped even if
they appear in the manifest — guards against a hand-edited manifest
that names foreign NSIDs. DNS TXT records are intentionally left in
place so re-publishing doesn't require re-provisioning DNS.

authored by stavola.xyz and committed by

Tangled c4fbd334 4f110ee4

+301 -1
+1
mlf-cli/src/lib.rs
··· 10 10 pub mod publish; 11 11 pub mod remote_state; 12 12 pub mod status; 13 + pub mod unpublish; 13 14 pub mod workspace_ext;
+10 -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, publish, status}; 5 + use mlf_cli::{check, diff, fetch, generate, init, login, logout, publish, status, unpublish}; 6 6 use std::path::PathBuf; 7 7 use std::process; 8 8 ··· 124 124 help = "Optional credential overrides forwarded into the session (e.g. --pds-app-password=TKN)" 125 125 )] 126 126 args: Vec<String>, 127 + }, 128 + 129 + #[command(about = "Delete every lexicon the workspace has published, per the manifest")] 130 + Unpublish { 131 + #[arg(long, help = "Skip the interactive confirmation prompt")] 132 + yes: bool, 127 133 }, 128 134 129 135 #[command(about = "Clear stored credentials")] ··· 353 359 .await 354 360 .into_diagnostic() 355 361 } 362 + Commands::Unpublish { yes } => unpublish::run_unpublish(unpublish::UnpublishOpts { yes }) 363 + .await 364 + .into_diagnostic(), 356 365 Commands::Logout { target, project } => { 357 366 let scope = if project { 358 367 Scope::Project
+249
mlf-cli/src/unpublish.rs
··· 1 + //! `mlf unpublish` — delete every lexicon in the package's manifest, 2 + //! plus the manifest record itself. Read the published manifest to 3 + //! figure out what was published; if it's missing, refuse (we'd be 4 + //! guessing which records belong to this workspace otherwise). 5 + 6 + use crate::config::{ConfigError, MlfConfig, find_project_root}; 7 + use crate::credentials::{CredentialsFile, Scope}; 8 + use crate::remote_state::{RemoteState, RemoteStateError}; 9 + use dialoguer::{Confirm, theme::ColorfulTheme}; 10 + use miette::Diagnostic; 11 + use mlf_atproto::records::{self, RecordError}; 12 + use mlf_atproto::session; 13 + use mlf_publish::manifest; 14 + use std::collections::BTreeSet; 15 + use thiserror::Error; 16 + 17 + #[derive(Error, Debug, Diagnostic)] 18 + pub enum UnpublishError { 19 + #[error("{0}")] 20 + #[diagnostic(transparent)] 21 + RemoteState(#[from] RemoteStateError), 22 + 23 + #[error("Failed to load mlf.toml: {0}")] 24 + #[diagnostic(code(mlf::unpublish::config))] 25 + Config(String), 26 + 27 + #[error("Package is not publishable — `[publish]` section missing from mlf.toml")] 28 + #[diagnostic(code(mlf::unpublish::not_publishable))] 29 + NotPublishable, 30 + 31 + #[error( 32 + "No manifest (`lol.mlf.package`) found on the PDS — refusing to guess which records belong to this workspace" 33 + )] 34 + #[diagnostic( 35 + code(mlf::unpublish::no_manifest), 36 + help( 37 + "Delete records manually via `goat lex unpublish`, or publish once first to create a manifest we can read back." 38 + ) 39 + )] 40 + NoManifest, 41 + 42 + #[error("PDS credentials are missing. Run `mlf login pds` first.")] 43 + #[diagnostic(code(mlf::unpublish::no_pds_creds))] 44 + NoPdsCreds, 45 + 46 + #[error("PDS session error: {0}")] 47 + #[diagnostic(code(mlf::unpublish::session))] 48 + Session(String), 49 + 50 + #[error("Record delete failed for `{nsid}`: {message}")] 51 + #[diagnostic(code(mlf::unpublish::record_delete))] 52 + RecordDelete { nsid: String, message: String }, 53 + 54 + #[error("Credential file error: {0}")] 55 + #[diagnostic(code(mlf::unpublish::credentials))] 56 + Credentials(String), 57 + 58 + #[error("Cancelled by user")] 59 + #[diagnostic(code(mlf::unpublish::cancelled))] 60 + Cancelled, 61 + } 62 + 63 + #[derive(Debug, Clone, Default)] 64 + pub struct UnpublishOpts { 65 + /// Skip the interactive confirmation prompt. 66 + pub yes: bool, 67 + } 68 + 69 + pub async fn run_unpublish(opts: UnpublishOpts) -> Result<(), UnpublishError> { 70 + let current_dir = 71 + std::env::current_dir().map_err(|e| UnpublishError::Config(format!("getcwd: {e}")))?; 72 + let project_root = find_project_root(&current_dir).map_err(|e| match e { 73 + ConfigError::NotFound => UnpublishError::Config("no mlf.toml found".into()), 74 + other => UnpublishError::Config(other.to_string()), 75 + })?; 76 + let config_path = project_root.join("mlf.toml"); 77 + let config = 78 + MlfConfig::load(&config_path).map_err(|e| UnpublishError::Config(e.to_string()))?; 79 + if config.publish.is_none() { 80 + return Err(UnpublishError::NotPublishable); 81 + } 82 + 83 + println!("Loading remote state..."); 84 + let state = RemoteState::load().await?; 85 + 86 + // The manifest is what tells us "here's what this workspace published." 87 + // If it's missing we don't know which records belong to us, so refuse. 88 + let manifest_record = state 89 + .remote 90 + .get(manifest::NSID) 91 + .ok_or(UnpublishError::NoManifest)? 92 + .record_json 93 + .clone(); 94 + let to_delete = manifest_items(&manifest_record); 95 + 96 + if to_delete.is_empty() { 97 + println!("Manifest lists zero records. Nothing to delete except the manifest itself."); 98 + } else { 99 + println!( 100 + "Manifest lists {} record(s) published under `{}`:", 101 + to_delete.len(), 102 + state.package.name 103 + ); 104 + for nsid in &to_delete { 105 + println!(" - {nsid}"); 106 + } 107 + } 108 + 109 + if !opts.yes && !confirm()? { 110 + return Err(UnpublishError::Cancelled); 111 + } 112 + 113 + // Credentials + session. 114 + let creds = load_credentials(&project_root)?; 115 + let pds_creds = creds.pds.ok_or(UnpublishError::NoPdsCreds)?; 116 + let handle = pds_creds.handle.clone().ok_or(UnpublishError::NoPdsCreds)?; 117 + let app_password = pds_creds 118 + .app_password 119 + .clone() 120 + .ok_or(UnpublishError::NoPdsCreds)?; 121 + let http = reqwest::Client::new(); 122 + let pds_url = match pds_creds.extra.get("pds").and_then(|v| v.as_str()) { 123 + Some(url) => url.to_string(), 124 + None => { 125 + let did = mlf_atproto::identity::resolve_handle_to_did(&http, &handle) 126 + .await 127 + .map_err(|e| UnpublishError::Session(e.to_string()))?; 128 + mlf_atproto::identity::resolve_did_to_pds(&http, &did) 129 + .await 130 + .map_err(|e| UnpublishError::Session(e.to_string()))? 131 + } 132 + }; 133 + let sess = session::create_session(&http, &pds_url, &handle, &app_password) 134 + .await 135 + .map_err(|e| UnpublishError::Session(e.to_string()))?; 136 + 137 + let collection = "com.atproto.lexicon.schema"; 138 + 139 + for nsid in &to_delete { 140 + // Only ever delete records in scope of the package — defence in depth 141 + // against a corrupted or hand-edited manifest naming foreign NSIDs. 142 + if !state.package.namespace_is_in_scope(nsid) && nsid != manifest::NSID { 143 + eprintln!("Skipping out-of-scope record `{nsid}`"); 144 + continue; 145 + } 146 + delete_one( 147 + &http, 148 + &pds_url, 149 + &sess.access_jwt, 150 + &sess.did, 151 + collection, 152 + nsid, 153 + ) 154 + .await?; 155 + println!(" ✓ deleted {nsid}"); 156 + } 157 + 158 + // Finally, the manifest itself. 159 + delete_one( 160 + &http, 161 + &pds_url, 162 + &sess.access_jwt, 163 + &sess.did, 164 + collection, 165 + manifest::NSID, 166 + ) 167 + .await?; 168 + println!(" ✓ deleted {} (manifest)", manifest::NSID); 169 + 170 + println!("\n✓ Unpublish complete"); 171 + Ok(()) 172 + } 173 + 174 + fn manifest_items(record: &serde_json::Value) -> BTreeSet<String> { 175 + let Some(items) = record.get("published").and_then(|v| v.as_array()) else { 176 + return BTreeSet::new(); 177 + }; 178 + items 179 + .iter() 180 + .filter_map(|item| { 181 + item.get("nsid") 182 + .and_then(|v| v.as_str()) 183 + .map(str::to_string) 184 + }) 185 + .collect() 186 + } 187 + 188 + fn load_credentials(project_root: &std::path::Path) -> Result<CredentialsFile, UnpublishError> { 189 + let global_path = match Scope::Global.path(project_root) { 190 + Ok(p) => p, 191 + Err(_) => { 192 + return CredentialsFile::load( 193 + &Scope::Project 194 + .path(project_root) 195 + .map_err(|e| UnpublishError::Credentials(e.to_string()))?, 196 + ) 197 + .map_err(|e| UnpublishError::Credentials(e.to_string())); 198 + } 199 + }; 200 + let mut merged = CredentialsFile::load(&global_path) 201 + .map_err(|e| UnpublishError::Credentials(e.to_string()))?; 202 + let project_path = Scope::Project 203 + .path(project_root) 204 + .map_err(|e| UnpublishError::Credentials(e.to_string()))?; 205 + let project = CredentialsFile::load(&project_path) 206 + .map_err(|e| UnpublishError::Credentials(e.to_string()))?; 207 + if project.pds.is_some() { 208 + merged.pds = project.pds; 209 + } 210 + for (k, v) in project.dns { 211 + merged.dns.insert(k, v); 212 + } 213 + Ok(merged) 214 + } 215 + 216 + fn confirm() -> Result<bool, UnpublishError> { 217 + // Non-TTY treat as "yes skipped" semantically — the caller should 218 + // pass --yes in that case. Here we err on the safe side and abort. 219 + if !std::io::IsTerminal::is_terminal(&std::io::stdin()) { 220 + return Err(UnpublishError::Cancelled); 221 + } 222 + Confirm::with_theme(&ColorfulTheme::default()) 223 + .with_prompt("Proceed with unpublish?") 224 + .default(false) 225 + .interact() 226 + .map_err(|e| UnpublishError::Session(e.to_string())) 227 + } 228 + 229 + async fn delete_one( 230 + http: &reqwest::Client, 231 + pds: &str, 232 + access_jwt: &str, 233 + repo: &str, 234 + collection: &str, 235 + rkey: &str, 236 + ) -> Result<(), UnpublishError> { 237 + match records::delete_record(http, pds, access_jwt, repo, collection, rkey).await { 238 + Ok(()) => Ok(()), 239 + Err(RecordError::NotFound { .. }) => { 240 + // Already gone — fine. Happens if manifest lists something the 241 + // user deleted out-of-band. 242 + Ok(()) 243 + } 244 + Err(e) => Err(UnpublishError::RecordDelete { 245 + nsid: rkey.to_string(), 246 + message: e.to_string(), 247 + }), 248 + } 249 + }
+41
website/content/docs/cli/12-unpublish.md
··· 1 + +++ 2 + title = "Unpublish Command" 3 + description = "Retire every lexicon in the package" 4 + weight = 12 5 + +++ 6 + 7 + `mlf unpublish` reads the package's `lol.mlf.package` manifest record from the PDS, then `deleteRecord`s every NSID it lists plus the manifest itself. It refuses to proceed if no manifest exists — without one we can't tell which records in the PDS repo belong to this workspace. 8 + 9 + ## Usage 10 + 11 + ```bash 12 + mlf unpublish # confirms interactively before deleting 13 + mlf unpublish --yes # skip the confirmation prompt 14 + ``` 15 + 16 + ## Behaviour 17 + 18 + 1. Load `mlf.toml` and verify `[publish]` is configured. 19 + 2. Fetch the current remote state (same pipeline `mlf status` uses). 20 + 3. Read the `lol.mlf.package` record and collect its `published[].nsid` list. 21 + 4. Print the list and prompt for confirmation (skippable with `--yes`). 22 + 5. Authenticate against the PDS. 23 + 6. `deleteRecord` each listed NSID, then `deleteRecord` the manifest itself. 24 + 25 + Records whose NSID isn't a descendant of `[package].name` are skipped even if they appear in the manifest — defence in depth against a hand-edited or corrupted manifest naming foreign records. 26 + 27 + ## Notes 28 + 29 + - Each delete is idempotent. If a record was already removed out-of-band, the command logs it as deleted without erroring. 30 + - DNS TXT records are *not* removed. The `_lexicon.<authority>` TXT keeps pointing at your DID; you can re-publish later without re-provisioning DNS. Remove the TXT manually via your registrar / DNS plugin if you want the authority completely released. 31 + - There is no "soft" unpublish. Republish the same source if you want to restore the records. 32 + 33 + ## Exit codes 34 + 35 + - `0` — every record the manifest named is deleted (or was already gone). 36 + - Non-zero — no manifest found, missing credentials, authentication failed, or a `deleteRecord` call errored. 37 + 38 + ## See also 39 + 40 + - [`mlf publish`](../11-publish/) — the other half of the lifecycle. 41 + - [`mlf status`](../08-status/) — verify the remote is empty after unpublish.