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; 9 10use clap::{Parser, Subcommand}; 11use jacquard::CowStr; 12use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession, AgentSession}; 13use jacquard::oauth::client::OAuthClient; 14use jacquard::oauth::loopback::LoopbackConfig; 15use jacquard::prelude::IdentityResolver; 16use jacquard_common::types::string::{Datetime, Rkey, RecordKey}; 17use jacquard_common::types::blob::MimeType; 18use miette::IntoDiagnostic; 19use std::path::{Path, PathBuf}; 20use std::collections::HashMap; 21use flate2::Compression; 22use flate2::write::GzEncoder; 23use std::io::Write; 24use base64::Engine; 25use futures::stream::{self, StreamExt}; 26 27use place_wisp::fs::*; 28 29#[derive(Parser, Debug)] 30#[command(author, version, about = "wisp.place CLI tool")] 31struct Args { 32 #[command(subcommand)] 33 command: Option<Commands>, 34 35 // Deploy arguments (when no subcommand is specified) 36 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 37 #[arg(global = true, conflicts_with = "command")] 38 input: Option<CowStr<'static>>, 39 40 /// Path to the directory containing your static site 41 #[arg(short, long, global = true, conflicts_with = "command")] 42 path: Option<PathBuf>, 43 44 /// Site name (defaults to directory name) 45 #[arg(short, long, global = true, conflicts_with = "command")] 46 site: Option<String>, 47 48 /// Path to auth store file 49 #[arg(long, global = true, conflicts_with = "command")] 50 store: Option<String>, 51 52 /// App Password for authentication 53 #[arg(long, global = true, conflicts_with = "command")] 54 password: Option<CowStr<'static>>, 55} 56 57#[derive(Subcommand, Debug)] 58enum Commands { 59 /// Deploy a static site to wisp.place (default command) 60 Deploy { 61 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 62 input: CowStr<'static>, 63 64 /// Path to the directory containing your static site 65 #[arg(short, long, default_value = ".")] 66 path: PathBuf, 67 68 /// Site name (defaults to directory name) 69 #[arg(short, long)] 70 site: Option<String>, 71 72 /// Path to auth store file (will be created if missing, only used with OAuth) 73 #[arg(long, default_value = "/tmp/wisp-oauth-session.json")] 74 store: String, 75 76 /// App Password for authentication (alternative to OAuth) 77 #[arg(long)] 78 password: Option<CowStr<'static>>, 79 }, 80 /// Pull a site from the PDS to a local directory 81 Pull { 82 /// Handle (e.g., alice.bsky.social) or DID 83 input: CowStr<'static>, 84 85 /// Site name (record key) 86 #[arg(short, long)] 87 site: String, 88 89 /// Output directory for the downloaded site 90 #[arg(short, long, default_value = ".")] 91 output: PathBuf, 92 }, 93 /// Serve a site locally with real-time firehose updates 94 Serve { 95 /// Handle (e.g., alice.bsky.social) or DID 96 input: CowStr<'static>, 97 98 /// Site name (record key) 99 #[arg(short, long)] 100 site: String, 101 102 /// Output directory for the site files 103 #[arg(short, long, default_value = ".")] 104 output: PathBuf, 105 106 /// Port to serve on 107 #[arg(short, long, default_value = "8080")] 108 port: u16, 109 }, 110} 111 112#[tokio::main] 113async fn main() -> miette::Result<()> { 114 let args = Args::parse(); 115 116 match args.command { 117 Some(Commands::Deploy { input, path, site, store, password }) => { 118 // Dispatch to appropriate authentication method 119 if let Some(password) = password { 120 run_with_app_password(input, password, path, site).await 121 } else { 122 run_with_oauth(input, store, path, site).await 123 } 124 } 125 Some(Commands::Pull { input, site, output }) => { 126 pull::pull_site(input, CowStr::from(site), output).await 127 } 128 Some(Commands::Serve { input, site, output, port }) => { 129 serve::serve_site(input, CowStr::from(site), output, port).await 130 } 131 None => { 132 // Legacy mode: if input is provided, assume deploy command 133 if let Some(input) = args.input { 134 let path = args.path.unwrap_or_else(|| PathBuf::from(".")); 135 let store = args.store.unwrap_or_else(|| "/tmp/wisp-oauth-session.json".to_string()); 136 137 // Dispatch to appropriate authentication method 138 if let Some(password) = args.password { 139 run_with_app_password(input, password, path, args.site).await 140 } else { 141 run_with_oauth(input, store, path, args.site).await 142 } 143 } else { 144 // No command and no input, show help 145 use clap::CommandFactory; 146 Args::command().print_help().into_diagnostic()?; 147 Ok(()) 148 } 149 } 150 } 151} 152 153/// Run deployment with app password authentication 154async fn run_with_app_password( 155 input: CowStr<'static>, 156 password: CowStr<'static>, 157 path: PathBuf, 158 site: Option<String>, 159) -> miette::Result<()> { 160 let (session, auth) = 161 MemoryCredentialSession::authenticated(input, password, None).await?; 162 println!("Signed in as {}", auth.handle); 163 164 let agent: Agent<_> = Agent::from(session); 165 deploy_site(&agent, path, site).await 166} 167 168/// Run deployment with OAuth authentication 169async fn run_with_oauth( 170 input: CowStr<'static>, 171 store: String, 172 path: PathBuf, 173 site: Option<String>, 174) -> miette::Result<()> { 175 let oauth = OAuthClient::with_default_config(FileAuthStore::new(&store)); 176 let session = oauth 177 .login_with_local_server(input, Default::default(), LoopbackConfig::default()) 178 .await?; 179 180 let agent: Agent<_> = Agent::from(session); 181 deploy_site(&agent, path, site).await 182} 183 184/// Deploy the site using the provided agent 185async fn deploy_site( 186 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>, 187 path: PathBuf, 188 site: Option<String>, 189) -> miette::Result<()> { 190 // Verify the path exists 191 if !path.exists() { 192 return Err(miette::miette!("Path does not exist: {}", path.display())); 193 } 194 195 // Get site name 196 let site_name = site.unwrap_or_else(|| { 197 path 198 .file_name() 199 .and_then(|n| n.to_str()) 200 .unwrap_or("site") 201 .to_string() 202 }); 203 204 println!("Deploying site '{}'...", site_name); 205 206 // Try to fetch existing manifest for incremental updates 207 let existing_blob_map: HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)> = { 208 use jacquard_common::types::string::AtUri; 209 210 // Get the DID for this session 211 let session_info = agent.session_info().await; 212 if let Some((did, _)) = session_info { 213 // Construct the AT URI for the record 214 let uri_string = format!("at://{}/place.wisp.fs/{}", did, site_name); 215 if let Ok(uri) = AtUri::new(&uri_string) { 216 match agent.get_record::<Fs>(&uri).await { 217 Ok(response) => { 218 match response.into_output() { 219 Ok(record_output) => { 220 let existing_manifest = record_output.value; 221 let blob_map = blob_map::extract_blob_map(&existing_manifest.root); 222 println!("Found existing manifest with {} files, checking for changes...", blob_map.len()); 223 blob_map 224 } 225 Err(_) => { 226 println!("No existing manifest found, uploading all files..."); 227 HashMap::new() 228 } 229 } 230 } 231 Err(_) => { 232 // Record doesn't exist yet - this is a new site 233 println!("No existing manifest found, uploading all files..."); 234 HashMap::new() 235 } 236 } 237 } else { 238 println!("No existing manifest found (invalid URI), uploading all files..."); 239 HashMap::new() 240 } 241 } else { 242 println!("No existing manifest found (could not get DID), uploading all files..."); 243 HashMap::new() 244 } 245 }; 246 247 // Build directory tree 248 let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map, String::new()).await?; 249 let uploaded_count = total_files - reused_count; 250 251 // Create the Fs record 252 let fs_record = Fs::new() 253 .site(CowStr::from(site_name.clone())) 254 .root(root_dir) 255 .file_count(total_files as i64) 256 .created_at(Datetime::now()) 257 .build(); 258 259 // Use site name as the record key 260 let rkey = Rkey::new(&site_name).map_err(|e| miette::miette!("Invalid rkey: {}", e))?; 261 let output = agent.put_record(RecordKey::from(rkey), fs_record).await?; 262 263 // Extract DID from the AT URI (format: at://did:plc:xxx/collection/rkey) 264 let uri_str = output.uri.to_string(); 265 let did = uri_str 266 .strip_prefix("at://") 267 .and_then(|s| s.split('/').next()) 268 .ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?; 269 270 println!("\n✓ Deployed site '{}': {}", site_name, output.uri); 271 println!(" Total files: {} ({} reused, {} uploaded)", total_files, reused_count, uploaded_count); 272 println!(" Available at: https://sites.wisp.place/{}/{}", did, site_name); 273 274 Ok(()) 275} 276 277/// Recursively build a Directory from a filesystem path 278/// current_path is the path from the root of the site (e.g., "" for root, "config" for config dir) 279fn build_directory<'a>( 280 agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>, 281 dir_path: &'a Path, 282 existing_blobs: &'a HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, 283 current_path: String, 284) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<(Directory<'static>, usize, usize)>> + 'a>> 285{ 286 Box::pin(async move { 287 // Collect all directory entries first 288 let dir_entries: Vec<_> = std::fs::read_dir(dir_path) 289 .into_diagnostic()? 290 .collect::<Result<Vec<_>, _>>() 291 .into_diagnostic()?; 292 293 // Separate files and directories 294 let mut file_tasks = Vec::new(); 295 let mut dir_tasks = Vec::new(); 296 297 for entry in dir_entries { 298 let path = entry.path(); 299 let name = entry.file_name(); 300 let name_str = name.to_str() 301 .ok_or_else(|| miette::miette!("Invalid filename: {:?}", name))? 302 .to_string(); 303 304 // Skip .git directories 305 if name_str == ".git" { 306 continue; 307 } 308 309 let metadata = entry.metadata().into_diagnostic()?; 310 311 if metadata.is_file() { 312 // Construct full path for this file (for blob map lookup) 313 let full_path = if current_path.is_empty() { 314 name_str.clone() 315 } else { 316 format!("{}/{}", current_path, name_str) 317 }; 318 file_tasks.push((name_str, path, full_path)); 319 } else if metadata.is_dir() { 320 dir_tasks.push((name_str, path)); 321 } 322 } 323 324 // Process files concurrently with a limit of 5 325 let file_results: Vec<(Entry<'static>, bool)> = stream::iter(file_tasks) 326 .map(|(name, path, full_path)| async move { 327 let (file_node, reused) = process_file(agent, &path, &full_path, existing_blobs).await?; 328 let entry = Entry::new() 329 .name(CowStr::from(name)) 330 .node(EntryNode::File(Box::new(file_node))) 331 .build(); 332 Ok::<_, miette::Report>((entry, reused)) 333 }) 334 .buffer_unordered(5) 335 .collect::<Vec<_>>() 336 .await 337 .into_iter() 338 .collect::<miette::Result<Vec<_>>>()?; 339 340 let mut file_entries = Vec::new(); 341 let mut reused_count = 0; 342 let mut total_files = 0; 343 344 for (entry, reused) in file_results { 345 file_entries.push(entry); 346 total_files += 1; 347 if reused { 348 reused_count += 1; 349 } 350 } 351 352 // Process directories recursively (sequentially to avoid too much nesting) 353 let mut dir_entries = Vec::new(); 354 for (name, path) in dir_tasks { 355 // Construct full path for subdirectory 356 let subdir_path = if current_path.is_empty() { 357 name.clone() 358 } else { 359 format!("{}/{}", current_path, name) 360 }; 361 let (subdir, sub_total, sub_reused) = build_directory(agent, &path, existing_blobs, subdir_path).await?; 362 dir_entries.push(Entry::new() 363 .name(CowStr::from(name)) 364 .node(EntryNode::Directory(Box::new(subdir))) 365 .build()); 366 total_files += sub_total; 367 reused_count += sub_reused; 368 } 369 370 // Combine file and directory entries 371 let mut entries = file_entries; 372 entries.extend(dir_entries); 373 374 let directory = Directory::new() 375 .r#type(CowStr::from("directory")) 376 .entries(entries) 377 .build(); 378 379 Ok((directory, total_files, reused_count)) 380 }) 381} 382 383/// Process a single file: gzip -> base64 -> upload blob (or reuse existing) 384/// Returns (File, reused: bool) 385/// file_path_key is the full path from the site root (e.g., "config/file.json") for blob map lookup 386async fn process_file( 387 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>, 388 file_path: &Path, 389 file_path_key: &str, 390 existing_blobs: &HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, 391) -> miette::Result<(File<'static>, bool)> 392{ 393 // Read file 394 let file_data = std::fs::read(file_path).into_diagnostic()?; 395 396 // Detect original MIME type 397 let original_mime = mime_guess::from_path(file_path) 398 .first_or_octet_stream() 399 .to_string(); 400 401 // Gzip compress 402 let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); 403 encoder.write_all(&file_data).into_diagnostic()?; 404 let gzipped = encoder.finish().into_diagnostic()?; 405 406 // Base64 encode the gzipped data 407 let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes(); 408 409 // Compute CID for this file (CRITICAL: on base64-encoded gzipped content) 410 let file_cid = cid::compute_cid(&base64_bytes); 411 412 // Check if we have an existing blob with the same CID 413 let existing_blob = existing_blobs.get(file_path_key); 414 415 if let Some((existing_blob_ref, existing_cid)) = existing_blob { 416 if existing_cid == &file_cid { 417 // CIDs match - reuse existing blob 418 println!(" ✓ Reusing blob for {} (CID: {})", file_path_key, file_cid); 419 return Ok(( 420 File::new() 421 .r#type(CowStr::from("file")) 422 .blob(existing_blob_ref.clone()) 423 .encoding(CowStr::from("gzip")) 424 .mime_type(CowStr::from(original_mime)) 425 .base64(true) 426 .build(), 427 true 428 )); 429 } 430 } 431 432 // File is new or changed - upload it 433 println!(" ↑ Uploading {} ({} bytes, CID: {})", file_path_key, base64_bytes.len(), file_cid); 434 let blob = agent.upload_blob( 435 base64_bytes, 436 MimeType::new_static("application/octet-stream"), 437 ).await?; 438 439 Ok(( 440 File::new() 441 .r#type(CowStr::from("file")) 442 .blob(blob) 443 .encoding(CowStr::from("gzip")) 444 .mime_type(CowStr::from(original_mime)) 445 .base64(true) 446 .build(), 447 false 448 )) 449} 450