From c4fbd334d931715db56eb452afdbe3c36a6172c7 Mon Sep 17 00:00:00 2001 From: Matt Stavola Date: Fri, 17 Apr 2026 21:11:35 -0400 Subject: [PATCH] Add mlf unpublish command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- mlf-cli/src/lib.rs | 1 + mlf-cli/src/main.rs | 11 +- mlf-cli/src/unpublish.rs | 249 +++++++++++++++++++++++ website/content/docs/cli/12-unpublish.md | 41 ++++ 4 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 mlf-cli/src/unpublish.rs create mode 100644 website/content/docs/cli/12-unpublish.md diff --git a/mlf-cli/src/lib.rs b/mlf-cli/src/lib.rs index 3a55300..dff79b1 100644 --- a/mlf-cli/src/lib.rs +++ b/mlf-cli/src/lib.rs @@ -10,4 +10,5 @@ pub mod logout; pub mod publish; pub mod remote_state; pub mod status; +pub mod unpublish; pub mod workspace_ext; diff --git a/mlf-cli/src/main.rs b/mlf-cli/src/main.rs index e7b2385..7095636 100644 --- a/mlf-cli/src/main.rs +++ b/mlf-cli/src/main.rs @@ -2,7 +2,7 @@ use clap::{Parser, Subcommand}; use miette::IntoDiagnostic; use mlf_cli::credentials::Scope; use mlf_cli::logout::Target as LogoutTarget; -use mlf_cli::{check, diff, fetch, generate, init, login, logout, publish, status}; +use mlf_cli::{check, diff, fetch, generate, init, login, logout, publish, status, unpublish}; use std::path::PathBuf; use std::process; @@ -126,6 +126,12 @@ enum Commands { args: Vec, }, + #[command(about = "Delete every lexicon the workspace has published, per the manifest")] + Unpublish { + #[arg(long, help = "Skip the interactive confirmation prompt")] + yes: bool, + }, + #[command(about = "Clear stored credentials")] Logout { #[command(subcommand)] @@ -353,6 +359,9 @@ async fn main() { .await .into_diagnostic() } + Commands::Unpublish { yes } => unpublish::run_unpublish(unpublish::UnpublishOpts { yes }) + .await + .into_diagnostic(), Commands::Logout { target, project } => { let scope = if project { Scope::Project diff --git a/mlf-cli/src/unpublish.rs b/mlf-cli/src/unpublish.rs new file mode 100644 index 0000000..5f7b052 --- /dev/null +++ b/mlf-cli/src/unpublish.rs @@ -0,0 +1,249 @@ +//! `mlf unpublish` — delete every lexicon in the package's manifest, +//! plus the manifest record itself. Read the published manifest to +//! figure out what was published; if it's missing, refuse (we'd be +//! guessing which records belong to this workspace otherwise). + +use crate::config::{ConfigError, MlfConfig, find_project_root}; +use crate::credentials::{CredentialsFile, Scope}; +use crate::remote_state::{RemoteState, RemoteStateError}; +use dialoguer::{Confirm, theme::ColorfulTheme}; +use miette::Diagnostic; +use mlf_atproto::records::{self, RecordError}; +use mlf_atproto::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 (`lol.mlf.package`) found on the PDS — refusing to guess which records belong to this workspace" + )] + #[diagnostic( + code(mlf::unpublish::no_manifest), + help( + "Delete records manually via `goat lex unpublish`, or 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); + } + + println!("Loading remote state..."); + let state = RemoteState::load().await?; + + // The manifest is what tells us "here's what this workspace published." + // If it's missing we don't know which records belong to us, so refuse. + let manifest_record = state + .remote + .get(manifest::NSID) + .ok_or(UnpublishError::NoManifest)? + .record_json + .clone(); + let to_delete = manifest_items(&manifest_record); + + if to_delete.is_empty() { + println!("Manifest lists zero records. Nothing to delete except the manifest itself."); + } else { + println!( + "Manifest lists {} record(s) published under `{}`:", + to_delete.len(), + state.package.name + ); + for nsid in &to_delete { + println!(" - {nsid}"); + } + } + + if !opts.yes && !confirm()? { + return Err(UnpublishError::Cancelled); + } + + // Credentials + session. + 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 = mlf_atproto::identity::resolve_handle_to_did(&http, &handle) + .await + .map_err(|e| UnpublishError::Session(e.to_string()))?; + mlf_atproto::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()))?; + + let collection = "com.atproto.lexicon.schema"; + + for nsid in &to_delete { + // Only ever delete records in scope of the package — defence in depth + // against a corrupted or hand-edited manifest naming foreign NSIDs. + if !state.package.namespace_is_in_scope(nsid) && nsid != manifest::NSID { + eprintln!("Skipping out-of-scope record `{nsid}`"); + continue; + } + delete_one( + &http, + &pds_url, + &sess.access_jwt, + &sess.did, + collection, + nsid, + ) + .await?; + println!(" ✓ deleted {nsid}"); + } + + // Finally, the manifest itself. + delete_one( + &http, + &pds_url, + &sess.access_jwt, + &sess.did, + collection, + manifest::NSID, + ) + .await?; + println!(" ✓ deleted {} (manifest)", manifest::NSID); + + println!("\n✓ Unpublish complete"); + Ok(()) +} + +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(), + }), + } +} diff --git a/website/content/docs/cli/12-unpublish.md b/website/content/docs/cli/12-unpublish.md new file mode 100644 index 0000000..8a8255e --- /dev/null +++ b/website/content/docs/cli/12-unpublish.md @@ -0,0 +1,41 @@ ++++ +title = "Unpublish Command" +description = "Retire every lexicon in the package" +weight = 12 ++++ + +`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. + +## Usage + +```bash +mlf unpublish # confirms interactively before deleting +mlf unpublish --yes # skip the confirmation prompt +``` + +## Behaviour + +1. Load `mlf.toml` and verify `[publish]` is configured. +2. Fetch the current remote state (same pipeline `mlf status` uses). +3. Read the `lol.mlf.package` record and collect its `published[].nsid` list. +4. Print the list and prompt for confirmation (skippable with `--yes`). +5. Authenticate against the PDS. +6. `deleteRecord` each listed NSID, then `deleteRecord` the manifest itself. + +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. + +## Notes + +- Each delete is idempotent. If a record was already removed out-of-band, the command logs it as deleted without erroring. +- DNS TXT records are *not* removed. The `_lexicon.` 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. +- There is no "soft" unpublish. Republish the same source if you want to restore the records. + +## Exit codes + +- `0` — every record the manifest named is deleted (or was already gone). +- Non-zero — no manifest found, missing credentials, authentication failed, or a `deleteRecord` call errored. + +## See also + +- [`mlf publish`](../11-publish/) — the other half of the lifecycle. +- [`mlf status`](../08-status/) — verify the remote is empty after unpublish. -- 2.51.2