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 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
154/// Run deployment with app password authentication
155async fn run_with_app_password(
156 input: CowStr<'static>,
157 password: CowStr<'static>,
158 path: PathBuf,
159 site: Option<String>,
160) -> miette::Result<()> {
161 let (session, auth) =
162 MemoryCredentialSession::authenticated(input, password, None).await?;
163 println!("Signed in as {}", auth.handle);
164
165 let agent: Agent<_> = Agent::from(session);
166 deploy_site(&agent, path, site).await
167}
168
169/// Run deployment with OAuth authentication
170async fn run_with_oauth(
171 input: CowStr<'static>,
172 store: String,
173 path: PathBuf,
174 site: Option<String>,
175) -> miette::Result<()> {
176 let oauth = OAuthClient::with_default_config(FileAuthStore::new(&store));
177 let session = oauth
178 .login_with_local_server(input, Default::default(), LoopbackConfig::default())
179 .await?;
180
181 let agent: Agent<_> = Agent::from(session);
182 deploy_site(&agent, path, site).await
183}
184
185/// Deploy the site using the provided agent
186async fn deploy_site(
187 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>,
188 path: PathBuf,
189 site: Option<String>,
190) -> miette::Result<()> {
191 // Verify the path exists
192 if !path.exists() {
193 return Err(miette::miette!("Path does not exist: {}", path.display()));
194 }
195
196 // Get site name
197 let site_name = site.unwrap_or_else(|| {
198 path
199 .file_name()
200 .and_then(|n| n.to_str())
201 .unwrap_or("site")
202 .to_string()
203 });
204
205 println!("Deploying site '{}'...", site_name);
206
207 // Try to fetch existing manifest for incremental updates
208 let (existing_blob_map, old_subfs_uris): (HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, Vec<(String, String)>) = {
209 use jacquard_common::types::string::AtUri;
210
211 // Get the DID for this session
212 let session_info = agent.session_info().await;
213 if let Some((did, _)) = session_info {
214 // Construct the AT URI for the record
215 let uri_string = format!("at://{}/place.wisp.fs/{}", did, site_name);
216 if let Ok(uri) = AtUri::new(&uri_string) {
217 match agent.get_record::<Fs>(&uri).await {
218 Ok(response) => {
219 match response.into_output() {
220 Ok(record_output) => {
221 let existing_manifest = record_output.value;
222 let mut blob_map = blob_map::extract_blob_map(&existing_manifest.root);
223 println!("Found existing manifest with {} files in main record", blob_map.len());
224
225 // Extract subfs URIs from main record
226 let subfs_uris = subfs_utils::extract_subfs_uris(&existing_manifest.root, String::new());
227
228 if !subfs_uris.is_empty() {
229 println!("Found {} subfs records, fetching for blob reuse...", subfs_uris.len());
230
231 // Merge blob maps from all subfs records
232 match subfs_utils::merge_subfs_blob_maps(agent, subfs_uris.clone(), &mut blob_map).await {
233 Ok(merged_count) => {
234 println!("Total blob map: {} files (main + {} from subfs)", blob_map.len(), merged_count);
235 }
236 Err(e) => {
237 eprintln!("⚠️ Failed to merge some subfs blob maps: {}", e);
238 }
239 }
240
241 (blob_map, subfs_uris)
242 } else {
243 (blob_map, Vec::new())
244 }
245 }
246 Err(_) => {
247 println!("No existing manifest found, uploading all files...");
248 (HashMap::new(), Vec::new())
249 }
250 }
251 }
252 Err(_) => {
253 // Record doesn't exist yet - this is a new site
254 println!("No existing manifest found, uploading all files...");
255 (HashMap::new(), Vec::new())
256 }
257 }
258 } else {
259 println!("No existing manifest found (invalid URI), uploading all files...");
260 (HashMap::new(), Vec::new())
261 }
262 } else {
263 println!("No existing manifest found (could not get DID), uploading all files...");
264 (HashMap::new(), Vec::new())
265 }
266 };
267
268 // Build directory tree
269 let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map, String::new()).await?;
270 let uploaded_count = total_files - reused_count;
271
272 // Check if we need to split into subfs records
273 const MAX_MANIFEST_SIZE: usize = 140 * 1024; // 140KB (PDS limit is 150KB)
274 const FILE_COUNT_THRESHOLD: usize = 250; // Start splitting at this many files
275 const TARGET_FILE_COUNT: usize = 200; // Keep main manifest under this
276
277 let mut working_directory = root_dir;
278 let mut current_file_count = total_files;
279 let mut new_subfs_uris: Vec<(String, String)> = Vec::new();
280
281 // Estimate initial manifest size
282 let mut manifest_size = subfs_utils::estimate_directory_size(&working_directory);
283
284 if total_files >= FILE_COUNT_THRESHOLD || manifest_size > MAX_MANIFEST_SIZE {
285 println!("\n⚠️ Large site detected ({} files, {:.1}KB manifest), splitting into subfs records...",
286 total_files, manifest_size as f64 / 1024.0);
287
288 let mut attempts = 0;
289 const MAX_SPLIT_ATTEMPTS: usize = 50;
290
291 while (manifest_size > MAX_MANIFEST_SIZE || current_file_count > TARGET_FILE_COUNT) && attempts < MAX_SPLIT_ATTEMPTS {
292 attempts += 1;
293
294 // Find large directories to split
295 let directories = subfs_utils::find_large_directories(&working_directory, String::new());
296
297 if let Some(largest_dir) = directories.first() {
298 println!(" Split #{}: {} ({} files, {:.1}KB)",
299 attempts, largest_dir.path, largest_dir.file_count, largest_dir.size as f64 / 1024.0);
300
301 // Create a subfs record for this directory
302 use jacquard_common::types::string::Tid;
303 let subfs_tid = Tid::now_0();
304 let subfs_rkey = subfs_tid.to_string();
305
306 let subfs_manifest = crate::place_wisp::subfs::SubfsRecord::new()
307 .root(convert_fs_dir_to_subfs_dir(largest_dir.directory.clone()))
308 .file_count(Some(largest_dir.file_count as i64))
309 .created_at(Datetime::now())
310 .build();
311
312 // Upload subfs record
313 let subfs_output = agent.put_record(
314 RecordKey::from(Rkey::new(&subfs_rkey).into_diagnostic()?),
315 subfs_manifest
316 ).await.into_diagnostic()?;
317
318 let subfs_uri = subfs_output.uri.to_string();
319 println!(" ✅ Created subfs: {}", subfs_uri);
320
321 // Replace directory with subfs node (flat: false to preserve structure)
322 working_directory = subfs_utils::replace_directory_with_subfs(
323 working_directory,
324 &largest_dir.path,
325 &subfs_uri,
326 false // Preserve directory structure
327 )?;
328
329 new_subfs_uris.push((subfs_uri, largest_dir.path.clone()));
330 current_file_count -= largest_dir.file_count;
331
332 // Recalculate manifest size
333 manifest_size = subfs_utils::estimate_directory_size(&working_directory);
334 println!(" → Manifest now {:.1}KB with {} files ({} subfs total)",
335 manifest_size as f64 / 1024.0, current_file_count, new_subfs_uris.len());
336
337 if manifest_size <= MAX_MANIFEST_SIZE && current_file_count <= TARGET_FILE_COUNT {
338 println!("✅ Manifest now fits within limits");
339 break;
340 }
341 } else {
342 println!(" No more subdirectories to split - stopping");
343 break;
344 }
345 }
346
347 if attempts >= MAX_SPLIT_ATTEMPTS {
348 return Err(miette::miette!(
349 "Exceeded maximum split attempts ({}). Manifest still too large: {:.1}KB with {} files",
350 MAX_SPLIT_ATTEMPTS,
351 manifest_size as f64 / 1024.0,
352 current_file_count
353 ));
354 }
355
356 println!("✅ Split complete: {} subfs records, {} files in main manifest, {:.1}KB",
357 new_subfs_uris.len(), current_file_count, manifest_size as f64 / 1024.0);
358 } else {
359 println!("Manifest created ({} files, {:.1}KB) - no splitting needed",
360 total_files, manifest_size as f64 / 1024.0);
361 }
362
363 // Create the final Fs record
364 let fs_record = Fs::new()
365 .site(CowStr::from(site_name.clone()))
366 .root(working_directory)
367 .file_count(current_file_count as i64)
368 .created_at(Datetime::now())
369 .build();
370
371 // Use site name as the record key
372 let rkey = Rkey::new(&site_name).map_err(|e| miette::miette!("Invalid rkey: {}", e))?;
373 let output = agent.put_record(RecordKey::from(rkey), fs_record).await?;
374
375 // Extract DID from the AT URI (format: at://did:plc:xxx/collection/rkey)
376 let uri_str = output.uri.to_string();
377 let did = uri_str
378 .strip_prefix("at://")
379 .and_then(|s| s.split('/').next())
380 .ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?;
381
382 println!("\n✓ Deployed site '{}': {}", site_name, output.uri);
383 println!(" Total files: {} ({} reused, {} uploaded)", total_files, reused_count, uploaded_count);
384 println!(" Available at: https://sites.wisp.place/{}/{}", did, site_name);
385
386 // Clean up old subfs records
387 if !old_subfs_uris.is_empty() {
388 println!("\nCleaning up {} old subfs records...", old_subfs_uris.len());
389
390 let mut deleted_count = 0;
391 let mut failed_count = 0;
392
393 for (uri, _path) in old_subfs_uris {
394 match subfs_utils::delete_subfs_record(agent, &uri).await {
395 Ok(_) => {
396 deleted_count += 1;
397 println!(" 🗑️ Deleted old subfs: {}", uri);
398 }
399 Err(e) => {
400 failed_count += 1;
401 eprintln!(" ⚠️ Failed to delete {}: {}", uri, e);
402 }
403 }
404 }
405
406 if failed_count > 0 {
407 eprintln!("⚠️ Cleanup completed with {} deleted, {} failed", deleted_count, failed_count);
408 } else {
409 println!("✅ Cleanup complete: {} old subfs records deleted", deleted_count);
410 }
411 }
412
413 Ok(())
414}
415
416/// Recursively build a Directory from a filesystem path
417/// current_path is the path from the root of the site (e.g., "" for root, "config" for config dir)
418fn build_directory<'a>(
419 agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>,
420 dir_path: &'a Path,
421 existing_blobs: &'a HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>,
422 current_path: String,
423) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<(Directory<'static>, usize, usize)>> + 'a>>
424{
425 Box::pin(async move {
426 // Collect all directory entries first
427 let dir_entries: Vec<_> = std::fs::read_dir(dir_path)
428 .into_diagnostic()?
429 .collect::<Result<Vec<_>, _>>()
430 .into_diagnostic()?;
431
432 // Separate files and directories
433 let mut file_tasks = Vec::new();
434 let mut dir_tasks = Vec::new();
435
436 for entry in dir_entries {
437 let path = entry.path();
438 let name = entry.file_name();
439 let name_str = name.to_str()
440 .ok_or_else(|| miette::miette!("Invalid filename: {:?}", name))?
441 .to_string();
442
443 // Skip .git directories
444 if name_str == ".git" {
445 continue;
446 }
447
448 let metadata = entry.metadata().into_diagnostic()?;
449
450 if metadata.is_file() {
451 // Construct full path for this file (for blob map lookup)
452 let full_path = if current_path.is_empty() {
453 name_str.clone()
454 } else {
455 format!("{}/{}", current_path, name_str)
456 };
457 file_tasks.push((name_str, path, full_path));
458 } else if metadata.is_dir() {
459 dir_tasks.push((name_str, path));
460 }
461 }
462
463 // Process files concurrently with a limit of 5
464 let file_results: Vec<(Entry<'static>, bool)> = stream::iter(file_tasks)
465 .map(|(name, path, full_path)| async move {
466 let (file_node, reused) = process_file(agent, &path, &full_path, existing_blobs).await?;
467 let entry = Entry::new()
468 .name(CowStr::from(name))
469 .node(EntryNode::File(Box::new(file_node)))
470 .build();
471 Ok::<_, miette::Report>((entry, reused))
472 })
473 .buffer_unordered(5)
474 .collect::<Vec<_>>()
475 .await
476 .into_iter()
477 .collect::<miette::Result<Vec<_>>>()?;
478
479 let mut file_entries = Vec::new();
480 let mut reused_count = 0;
481 let mut total_files = 0;
482
483 for (entry, reused) in file_results {
484 file_entries.push(entry);
485 total_files += 1;
486 if reused {
487 reused_count += 1;
488 }
489 }
490
491 // Process directories recursively (sequentially to avoid too much nesting)
492 let mut dir_entries = Vec::new();
493 for (name, path) in dir_tasks {
494 // Construct full path for subdirectory
495 let subdir_path = if current_path.is_empty() {
496 name.clone()
497 } else {
498 format!("{}/{}", current_path, name)
499 };
500 let (subdir, sub_total, sub_reused) = build_directory(agent, &path, existing_blobs, subdir_path).await?;
501 dir_entries.push(Entry::new()
502 .name(CowStr::from(name))
503 .node(EntryNode::Directory(Box::new(subdir)))
504 .build());
505 total_files += sub_total;
506 reused_count += sub_reused;
507 }
508
509 // Combine file and directory entries
510 let mut entries = file_entries;
511 entries.extend(dir_entries);
512
513 let directory = Directory::new()
514 .r#type(CowStr::from("directory"))
515 .entries(entries)
516 .build();
517
518 Ok((directory, total_files, reused_count))
519 })
520}
521
522/// Process a single file: gzip -> base64 -> upload blob (or reuse existing)
523/// Returns (File, reused: bool)
524/// file_path_key is the full path from the site root (e.g., "config/file.json") for blob map lookup
525async fn process_file(
526 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>,
527 file_path: &Path,
528 file_path_key: &str,
529 existing_blobs: &HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>,
530) -> miette::Result<(File<'static>, bool)>
531{
532 // Read file
533 let file_data = std::fs::read(file_path).into_diagnostic()?;
534
535 // Detect original MIME type
536 let original_mime = mime_guess::from_path(file_path)
537 .first_or_octet_stream()
538 .to_string();
539
540 // Gzip compress
541 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
542 encoder.write_all(&file_data).into_diagnostic()?;
543 let gzipped = encoder.finish().into_diagnostic()?;
544
545 // Base64 encode the gzipped data
546 let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes();
547
548 // Compute CID for this file (CRITICAL: on base64-encoded gzipped content)
549 let file_cid = cid::compute_cid(&base64_bytes);
550
551 // Check if we have an existing blob with the same CID
552 let existing_blob = existing_blobs.get(file_path_key);
553
554 if let Some((existing_blob_ref, existing_cid)) = existing_blob {
555 if existing_cid == &file_cid {
556 // CIDs match - reuse existing blob
557 println!(" ✓ Reusing blob for {} (CID: {})", file_path_key, file_cid);
558 return Ok((
559 File::new()
560 .r#type(CowStr::from("file"))
561 .blob(existing_blob_ref.clone())
562 .encoding(CowStr::from("gzip"))
563 .mime_type(CowStr::from(original_mime))
564 .base64(true)
565 .build(),
566 true
567 ));
568 }
569 }
570
571 // File is new or changed - upload it
572 println!(" ↑ Uploading {} ({} bytes, CID: {})", file_path_key, base64_bytes.len(), file_cid);
573 let blob = agent.upload_blob(
574 base64_bytes,
575 MimeType::new_static("application/octet-stream"),
576 ).await?;
577
578 Ok((
579 File::new()
580 .r#type(CowStr::from("file"))
581 .blob(blob)
582 .encoding(CowStr::from("gzip"))
583 .mime_type(CowStr::from(original_mime))
584 .base64(true)
585 .build(),
586 false
587 ))
588}
589
590/// Convert fs::Directory to subfs::Directory
591/// They have the same structure, but different types
592fn convert_fs_dir_to_subfs_dir(fs_dir: place_wisp::fs::Directory<'static>) -> place_wisp::subfs::Directory<'static> {
593 use place_wisp::subfs::{Directory as SubfsDirectory, Entry as SubfsEntry, EntryNode as SubfsEntryNode, File as SubfsFile};
594
595 let subfs_entries: Vec<SubfsEntry> = fs_dir.entries.into_iter().map(|entry| {
596 let node = match entry.node {
597 place_wisp::fs::EntryNode::File(file) => {
598 SubfsEntryNode::File(Box::new(SubfsFile::new()
599 .r#type(file.r#type)
600 .blob(file.blob)
601 .encoding(file.encoding)
602 .mime_type(file.mime_type)
603 .base64(file.base64)
604 .build()))
605 }
606 place_wisp::fs::EntryNode::Directory(dir) => {
607 SubfsEntryNode::Directory(Box::new(convert_fs_dir_to_subfs_dir(*dir)))
608 }
609 place_wisp::fs::EntryNode::Subfs(subfs) => {
610 // Nested subfs in the directory we're converting
611 // Note: subfs::Subfs doesn't have the 'flat' field - that's only in fs::Subfs
612 SubfsEntryNode::Subfs(Box::new(place_wisp::subfs::Subfs::new()
613 .r#type(subfs.r#type)
614 .subject(subfs.subject)
615 .build()))
616 }
617 place_wisp::fs::EntryNode::Unknown(unknown) => {
618 SubfsEntryNode::Unknown(unknown)
619 }
620 };
621
622 SubfsEntry::new()
623 .name(entry.name)
624 .node(node)
625 .build()
626 }).collect();
627
628 SubfsDirectory::new()
629 .r#type(fs_dir.r#type)
630 .entries(subfs_entries)
631 .build()
632}
633