···3434 let blob_ref = &file_node.blob;
3535 let cid_string = blob_ref.blob().r#ref.to_string();
36363737- // Store both normalized and full paths
3838- // Normalize by removing base folder prefix (e.g., "cobblemon/index.html" -> "index.html")
3939- let normalized_path = normalize_path(&full_path);
4040-3737+ // Store with full path (mirrors TypeScript implementation)
4138 blob_map.insert(
4242- normalized_path.clone(),
4343- (blob_ref.clone().into_static(), cid_string.clone())
3939+ full_path,
4040+ (blob_ref.clone().into_static(), cid_string)
4441 );
4545-4646- // Also store the full path for matching
4747- if normalized_path != full_path {
4848- blob_map.insert(
4949- full_path,
5050- (blob_ref.clone().into_static(), cid_string)
5151- );
5252- }
5342 }
5443 EntryNode::Directory(subdir) => {
5544 let sub_map = extract_blob_map_recursive(subdir, full_path);
···6756/// Normalize file path by removing base folder prefix
6857/// Example: "cobblemon/index.html" -> "index.html"
6958///
7070-/// Mirrors TypeScript implementation at src/routes/wisp.ts line 291
5959+/// Note: This function is kept for reference but is no longer used in production code.
6060+/// The TypeScript server has a similar normalization (src/routes/wisp.ts line 291) to handle
6161+/// uploads that include a base folder prefix, but our CLI doesn't need this since we
6262+/// track full paths consistently.
6363+#[allow(dead_code)]
7164pub fn normalize_path(path: &str) -> String {
7265 // Remove base folder prefix (everything before first /)
7366 if let Some(idx) = path.find('/') {
+24-13
cli/src/main.rs
···152152 };
153153154154 // Build directory tree
155155- let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map).await?;
155155+ let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map, String::new()).await?;
156156 let uploaded_count = total_files - reused_count;
157157158158 // Create the Fs record
···182182}
183183184184/// Recursively build a Directory from a filesystem path
185185+/// current_path is the path from the root of the site (e.g., "" for root, "config" for config dir)
185186fn build_directory<'a>(
186187 agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>,
187188 dir_path: &'a Path,
188189 existing_blobs: &'a HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>,
190190+ current_path: String,
189191) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<(Directory<'static>, usize, usize)>> + 'a>>
190192{
191193 Box::pin(async move {
···214216 let metadata = entry.metadata().into_diagnostic()?;
215217216218 if metadata.is_file() {
217217- file_tasks.push((name_str, path));
219219+ // Construct full path for this file (for blob map lookup)
220220+ let full_path = if current_path.is_empty() {
221221+ name_str.clone()
222222+ } else {
223223+ format!("{}/{}", current_path, name_str)
224224+ };
225225+ file_tasks.push((name_str, path, full_path));
218226 } else if metadata.is_dir() {
219227 dir_tasks.push((name_str, path));
220228 }
···222230223231 // Process files concurrently with a limit of 5
224232 let file_results: Vec<(Entry<'static>, bool)> = stream::iter(file_tasks)
225225- .map(|(name, path)| async move {
226226- let (file_node, reused) = process_file(agent, &path, &name, existing_blobs).await?;
233233+ .map(|(name, path, full_path)| async move {
234234+ let (file_node, reused) = process_file(agent, &path, &full_path, existing_blobs).await?;
227235 let entry = Entry::new()
228236 .name(CowStr::from(name))
229237 .node(EntryNode::File(Box::new(file_node)))
···251259 // Process directories recursively (sequentially to avoid too much nesting)
252260 let mut dir_entries = Vec::new();
253261 for (name, path) in dir_tasks {
254254- let (subdir, sub_total, sub_reused) = build_directory(agent, &path, existing_blobs).await?;
262262+ // Construct full path for subdirectory
263263+ let subdir_path = if current_path.is_empty() {
264264+ name.clone()
265265+ } else {
266266+ format!("{}/{}", current_path, name)
267267+ };
268268+ let (subdir, sub_total, sub_reused) = build_directory(agent, &path, existing_blobs, subdir_path).await?;
255269 dir_entries.push(Entry::new()
256270 .name(CowStr::from(name))
257271 .node(EntryNode::Directory(Box::new(subdir)))
···275289276290/// Process a single file: gzip -> base64 -> upload blob (or reuse existing)
277291/// Returns (File, reused: bool)
292292+/// file_path_key is the full path from the site root (e.g., "config/file.json") for blob map lookup
278293async fn process_file(
279294 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>,
280295 file_path: &Path,
281281- file_name: &str,
296296+ file_path_key: &str,
282297 existing_blobs: &HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>,
283298) -> miette::Result<(File<'static>, bool)>
284299{
···301316 // Compute CID for this file (CRITICAL: on base64-encoded gzipped content)
302317 let file_cid = cid::compute_cid(&base64_bytes);
303318304304- // Normalize the file path for comparison
305305- let normalized_path = blob_map::normalize_path(file_name);
306306-307319 // Check if we have an existing blob with the same CID
308308- let existing_blob = existing_blobs.get(&normalized_path)
309309- .or_else(|| existing_blobs.get(file_name));
320320+ let existing_blob = existing_blobs.get(file_path_key);
310321311322 if let Some((existing_blob_ref, existing_cid)) = existing_blob {
312323 if existing_cid == &file_cid {
313324 // CIDs match - reuse existing blob
314314- println!(" ✓ Reusing blob for {} (CID: {})", file_name, file_cid);
325325+ println!(" ✓ Reusing blob for {} (CID: {})", file_path_key, file_cid);
315326 return Ok((
316327 File::new()
317328 .r#type(CowStr::from("file"))
···326337 }
327338328339 // File is new or changed - upload it
329329- println!(" ↑ Uploading {} ({} bytes, CID: {})", file_name, base64_bytes.len(), file_cid);
340340+ println!(" ↑ Uploading {} ({} bytes, CID: {})", file_path_key, base64_bytes.len(), file_cid);
330341 let blob = agent.upload_blob(
331342 base64_bytes,
332343 MimeType::new_static("application/octet-stream"),