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.