An encrypted personal cloud built on the AT Protocol.

Add --dir flag to mkdir, fix root path resolution, and allow recursive root deletion

mkdir now supports --dir to create directories inside other directories,
with duplicate name detection. resolve() handles "/" as the root directory.
rm -yr / deletes all contents recursively. upload-test-data.sh rewritten to
create nested directory structures and handle filenames with spaces.

[CL-198] [CL-200] [CL-201] [CL-202]

sans-self.org 8a58e1c9 b9176fce

Waiting for spindle ...
+119 -47
+3
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html)s 13 13 14 14 ### Added 15 + - Add --dir flag to mkdir for nested directory creation [#198](https://issues.opake.app/issues/198.html) 15 16 - Add purge command to delete all Opake data from PDS [#196](https://issues.opake.app/issues/196.html) 16 17 - Add metadata CLI command for rename, tag, and description management [#190](https://issues.opake.app/issues/190.html) 17 18 - Consolidate DNS and transport into opake-core, unify handle resolution [#185](https://issues.opake.app/issues/185.html) ··· 63 64 - Update login command to read password from stdin [#112](https://issues.opake.app/issues/112.html) 64 65 65 66 ### Fixed 67 + - Fix rm -yr / failing with empty path error on root directory [#202](https://issues.opake.app/issues/202.html) 68 + - Fix mkdir creating duplicate directories with the same name [#201](https://issues.opake.app/issues/201.html) 66 69 - Fix token refresh not triggering on HTTP 401 ExpiredToken responses [#197](https://issues.opake.app/issues/197.html) 67 70 - Require directory on upload, default to root when --dir is omitted [#192](https://issues.opake.app/issues/192.html) 68 71 - Fix bugs found during black-box integration testing of sharing workflow [#70](https://issues.opake.app/issues/70.html)
+42 -3
crates/opake-cli/src/commands/mkdir.rs
··· 3 3 use clap::Args; 4 4 use opake_core::client::Session; 5 5 use opake_core::crypto::OsRng; 6 - use opake_core::directories; 6 + use opake_core::directories::{self, DirectoryTree, EntryKind}; 7 + use opake_core::error::Error; 7 8 8 9 use crate::commands::{encrypt_directory, Execute}; 9 10 use crate::identity; ··· 14 15 pub struct MkdirCommand { 15 16 /// Name for the directory 16 17 name: String, 18 + 19 + /// Parent directory (path, name, or AT-URI). Defaults to root. 20 + #[arg(long)] 21 + dir: Option<String>, 17 22 } 18 23 19 24 impl Execute for MkdirCommand { ··· 21 26 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 22 27 let id = identity::load_identity(&ctx.storage, &ctx.did)?; 23 28 let pubkey = id.public_key_bytes()?; 29 + let private_key = id.private_key_bytes()?; 24 30 let now = Utc::now().to_rfc3339(); 25 31 26 32 let (root_enc, root_meta) = encrypt_directory("/", &ctx.did, &pubkey, &mut OsRng)?; ··· 28 34 directories::get_or_create_root(&mut client, &ctx.did, root_enc, root_meta, &now) 29 35 .await?; 30 36 37 + let mut tree = DirectoryTree::load(&mut client).await?; 38 + tree.decrypt_names(&ctx.did, &private_key); 39 + 40 + let (parent_uri, parent_label) = if let Some(dir_path) = &self.dir { 41 + let resolved = tree.resolve(&mut client, dir_path).await?; 42 + 43 + if resolved.kind != EntryKind::Directory { 44 + anyhow::bail!("{dir_path:?} is not a directory"); 45 + } 46 + (resolved.uri, dir_path.as_str()) 47 + } else { 48 + (root_uri, "/") 49 + }; 50 + 51 + // Check for existing child directory with the same name. 52 + // Only NotFound means the name is free — AmbiguousName or Ok both mean it's taken. 53 + let full_path = if parent_label == "/" { 54 + self.name.clone() 55 + } else { 56 + format!("{}/{}", parent_label, self.name) 57 + }; 58 + match tree.resolve(&mut client, &full_path).await { 59 + Err(Error::NotFound(_)) => {} 60 + Ok(_) | Err(Error::AmbiguousName { .. }) => { 61 + anyhow::bail!( 62 + "directory {:?} already exists in {}", 63 + self.name, 64 + parent_label 65 + ); 66 + } 67 + Err(e) => return Err(e.into()), 68 + } 69 + 31 70 let (dir_enc, dir_meta) = encrypt_directory(&self.name, &ctx.did, &pubkey, &mut OsRng)?; 32 71 let directory_uri = 33 72 directories::create_directory(&mut client, dir_enc, dir_meta, &now).await?; 34 - directories::add_entry(&mut client, &root_uri, &directory_uri, &now).await?; 73 + directories::add_entry(&mut client, &parent_uri, &directory_uri, &now).await?; 35 74 36 - println!("{} → {}", self.name, directory_uri); 75 + println!("{} → {} (in {})", self.name, directory_uri, parent_label); 37 76 38 77 Ok(session::refreshed_session(&client)) 39 78 }
+7 -5
crates/opake-core/src/directories/remove.rs
··· 70 70 ) -> Result<RemoveResult, Error> { 71 71 let at_uri = atproto::parse_at_uri(&target.uri)?; 72 72 73 - if at_uri.rkey == ROOT_DIRECTORY_RKEY { 74 - return Err(Error::InvalidRecord( 75 - "cannot delete the root directory".into(), 76 - )); 77 - } 73 + let is_root = at_uri.rkey == ROOT_DIRECTORY_RKEY; 78 74 79 75 let (child_docs, child_dirs) = tree.count_descendants(&target.uri); 80 76 let is_empty = child_docs == 0 && child_dirs == 0; 77 + 78 + if is_root && !recursive { 79 + return Err(Error::InvalidRecord( 80 + "cannot delete the root directory — use -r to delete all contents".into(), 81 + )); 82 + } 81 83 82 84 if !is_empty && !recursive { 83 85 return Err(Error::InvalidRecord(format!(
+25 -3
crates/opake-core/src/directories/remove_tests.rs
··· 264 264 assert_eq!(result.directories_deleted, 2); // Vacation + Photos 265 265 } 266 266 267 - // -- root deletion guard -- 267 + // -- root deletion -- 268 268 269 269 #[tokio::test] 270 - async fn remove_root_rejected() { 270 + async fn remove_root_without_recursive_rejected() { 271 271 let mock = MockTransport::new(); 272 272 let (mut client, tree) = setup_simple(&mock).await; 273 273 274 274 let resolved = tree.resolve_at_uri(&mut client, ROOT_URI).await.unwrap(); 275 275 276 - let err = remove(&mut client, &tree, &resolved, true, "2026-03-01T12:00:00Z") 276 + let err = remove(&mut client, &tree, &resolved, false, "2026-03-01T12:00:00Z") 277 277 .await 278 278 .unwrap_err(); 279 279 280 280 assert!(err.to_string().contains("root directory")); 281 + assert!(err.to_string().contains("-r")); 282 + } 283 + 284 + #[tokio::test] 285 + async fn remove_root_recursive_deletes_everything() { 286 + let mock = MockTransport::new(); 287 + let (mut client, tree) = setup_simple(&mock).await; 288 + 289 + let resolved = tree.resolve_at_uri(&mut client, ROOT_URI).await.unwrap(); 290 + 291 + // Post-order: beach.jpg (doc), Photos (dir), notes.txt (doc), then root itself 292 + mock.enqueue(delete_ok()); // beach.jpg 293 + mock.enqueue(delete_ok()); // Photos 294 + mock.enqueue(delete_ok()); // notes.txt 295 + mock.enqueue(delete_ok()); // root "self" 296 + 297 + let result = remove(&mut client, &tree, &resolved, true, "2026-03-01T12:00:00Z") 298 + .await 299 + .unwrap(); 300 + 301 + assert_eq!(result.documents_deleted, 2); // beach.jpg + notes.txt 302 + assert_eq!(result.directories_deleted, 2); // Photos + root 281 303 }
+13
crates/opake-core/src/directories/tree.rs
··· 199 199 return self.resolve_at_uri(client, reference).await; 200 200 } 201 201 202 + // "/" refers to the root directory. 203 + if reference.chars().all(|c| c == '/') && !reference.is_empty() { 204 + let root_uri = self.root_uri.as_ref().ok_or_else(|| { 205 + Error::NotFound("no root directory — run `opake mkdir` first".into()) 206 + })?; 207 + return Ok(ResolvedPath { 208 + uri: root_uri.clone(), 209 + kind: EntryKind::Directory, 210 + name: ROOT_DIRECTORY_NAME.into(), 211 + parent_uri: None, 212 + }); 213 + } 214 + 202 215 if reference.contains('/') { 203 216 return self.resolve_path(client, reference).await; 204 217 }
+29 -36
tools/upload-test-data.sh
··· 2 2 3 3 # upload-test-data.sh 4 4 # 5 - # A script to populate your Opake vault with the sample data from test-data/. 6 - # Use this to quickly see how Opake handles nested structures and different 7 - # file types. 8 - # 9 - # NOTE: Currently, the Opake CLI's `mkdir` and `upload --dir` commands have 10 - # limited support for nested paths. This script works best with single-level 11 - # directories. 5 + # Populate your Opake vault with the sample data from test-data/. 6 + # Creates the full nested directory structure, then uploads all files 7 + # into their correct locations. 12 8 13 9 set -e 14 10 ··· 44 40 exit 1 45 41 fi 46 42 47 - # Check if logged in by checking for a DID in the accounts list 48 43 if ! $OPAKE_BIN accounts | grep -q "did:plc"; then 49 44 echo -e "${YELLOW}No accounts found.${RESET} Please run 'opake login' first." 50 45 exit 1 ··· 52 47 53 48 log "Starting test data upload..." 54 49 55 - # --- Create Top-Level Directories --- 50 + # --- Create Directories (depth-first, parents before children) --- 56 51 57 - # Find first-level directories in test-data/ 58 - find "$TEST_DATA_DIR" -maxdepth 1 -type d -not -path "$TEST_DATA_DIR" | while read -r dir_path; do 59 - rel_dir=$(basename "$dir_path") 60 - log "Ensuring directory exists: /$rel_dir" 61 - # mkdir might fail if it exists, so we ignore errors here 62 - $OPAKE_BIN mkdir "$rel_dir" 2>/dev/null || warn "Directory '$rel_dir' might already exist." 52 + # Sort by depth so parent directories are created before their children. 53 + find "$TEST_DATA_DIR" -type d -not -path "$TEST_DATA_DIR" | awk -F/ '{print NF, $0}' | sort -n | cut -d' ' -f2- | while read -r dir_path; do 54 + rel_path=${dir_path#$TEST_DATA_DIR/} 55 + dir_name=$(basename "$rel_path") 56 + parent_dir=$(dirname "$rel_path") 57 + 58 + if [ "$parent_dir" = "." ]; then 59 + log "Creating directory: /$dir_name" 60 + $OPAKE_BIN mkdir "$dir_name" 2>/dev/null || warn "Directory '$dir_name' might already exist." 61 + else 62 + log "Creating directory: /$rel_path" 63 + $OPAKE_BIN mkdir "$dir_name" --dir "$parent_dir" 2>/dev/null || warn "Directory '$rel_path' might already exist." 64 + fi 63 65 done 64 66 65 - # --- Upload Files --- 67 + # --- Upload Files (sequential — PDS uses repo-level optimistic locking) --- 66 68 67 - # Find all files in test-data/ and upload them 68 - # We use -mindepth 1 to avoid the test-data directory itself 69 - find "$TEST_DATA_DIR" -type f | while read -r file_path; do 70 - # Get relative path within test-data/ 69 + log "Uploading files..." 70 + 71 + find "$TEST_DATA_DIR" -type f -print0 | while IFS= read -r -d '' file_path; do 71 72 rel_path=${file_path#$TEST_DATA_DIR/} 72 - 73 - # Extract filename and its parent directory 74 73 filename=$(basename "$file_path") 75 74 parent_dir=$(dirname "$rel_path") 76 - 77 - # If the file is in a nested directory (e.g., notes/anarchy-and-praxis), 78 - # we currently upload it to the top-level parent because the CLI 79 - # doesn't support recursive mkdir or nested --dir resolution well yet. 80 - top_level_parent=$(echo "$parent_dir" | cut -d'/' -f1) 81 75 82 - if [ "$parent_dir" == "." ]; then 83 - log "Uploading $filename to root..." 84 - $OPAKE_BIN upload "$file_path" 76 + if [ "$parent_dir" = "." ]; then 77 + $OPAKE_BIN upload "$file_path" && \ 78 + success "$filename → /" || \ 79 + warn "$filename (failed)" 85 80 else 86 - log "Uploading $filename to /$top_level_parent..." 87 - # We use the top_level_parent to ensure it goes into an existing folder 88 - $OPAKE_BIN upload "$file_path" --dir "$top_level_parent" 81 + $OPAKE_BIN upload "$file_path" --dir "$parent_dir" && \ 82 + success "$filename → /$parent_dir" || \ 83 + warn "$filename (failed)" 89 84 fi 90 - 91 - success "Uploaded $filename" 92 85 done 93 86 94 - echo -e "\n${GREEN}${BOLD}All test data uploaded! ✨${RESET}" 87 + echo -e "\n${GREEN}${BOLD}All test data uploaded!${RESET}" 95 88 echo -e "Try running ${BOLD}opake tree${RESET} to see your new files."