Add purge command to delete all Opake data from PDS
Enumerates and deletes all records across all 7 app.opake.* collections. Supports --dry-run for preview, --force to skip passphrase confirmation and auto-remove local identity. [CL-196]
···1212- Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html)s
13131414### Added
1515+- Add purge command to delete all Opake data from PDS [#196](https://issues.opake.app/issues/196.html)
1516- Add metadata CLI command for rename, tag, and description management [#190](https://issues.opake.app/issues/190.html)
1617- Consolidate DNS and transport into opake-core, unify handle resolution [#185](https://issues.opake.app/issues/185.html)
1718- Build web login, callback, setup, and recover routes [#173](https://issues.opake.app/issues/173.html)
+9
README.md
···138138opake pair request # on the NEW device (polls for approval)
139139opake pair approve # on the EXISTING device (select + approve)
140140141141+# delete all Opake data from PDS (see what would go)
142142+opake purge --dry-run
143143+144144+# delete everything (prompts for confirmation phrase)
145145+opake purge
146146+147147+# skip confirmation and also remove local identity
148148+opake purge --force
149149+141150# remove an account (defaults to only account if just one)
142151opake logout
143152opake logout bob.other.com
+1
crates/opake-cli/src/commands/mod.rs
···1010pub mod mkdir;
1111pub mod move_cmd;
1212pub mod pair;
1313+pub mod purge;
1314pub mod resolve;
1415pub mod revoke;
1516pub mod rm;
+186
crates/opake-cli/src/commands/purge.rs
···11+use anyhow::{Context, Result};
22+use clap::Args;
33+use opake_core::atproto;
44+use opake_core::client::{Session, Transport, XrpcClient};
55+use opake_core::directories::DIRECTORY_COLLECTION;
66+use opake_core::documents::DOCUMENT_COLLECTION;
77+use opake_core::keyrings::KEYRING_COLLECTION;
88+use opake_core::records::{
99+ PAIR_REQUEST_COLLECTION, PAIR_RESPONSE_COLLECTION, PUBLIC_KEY_COLLECTION,
1010+};
1111+use opake_core::sharing::GRANT_COLLECTION;
1212+1313+use crate::commands::Execute;
1414+use crate::session::{self, CommandContext};
1515+1616+const CONFIRMATION_PHRASE: &str = "I want to delete all my Opake data";
1717+1818+/// All Opake collections in deletion order — dependents before parents.
1919+const COLLECTIONS: &[&str] = &[
2020+ GRANT_COLLECTION,
2121+ PAIR_RESPONSE_COLLECTION,
2222+ PAIR_REQUEST_COLLECTION,
2323+ KEYRING_COLLECTION,
2424+ DOCUMENT_COLLECTION,
2525+ DIRECTORY_COLLECTION,
2626+ PUBLIC_KEY_COLLECTION,
2727+];
2828+2929+/// Delete all Opake data from the PDS
3030+#[derive(Args)]
3131+pub struct PurgeCommand {
3232+ /// Show what would be deleted without deleting anything
3333+ #[arg(long)]
3434+ dry_run: bool,
3535+3636+ /// Skip confirmation prompt
3737+ #[arg(long)]
3838+ force: bool,
3939+}
4040+4141+/// Paginate through a collection and collect all record keys.
4242+///
4343+/// Unlike `list_collection`, this doesn't parse or version-check records —
4444+/// purge wants to delete everything regardless of schema version.
4545+async fn collect_rkeys(
4646+ client: &mut XrpcClient<impl Transport>,
4747+ collection: &str,
4848+) -> Result<Vec<String>> {
4949+ let mut rkeys = Vec::new();
5050+ let mut cursor: Option<String> = None;
5151+5252+ loop {
5353+ let page = client
5454+ .list_records(collection, Some(100), cursor.as_deref())
5555+ .await?;
5656+5757+ for record in &page.records {
5858+ let at_uri = atproto::parse_at_uri(&record.uri)?;
5959+ rkeys.push(at_uri.rkey);
6060+ }
6161+6262+ match page.cursor {
6363+ Some(c) => cursor = Some(c),
6464+ None => break,
6565+ }
6666+ }
6767+6868+ Ok(rkeys)
6969+}
7070+7171+/// Prompt the user to type the exact confirmation phrase.
7272+fn require_confirmation() -> Result<()> {
7373+ println!();
7474+ println!("WARNING: This will permanently delete ALL Opake data from your PDS.");
7575+ println!("All encrypted files, keys, grants, and directories will be gone.");
7676+ println!("This action is irreversible.");
7777+ println!();
7878+ println!("Type exactly: {CONFIRMATION_PHRASE}");
7979+ print!("> ");
8080+ std::io::Write::flush(&mut std::io::stdout())?;
8181+8282+ let mut input = String::new();
8383+ std::io::stdin().read_line(&mut input)?;
8484+8585+ if input.trim() != CONFIRMATION_PHRASE {
8686+ anyhow::bail!("Purge cancelled.");
8787+ }
8888+8989+ Ok(())
9090+}
9191+9292+/// Ask whether to delete local identity and session files.
9393+fn confirm_local_cleanup(force: bool) -> Result<bool> {
9494+ if force {
9595+ return Ok(true);
9696+ }
9797+9898+ eprint!("Delete local identity and session? [y/N] ");
9999+ let mut answer = String::new();
100100+ std::io::stdin()
101101+ .read_line(&mut answer)
102102+ .context("failed to read confirmation")?;
103103+104104+ Ok(answer.trim().eq_ignore_ascii_case("y"))
105105+}
106106+107107+impl Execute for PurgeCommand {
108108+ async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> {
109109+ let mut client = session::load_client(&ctx.storage, &ctx.did)?;
110110+111111+ // Enumerate all records across all collections.
112112+ let mut inventory: Vec<(&str, Vec<String>)> = Vec::new();
113113+ let mut total = 0usize;
114114+115115+ for &collection in COLLECTIONS {
116116+ let rkeys = collect_rkeys(&mut client, collection).await?;
117117+ total += rkeys.len();
118118+ inventory.push((collection, rkeys));
119119+ }
120120+121121+ // Nothing to do.
122122+ if total == 0 {
123123+ println!("No Opake records found on PDS.");
124124+ return Ok(session::refreshed_session(&client));
125125+ }
126126+127127+ // Print inventory (always, not just dry-run — useful context before confirmation).
128128+ let max_name_len = inventory
129129+ .iter()
130130+ .map(|(name, _)| name.len())
131131+ .max()
132132+ .unwrap_or(0);
133133+134134+ for &(collection, ref rkeys) in &inventory {
135135+ let noun = if rkeys.len() == 1 {
136136+ "record"
137137+ } else {
138138+ "records"
139139+ };
140140+ println!(
141141+ " {:<width$} {:>3} {noun}",
142142+ collection,
143143+ rkeys.len(),
144144+ width = max_name_len,
145145+ );
146146+ }
147147+148148+ println!();
149149+150150+ if self.dry_run {
151151+ let noun = if total == 1 { "record" } else { "records" };
152152+ println!("Total: {total} {noun} would be deleted.");
153153+ return Ok(session::refreshed_session(&client));
154154+ }
155155+156156+ // Confirmation gate.
157157+ if !self.force {
158158+ require_confirmation()?;
159159+ }
160160+161161+ // Delete everything.
162162+ for (collection, rkeys) in &inventory {
163163+ for rkey in rkeys {
164164+ client.delete_record(collection, rkey).await?;
165165+ }
166166+ if !rkeys.is_empty() {
167167+ println!("deleted {} {collection}", rkeys.len());
168168+ }
169169+ }
170170+171171+ println!();
172172+ println!("Purged {total} records from PDS.");
173173+174174+ // Local cleanup.
175175+ if confirm_local_cleanup(self.force)? {
176176+ // Session will be invalid after remove_account, so grab any refresh first.
177177+ let refreshed = session::refreshed_session(&client);
178178+ ctx.storage.remove_account(&ctx.did)?;
179179+ println!("Removed local identity and session for {}.", ctx.did);
180180+ // Don't persist session — the account dir is gone.
181181+ return Ok(refreshed.and(None));
182182+ }
183183+184184+ Ok(session::refreshed_session(&client))
185185+ }
186186+}