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