Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1mod builder_types; 2mod place_wisp; 3mod cid; 4mod blob_map; 5mod metadata; 6mod download; 7mod pull; 8mod serve; 9mod subfs_utils; 10 11use clap::{Parser, Subcommand}; 12use jacquard::CowStr; 13use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession, AgentSession}; 14use jacquard::oauth::client::OAuthClient; 15use jacquard::oauth::loopback::LoopbackConfig; 16use jacquard::prelude::IdentityResolver; 17use jacquard_common::types::string::{Datetime, Rkey, RecordKey}; 18use jacquard_common::types::blob::MimeType; 19use miette::IntoDiagnostic; 20use std::path::{Path, PathBuf}; 21use std::collections::HashMap; 22use flate2::Compression; 23use flate2::write::GzEncoder; 24use std::io::Write; 25use base64::Engine; 26use futures::stream::{self, StreamExt}; 27 28use place_wisp::fs::*; 29 30#[derive(Parser, Debug)] 31#[command(author, version, about = "wisp.place CLI tool")] 32struct Args { 33 #[command(subcommand)] 34 command: Option<Commands>, 35 36 // Deploy arguments (when no subcommand is specified) 37 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 38 #[arg(global = true, conflicts_with = "command")] 39 input: Option<CowStr<'static>>, 40 41 /// Path to the directory containing your static site 42 #[arg(short, long, global = true, conflicts_with = "command")] 43 path: Option<PathBuf>, 44 45 /// Site name (defaults to directory name) 46 #[arg(short, long, global = true, conflicts_with = "command")] 47 site: Option<String>, 48 49 /// Path to auth store file 50 #[arg(long, global = true, conflicts_with = "command")] 51 store: Option<String>, 52 53 /// App Password for authentication 54 #[arg(long, global = true, conflicts_with = "command")] 55 password: Option<CowStr<'static>>, 56} 57 58#[derive(Subcommand, Debug)] 59enum Commands { 60 /// Deploy a static site to wisp.place (default command) 61 Deploy { 62 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 63 input: CowStr<'static>, 64 65 /// Path to the directory containing your static site 66 #[arg(short, long, default_value = ".")] 67 path: PathBuf, 68 69 /// Site name (defaults to directory name) 70 #[arg(short, long)] 71 site: Option<String>, 72 73 /// Path to auth store file (will be created if missing, only used with OAuth) 74 #[arg(long, default_value = "/tmp/wisp-oauth-session.json")] 75 store: String, 76 77 /// App Password for authentication (alternative to OAuth) 78 #[arg(long)] 79 password: Option<CowStr<'static>>, 80 }, 81 /// Pull a site from the PDS to a local directory 82 Pull { 83 /// Handle (e.g., alice.bsky.social) or DID 84 input: CowStr<'static>, 85 86 /// Site name (record key) 87 #[arg(short, long)] 88 site: String, 89 90 /// Output directory for the downloaded site 91 #[arg(short, long, default_value = ".")] 92 output: PathBuf, 93 }, 94 /// Serve a site locally with real-time firehose updates 95 Serve { 96 /// Handle (e.g., alice.bsky.social) or DID 97 input: CowStr<'static>, 98 99 /// Site name (record key) 100 #[arg(short, long)] 101 site: String, 102 103 /// Output directory for the site files 104 #[arg(short, long, default_value = ".")] 105 output: PathBuf, 106 107 /// Port to serve on 108 #[arg(short, long, default_value = "8080")] 109 port: u16, 110 }, 111} 112 113#[tokio::main] 114async fn main() -> miette::Result<()> { 115 let args = Args::parse(); 116 117 let result = match args.command { 118 Some(Commands::Deploy { input, path, site, store, password }) => { 119 // Dispatch to appropriate authentication method 120 if let Some(password) = password { 121 run_with_app_password(input, password, path, site).await 122 } else { 123 run_with_oauth(input, store, path, site).await 124 } 125 } 126 Some(Commands::Pull { input, site, output }) => { 127 pull::pull_site(input, CowStr::from(site), output).await 128 } 129 Some(Commands::Serve { input, site, output, port }) => { 130 serve::serve_site(input, CowStr::from(site), output, port).await 131 } 132 None => { 133 // Legacy mode: if input is provided, assume deploy command 134 if let Some(input) = args.input { 135 let path = args.path.unwrap_or_else(|| PathBuf::from(".")); 136 let store = args.store.unwrap_or_else(|| "/tmp/wisp-oauth-session.json".to_string()); 137 138 // Dispatch to appropriate authentication method 139 if let Some(password) = args.password { 140 run_with_app_password(input, password, path, args.site).await 141 } else { 142 run_with_oauth(input, store, path, args.site).await 143 } 144 } else { 145 // No command and no input, show help 146 use clap::CommandFactory; 147 Args::command().print_help().into_diagnostic()?; 148 Ok(()) 149 } 150 } 151 }; 152 153 // Force exit to avoid hanging on background tasks/connections 154 match result { 155 Ok(_) => std::process::exit(0), 156 Err(e) => { 157 eprintln!("{:?}", e); 158 std::process::exit(1) 159 } 160 } 161} 162 163/// Run deployment with app password authentication 164async fn run_with_app_password( 165 input: CowStr<'static>, 166 password: CowStr<'static>, 167 path: PathBuf, 168 site: Option<String>, 169) -> miette::Result<()> { 170 let (session, auth) = 171 MemoryCredentialSession::authenticated(input, password, None).await?; 172 println!("Signed in as {}", auth.handle); 173 174 let agent: Agent<_> = Agent::from(session); 175 deploy_site(&agent, path, site).await 176} 177 178/// Run deployment with OAuth authentication 179async fn run_with_oauth( 180 input: CowStr<'static>, 181 store: String, 182 path: PathBuf, 183 site: Option<String>, 184) -> miette::Result<()> { 185 use jacquard::oauth::scopes::Scope; 186 use jacquard::oauth::atproto::AtprotoClientMetadata; 187 use jacquard::oauth::session::ClientData; 188 use url::Url; 189 190 // Request the necessary scopes for wisp.place 191 let scopes = Scope::parse_multiple("atproto repo:place.wisp.fs repo:place.wisp.subfs blob:*/*") 192 .map_err(|e| miette::miette!("Failed to parse scopes: {:?}", e))?; 193 194 // Create redirect URIs that match the loopback server (port 4000, path /oauth/callback) 195 let redirect_uris = vec![ 196 Url::parse("http://127.0.0.1:4000/oauth/callback").into_diagnostic()?, 197 Url::parse("http://[::1]:4000/oauth/callback").into_diagnostic()?, 198 ]; 199 200 // Create client metadata with matching redirect URIs and scopes 201 let client_data = ClientData { 202 keyset: None, 203 config: AtprotoClientMetadata::new_localhost( 204 Some(redirect_uris), 205 Some(scopes), 206 ), 207 }; 208 209 let oauth = OAuthClient::new(FileAuthStore::new(&store), client_data); 210 211 let session = oauth 212 .login_with_local_server(input, Default::default(), LoopbackConfig::default()) 213 .await?; 214 215 let agent: Agent<_> = Agent::from(session); 216 deploy_site(&agent, path, site).await 217} 218 219/// Deploy the site using the provided agent 220async fn deploy_site( 221 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>, 222 path: PathBuf, 223 site: Option<String>, 224) -> miette::Result<()> { 225 // Verify the path exists 226 if !path.exists() { 227 return Err(miette::miette!("Path does not exist: {}", path.display())); 228 } 229 230 // Get site name 231 let site_name = site.unwrap_or_else(|| { 232 path 233 .file_name() 234 .and_then(|n| n.to_str()) 235 .unwrap_or("site") 236 .to_string() 237 }); 238 239 println!("Deploying site '{}'...", site_name); 240 241 // Try to fetch existing manifest for incremental updates 242 let (existing_blob_map, old_subfs_uris): (HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, Vec<(String, String)>) = { 243 use jacquard_common::types::string::AtUri; 244 245 // Get the DID for this session 246 let session_info = agent.session_info().await; 247 if let Some((did, _)) = session_info { 248 // Construct the AT URI for the record 249 let uri_string = format!("at://{}/place.wisp.fs/{}", did, site_name); 250 if let Ok(uri) = AtUri::new(&uri_string) { 251 match agent.get_record::<Fs>(&uri).await { 252 Ok(response) => { 253 match response.into_output() { 254 Ok(record_output) => { 255 let existing_manifest = record_output.value; 256 let mut blob_map = blob_map::extract_blob_map(&existing_manifest.root); 257 println!("Found existing manifest with {} files in main record", blob_map.len()); 258 259 // Extract subfs URIs from main record 260 let subfs_uris = subfs_utils::extract_subfs_uris(&existing_manifest.root, String::new()); 261 262 if !subfs_uris.is_empty() { 263 println!("Found {} subfs records, fetching for blob reuse...", subfs_uris.len()); 264 265 // Merge blob maps from all subfs records 266 match subfs_utils::merge_subfs_blob_maps(agent, subfs_uris.clone(), &mut blob_map).await { 267 Ok(merged_count) => { 268 println!("Total blob map: {} files (main + {} from subfs)", blob_map.len(), merged_count); 269 } 270 Err(e) => { 271 eprintln!("⚠️ Failed to merge some subfs blob maps: {}", e); 272 } 273 } 274 275 (blob_map, subfs_uris) 276 } else { 277 (blob_map, Vec::new()) 278 } 279 } 280 Err(_) => { 281 println!("No existing manifest found, uploading all files..."); 282 (HashMap::new(), Vec::new()) 283 } 284 } 285 } 286 Err(_) => { 287 // Record doesn't exist yet - this is a new site 288 println!("No existing manifest found, uploading all files..."); 289 (HashMap::new(), Vec::new()) 290 } 291 } 292 } else { 293 println!("No existing manifest found (invalid URI), uploading all files..."); 294 (HashMap::new(), Vec::new()) 295 } 296 } else { 297 println!("No existing manifest found (could not get DID), uploading all files..."); 298 (HashMap::new(), Vec::new()) 299 } 300 }; 301 302 // Build directory tree 303 let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map, String::new()).await?; 304 let uploaded_count = total_files - reused_count; 305 306 // Check if we need to split into subfs records 307 const MAX_MANIFEST_SIZE: usize = 140 * 1024; // 140KB (PDS limit is 150KB) 308 const FILE_COUNT_THRESHOLD: usize = 250; // Start splitting at this many files 309 const TARGET_FILE_COUNT: usize = 200; // Keep main manifest under this 310 311 let mut working_directory = root_dir; 312 let mut current_file_count = total_files; 313 let mut new_subfs_uris: Vec<(String, String)> = Vec::new(); 314 315 // Estimate initial manifest size 316 let mut manifest_size = subfs_utils::estimate_directory_size(&working_directory); 317 318 if total_files >= FILE_COUNT_THRESHOLD || manifest_size > MAX_MANIFEST_SIZE { 319 println!("\n⚠️ Large site detected ({} files, {:.1}KB manifest), splitting into subfs records...", 320 total_files, manifest_size as f64 / 1024.0); 321 322 let mut attempts = 0; 323 const MAX_SPLIT_ATTEMPTS: usize = 50; 324 325 while (manifest_size > MAX_MANIFEST_SIZE || current_file_count > TARGET_FILE_COUNT) && attempts < MAX_SPLIT_ATTEMPTS { 326 attempts += 1; 327 328 // Find large directories to split 329 let directories = subfs_utils::find_large_directories(&working_directory, String::new()); 330 331 if let Some(largest_dir) = directories.first() { 332 println!(" Split #{}: {} ({} files, {:.1}KB)", 333 attempts, largest_dir.path, largest_dir.file_count, largest_dir.size as f64 / 1024.0); 334 335 // Create a subfs record for this directory 336 use jacquard_common::types::string::Tid; 337 let subfs_tid = Tid::now_0(); 338 let subfs_rkey = subfs_tid.to_string(); 339 340 let subfs_manifest = crate::place_wisp::subfs::SubfsRecord::new() 341 .root(convert_fs_dir_to_subfs_dir(largest_dir.directory.clone())) 342 .file_count(Some(largest_dir.file_count as i64)) 343 .created_at(Datetime::now()) 344 .build(); 345 346 // Upload subfs record 347 let subfs_output = agent.put_record( 348 RecordKey::from(Rkey::new(&subfs_rkey).into_diagnostic()?), 349 subfs_manifest 350 ).await.into_diagnostic()?; 351 352 let subfs_uri = subfs_output.uri.to_string(); 353 println!(" ✅ Created subfs: {}", subfs_uri); 354 355 // Replace directory with subfs node (flat: false to preserve structure) 356 working_directory = subfs_utils::replace_directory_with_subfs( 357 working_directory, 358 &largest_dir.path, 359 &subfs_uri, 360 false // Preserve directory structure 361 )?; 362 363 new_subfs_uris.push((subfs_uri, largest_dir.path.clone())); 364 current_file_count -= largest_dir.file_count; 365 366 // Recalculate manifest size 367 manifest_size = subfs_utils::estimate_directory_size(&working_directory); 368 println!(" → Manifest now {:.1}KB with {} files ({} subfs total)", 369 manifest_size as f64 / 1024.0, current_file_count, new_subfs_uris.len()); 370 371 if manifest_size <= MAX_MANIFEST_SIZE && current_file_count <= TARGET_FILE_COUNT { 372 println!("✅ Manifest now fits within limits"); 373 break; 374 } 375 } else { 376 println!(" No more subdirectories to split - stopping"); 377 break; 378 } 379 } 380 381 if attempts >= MAX_SPLIT_ATTEMPTS { 382 return Err(miette::miette!( 383 "Exceeded maximum split attempts ({}). Manifest still too large: {:.1}KB with {} files", 384 MAX_SPLIT_ATTEMPTS, 385 manifest_size as f64 / 1024.0, 386 current_file_count 387 )); 388 } 389 390 println!("✅ Split complete: {} subfs records, {} files in main manifest, {:.1}KB", 391 new_subfs_uris.len(), current_file_count, manifest_size as f64 / 1024.0); 392 } else { 393 println!("Manifest created ({} files, {:.1}KB) - no splitting needed", 394 total_files, manifest_size as f64 / 1024.0); 395 } 396 397 // Create the final Fs record 398 let fs_record = Fs::new() 399 .site(CowStr::from(site_name.clone())) 400 .root(working_directory) 401 .file_count(current_file_count as i64) 402 .created_at(Datetime::now()) 403 .build(); 404 405 // Use site name as the record key 406 let rkey = Rkey::new(&site_name).map_err(|e| miette::miette!("Invalid rkey: {}", e))?; 407 let output = agent.put_record(RecordKey::from(rkey), fs_record).await?; 408 409 // Extract DID from the AT URI (format: at://did:plc:xxx/collection/rkey) 410 let uri_str = output.uri.to_string(); 411 let did = uri_str 412 .strip_prefix("at://") 413 .and_then(|s| s.split('/').next()) 414 .ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?; 415 416 println!("\n✓ Deployed site '{}': {}", site_name, output.uri); 417 println!(" Total files: {} ({} reused, {} uploaded)", total_files, reused_count, uploaded_count); 418 println!(" Available at: https://sites.wisp.place/{}/{}", did, site_name); 419 420 // Clean up old subfs records 421 if !old_subfs_uris.is_empty() { 422 println!("\nCleaning up {} old subfs records...", old_subfs_uris.len()); 423 424 let mut deleted_count = 0; 425 let mut failed_count = 0; 426 427 for (uri, _path) in old_subfs_uris { 428 match subfs_utils::delete_subfs_record(agent, &uri).await { 429 Ok(_) => { 430 deleted_count += 1; 431 println!(" 🗑️ Deleted old subfs: {}", uri); 432 } 433 Err(e) => { 434 failed_count += 1; 435 eprintln!(" ⚠️ Failed to delete {}: {}", uri, e); 436 } 437 } 438 } 439 440 if failed_count > 0 { 441 eprintln!("⚠️ Cleanup completed with {} deleted, {} failed", deleted_count, failed_count); 442 } else { 443 println!("✅ Cleanup complete: {} old subfs records deleted", deleted_count); 444 } 445 } 446 447 Ok(()) 448} 449 450/// Recursively build a Directory from a filesystem path 451/// current_path is the path from the root of the site (e.g., "" for root, "config" for config dir) 452fn build_directory<'a>( 453 agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>, 454 dir_path: &'a Path, 455 existing_blobs: &'a HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, 456 current_path: String, 457) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<(Directory<'static>, usize, usize)>> + 'a>> 458{ 459 Box::pin(async move { 460 // Collect all directory entries first 461 let dir_entries: Vec<_> = std::fs::read_dir(dir_path) 462 .into_diagnostic()? 463 .collect::<Result<Vec<_>, _>>() 464 .into_diagnostic()?; 465 466 // Separate files and directories 467 let mut file_tasks = Vec::new(); 468 let mut dir_tasks = Vec::new(); 469 470 for entry in dir_entries { 471 let path = entry.path(); 472 let name = entry.file_name(); 473 let name_str = name.to_str() 474 .ok_or_else(|| miette::miette!("Invalid filename: {:?}", name))? 475 .to_string(); 476 477 // Skip .git directories 478 if name_str == ".git" { 479 continue; 480 } 481 482 let metadata = entry.metadata().into_diagnostic()?; 483 484 if metadata.is_file() { 485 // Construct full path for this file (for blob map lookup) 486 let full_path = if current_path.is_empty() { 487 name_str.clone() 488 } else { 489 format!("{}/{}", current_path, name_str) 490 }; 491 file_tasks.push((name_str, path, full_path)); 492 } else if metadata.is_dir() { 493 dir_tasks.push((name_str, path)); 494 } 495 } 496 497 // Process files concurrently with a limit of 5 498 let file_results: Vec<(Entry<'static>, bool)> = stream::iter(file_tasks) 499 .map(|(name, path, full_path)| async move { 500 let (file_node, reused) = process_file(agent, &path, &full_path, existing_blobs).await?; 501 let entry = Entry::new() 502 .name(CowStr::from(name)) 503 .node(EntryNode::File(Box::new(file_node))) 504 .build(); 505 Ok::<_, miette::Report>((entry, reused)) 506 }) 507 .buffer_unordered(5) 508 .collect::<Vec<_>>() 509 .await 510 .into_iter() 511 .collect::<miette::Result<Vec<_>>>()?; 512 513 let mut file_entries = Vec::new(); 514 let mut reused_count = 0; 515 let mut total_files = 0; 516 517 for (entry, reused) in file_results { 518 file_entries.push(entry); 519 total_files += 1; 520 if reused { 521 reused_count += 1; 522 } 523 } 524 525 // Process directories recursively (sequentially to avoid too much nesting) 526 let mut dir_entries = Vec::new(); 527 for (name, path) in dir_tasks { 528 // Construct full path for subdirectory 529 let subdir_path = if current_path.is_empty() { 530 name.clone() 531 } else { 532 format!("{}/{}", current_path, name) 533 }; 534 let (subdir, sub_total, sub_reused) = build_directory(agent, &path, existing_blobs, subdir_path).await?; 535 dir_entries.push(Entry::new() 536 .name(CowStr::from(name)) 537 .node(EntryNode::Directory(Box::new(subdir))) 538 .build()); 539 total_files += sub_total; 540 reused_count += sub_reused; 541 } 542 543 // Combine file and directory entries 544 let mut entries = file_entries; 545 entries.extend(dir_entries); 546 547 let directory = Directory::new() 548 .r#type(CowStr::from("directory")) 549 .entries(entries) 550 .build(); 551 552 Ok((directory, total_files, reused_count)) 553 }) 554} 555 556/// Process a single file: gzip -> base64 -> upload blob (or reuse existing) 557/// Returns (File, reused: bool) 558/// file_path_key is the full path from the site root (e.g., "config/file.json") for blob map lookup 559async fn process_file( 560 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>, 561 file_path: &Path, 562 file_path_key: &str, 563 existing_blobs: &HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, 564) -> miette::Result<(File<'static>, bool)> 565{ 566 // Read file 567 let file_data = std::fs::read(file_path).into_diagnostic()?; 568 569 // Detect original MIME type 570 let original_mime = mime_guess::from_path(file_path) 571 .first_or_octet_stream() 572 .to_string(); 573 574 // Gzip compress 575 let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); 576 encoder.write_all(&file_data).into_diagnostic()?; 577 let gzipped = encoder.finish().into_diagnostic()?; 578 579 // Base64 encode the gzipped data 580 let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes(); 581 582 // Compute CID for this file (CRITICAL: on base64-encoded gzipped content) 583 let file_cid = cid::compute_cid(&base64_bytes); 584 585 // Check if we have an existing blob with the same CID 586 let existing_blob = existing_blobs.get(file_path_key); 587 588 if let Some((existing_blob_ref, existing_cid)) = existing_blob { 589 if existing_cid == &file_cid { 590 // CIDs match - reuse existing blob 591 println!(" ✓ Reusing blob for {} (CID: {})", file_path_key, file_cid); 592 return Ok(( 593 File::new() 594 .r#type(CowStr::from("file")) 595 .blob(existing_blob_ref.clone()) 596 .encoding(CowStr::from("gzip")) 597 .mime_type(CowStr::from(original_mime)) 598 .base64(true) 599 .build(), 600 true 601 )); 602 } 603 } 604 605 // File is new or changed - upload it 606 println!(" ↑ Uploading {} ({} bytes, CID: {})", file_path_key, base64_bytes.len(), file_cid); 607 let blob = agent.upload_blob( 608 base64_bytes, 609 MimeType::new_static("application/octet-stream"), 610 ).await?; 611 612 Ok(( 613 File::new() 614 .r#type(CowStr::from("file")) 615 .blob(blob) 616 .encoding(CowStr::from("gzip")) 617 .mime_type(CowStr::from(original_mime)) 618 .base64(true) 619 .build(), 620 false 621 )) 622} 623 624/// Convert fs::Directory to subfs::Directory 625/// They have the same structure, but different types 626fn convert_fs_dir_to_subfs_dir(fs_dir: place_wisp::fs::Directory<'static>) -> place_wisp::subfs::Directory<'static> { 627 use place_wisp::subfs::{Directory as SubfsDirectory, Entry as SubfsEntry, EntryNode as SubfsEntryNode, File as SubfsFile}; 628 629 let subfs_entries: Vec<SubfsEntry> = fs_dir.entries.into_iter().map(|entry| { 630 let node = match entry.node { 631 place_wisp::fs::EntryNode::File(file) => { 632 SubfsEntryNode::File(Box::new(SubfsFile::new() 633 .r#type(file.r#type) 634 .blob(file.blob) 635 .encoding(file.encoding) 636 .mime_type(file.mime_type) 637 .base64(file.base64) 638 .build())) 639 } 640 place_wisp::fs::EntryNode::Directory(dir) => { 641 SubfsEntryNode::Directory(Box::new(convert_fs_dir_to_subfs_dir(*dir))) 642 } 643 place_wisp::fs::EntryNode::Subfs(subfs) => { 644 // Nested subfs in the directory we're converting 645 // Note: subfs::Subfs doesn't have the 'flat' field - that's only in fs::Subfs 646 SubfsEntryNode::Subfs(Box::new(place_wisp::subfs::Subfs::new() 647 .r#type(subfs.r#type) 648 .subject(subfs.subject) 649 .build())) 650 } 651 place_wisp::fs::EntryNode::Unknown(unknown) => { 652 SubfsEntryNode::Unknown(unknown) 653 } 654 }; 655 656 SubfsEntry::new() 657 .name(entry.name) 658 .node(node) 659 .build() 660 }).collect(); 661 662 SubfsDirectory::new() 663 .r#type(fs_dir.r#type) 664 .entries(subfs_entries) 665 .build() 666} 667