Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
86
fork

Configure Feed

Select the types of activity you want to include in your feed.

start work on actual production backend

+644 -31
-2
api.md
··· 37 37 * 38 38 * Routes: 39 39 * GET /wisp/sites - List all sites for authenticated user 40 - * GET /wisp/fs/:site - Get site record (metadata/manifest) 41 - * GET /wisp/fs/:site/file/* - Get individual file content by path 42 40 * POST /wisp/upload-files - Upload and deploy files as a site 43 41 */
+2 -2
lexicons/fs.json
··· 18 18 }, 19 19 "file": { 20 20 "type": "object", 21 - "required": ["type", "hash"], 21 + "required": ["type", "blob"], 22 22 "properties": { 23 23 "type": { "type": "string", "const": "file" }, 24 - "hash": { "type": "string", "description": "Content blob hash" } 24 + "blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000, "description": "Content blob ref" } 25 25 } 26 26 }, 27 27 "directory": {
+123
public/editor/editor.tsx
··· 1 + import { useState, useRef } from 'react' 2 + import { createRoot } from 'react-dom/client' 3 + 4 + import Layout from '@public/layouts' 5 + 6 + function Editor() { 7 + const [uploading, setUploading] = useState(false) 8 + const [result, setResult] = useState<any>(null) 9 + const [error, setError] = useState<string | null>(null) 10 + const folderInputRef = useRef<HTMLInputElement>(null) 11 + const siteNameRef = useRef<HTMLInputElement>(null) 12 + 13 + const handleFileUpload = async (e: React.FormEvent) => { 14 + e.preventDefault() 15 + setError(null) 16 + setResult(null) 17 + 18 + const files = folderInputRef.current?.files 19 + const siteName = siteNameRef.current?.value 20 + 21 + if (!files || files.length === 0) { 22 + setError('Please select a folder to upload') 23 + return 24 + } 25 + 26 + if (!siteName) { 27 + setError('Please enter a site name') 28 + return 29 + } 30 + 31 + setUploading(true) 32 + 33 + try { 34 + const formData = new FormData() 35 + formData.append('siteName', siteName) 36 + 37 + for (let i = 0; i < files.length; i++) { 38 + formData.append('files', files[i]) 39 + } 40 + 41 + const response = await fetch('/wisp/upload-files', { 42 + method: 'POST', 43 + body: formData 44 + }) 45 + 46 + if (!response.ok) { 47 + throw new Error(`Upload failed: ${response.statusText}`) 48 + } 49 + 50 + const data = await response.json() 51 + setResult(data) 52 + } catch (err) { 53 + setError(err instanceof Error ? err.message : 'Upload failed') 54 + } finally { 55 + setUploading(false) 56 + } 57 + } 58 + 59 + return ( 60 + <div className="w-full max-w-2xl mx-auto p-6"> 61 + <h1 className="text-3xl font-bold mb-6 text-center">Upload Folder</h1> 62 + 63 + <form onSubmit={handleFileUpload} className="space-y-4"> 64 + <div> 65 + <label htmlFor="siteName" className="block text-sm font-medium mb-2"> 66 + Site Name 67 + </label> 68 + <input 69 + ref={siteNameRef} 70 + type="text" 71 + id="siteName" 72 + placeholder="Enter site name" 73 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 74 + /> 75 + </div> 76 + 77 + <div> 78 + <label htmlFor="folder" className="block text-sm font-medium mb-2"> 79 + Select Folder 80 + </label> 81 + <input 82 + ref={folderInputRef} 83 + type="file" 84 + id="folder" 85 + {...({ webkitdirectory: '', directory: '' } as any)} 86 + multiple 87 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 88 + /> 89 + </div> 90 + 91 + <button 92 + type="submit" 93 + disabled={uploading} 94 + className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-semibold py-2 px-4 rounded-md transition-colors" 95 + > 96 + {uploading ? 'Uploading...' : 'Upload Folder'} 97 + </button> 98 + </form> 99 + 100 + {error && ( 101 + <div className="mt-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded-md"> 102 + {error} 103 + </div> 104 + )} 105 + 106 + {result && ( 107 + <div className="mt-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded-md"> 108 + <h3 className="font-semibold mb-2">Upload Successful!</h3> 109 + <p>Files uploaded: {result.fileCount}</p> 110 + <p>Site name: {result.siteName}</p> 111 + <p>URI: {result.uri}</p> 112 + </div> 113 + )} 114 + </div> 115 + ) 116 + } 117 + 118 + const root = createRoot(document.getElementById('elysia')!) 119 + root.render( 120 + <Layout className="gap-6"> 121 + <Editor /> 122 + </Layout> 123 + )
+12
public/editor/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Elysia Static</title> 7 + </head> 8 + <body> 9 + <div id="elysia"></div> 10 + <script type="module" src="./editor.tsx"></script> 11 + </body> 12 + </html>
+4 -21
src/index.ts
··· 10 10 getOAuthClient, 11 11 getCurrentKeys 12 12 } from './lib/oauth-client' 13 + import { authRoutes } from './routes/auth' 14 + import { wispRoutes } from './routes/wisp' 13 15 14 16 const config: Config = { 15 17 domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`, ··· 29 31 prefix: '/' 30 32 }) 31 33 ) 32 - .post('/api/auth/signin', async (c) => { 33 - try { 34 - const { handle } = await c.request.json() 35 - const state = crypto.randomUUID() 36 - const url = await client.authorize(handle, { state }) 37 - return { url: url.toString() } 38 - } catch (err) { 39 - console.error('Signin error', err) 40 - return { error: 'Authentication failed' } 41 - } 42 - }) 43 - .get('/api/auth/callback', async (c) => { 44 - const params = new URLSearchParams(c.query) 45 - const { session } = await client.callback(params) 46 - if (!session) return { error: 'Authentication failed' } 47 - 48 - const cookieSession = c.cookie 49 - cookieSession.did.value = session.did 50 - 51 - return c.redirect('/') 52 - }) 34 + .use(authRoutes(client)) 35 + .use(wispRoutes(client)) 53 36 .get('/client-metadata.json', (c) => { 54 37 return createClientMetadata(config) 55 38 })
+6 -4
src/lexicon/lexicons.ts
··· 42 42 }, 43 43 file: { 44 44 type: 'object', 45 - required: ['type', 'hash'], 45 + required: ['type', 'blob'], 46 46 properties: { 47 47 type: { 48 48 type: 'string', 49 49 const: 'file', 50 50 }, 51 - hash: { 52 - type: 'string', 53 - description: 'Content blob hash', 51 + blob: { 52 + type: 'blob', 53 + accept: ['*/*'], 54 + maxSize: 1000000, 55 + description: 'Content blob ref', 54 56 }, 55 57 }, 56 58 },
+2 -2
src/lexicon/types/place/wisp/fs.ts
··· 32 32 export interface File { 33 33 $type?: 'place.wisp.fs#file' 34 34 type: 'file' 35 - /** Content blob hash */ 36 - hash: string 35 + /** Content blob ref */ 36 + blob: BlobRef 37 37 } 38 38 39 39 const hashFile = 'file'
+37
src/lib/wisp-auth.ts
··· 1 + import { Did } from "@atproto/api"; 2 + import { NodeOAuthClient } from "@atproto/oauth-client-node"; 3 + import type { OAuthSession } from "@atproto/oauth-client-node"; 4 + import { Cookie } from "elysia"; 5 + 6 + 7 + export interface AuthenticatedContext { 8 + did: Did; 9 + session: OAuthSession; 10 + } 11 + 12 + export const authenticateRequest = async ( 13 + client: NodeOAuthClient, 14 + cookies: Record<string, Cookie<unknown>> 15 + ): Promise<AuthenticatedContext | null> => { 16 + try { 17 + const did = cookies.did?.value as Did; 18 + if (!did) return null; 19 + 20 + const session = await client.restore(did, "auto"); 21 + return session ? { did, session } : null; 22 + } catch (err) { 23 + console.error('Authentication error:', err); 24 + return null; 25 + } 26 + }; 27 + 28 + export const requireAuth = async ( 29 + client: NodeOAuthClient, 30 + cookies: Record<string, Cookie<unknown>> 31 + ): Promise<AuthenticatedContext> => { 32 + const auth = await authenticateRequest(client, cookies); 33 + if (!auth) { 34 + throw new Error('Authentication required'); 35 + } 36 + return auth; 37 + };
+203
src/lib/wisp-utils.ts
··· 1 + import type { BlobRef } from "@atproto/api"; 2 + import type { Record, Directory, File, Entry } from "../lexicon/types/place/wisp/fs"; 3 + 4 + export interface UploadedFile { 5 + name: string; 6 + content: Buffer; 7 + mimeType: string; 8 + size: number; 9 + } 10 + 11 + export interface FileUploadResult { 12 + hash: string; 13 + blobRef: BlobRef; 14 + } 15 + 16 + export interface ProcessedDirectory { 17 + directory: Directory; 18 + fileCount: number; 19 + } 20 + 21 + /** 22 + * Process uploaded files into a directory structure 23 + */ 24 + export function processUploadedFiles(files: UploadedFile[]): ProcessedDirectory { 25 + console.log(`🏗️ Processing ${files.length} uploaded files`); 26 + const entries: Entry[] = []; 27 + let fileCount = 0; 28 + 29 + // Group files by directory 30 + const directoryMap = new Map<string, UploadedFile[]>(); 31 + 32 + for (const file of files) { 33 + // Remove any base folder name from the path 34 + const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 35 + const parts = normalizedPath.split('/'); 36 + 37 + console.log(`📄 Processing file: ${file.name} -> normalized: ${normalizedPath}`); 38 + 39 + if (parts.length === 1) { 40 + // Root level file 41 + console.log(`📁 Root level file: ${parts[0]}`); 42 + entries.push({ 43 + name: parts[0], 44 + node: { 45 + $type: 'place.wisp.fs#file' as const, 46 + type: 'file' as const, 47 + blob: undefined as any // Will be filled after upload 48 + } 49 + }); 50 + fileCount++; 51 + } else { 52 + // File in subdirectory 53 + const dirPath = parts.slice(0, -1).join('/'); 54 + console.log(`📂 Subdirectory file: ${dirPath}/${parts[parts.length - 1]}`); 55 + if (!directoryMap.has(dirPath)) { 56 + directoryMap.set(dirPath, []); 57 + console.log(`➕ Created directory: ${dirPath}`); 58 + } 59 + directoryMap.get(dirPath)!.push({ 60 + ...file, 61 + name: normalizedPath 62 + }); 63 + } 64 + } 65 + 66 + // Process subdirectories 67 + console.log(`📂 Processing ${directoryMap.size} subdirectories`); 68 + for (const [dirPath, dirFiles] of directoryMap) { 69 + console.log(`📁 Processing directory: ${dirPath} with ${dirFiles.length} files`); 70 + const dirEntries: Entry[] = []; 71 + 72 + for (const file of dirFiles) { 73 + const fileName = file.name.split('/').pop()!; 74 + console.log(` 📄 Adding file to directory: ${fileName}`); 75 + dirEntries.push({ 76 + name: fileName, 77 + node: { 78 + $type: 'place.wisp.fs#file' as const, 79 + type: 'file' as const, 80 + blob: undefined as any // Will be filled after upload 81 + } 82 + }); 83 + fileCount++; 84 + } 85 + 86 + // Build nested directory structure 87 + const pathParts = dirPath.split('/'); 88 + let currentEntries = entries; 89 + 90 + console.log(`🏗️ Building nested structure for path: ${pathParts.join('/')}`); 91 + 92 + for (let i = 0; i < pathParts.length; i++) { 93 + const part = pathParts[i]; 94 + const isLast = i === pathParts.length - 1; 95 + 96 + let existingEntry = currentEntries.find(e => e.name === part); 97 + 98 + if (!existingEntry) { 99 + const newDir = { 100 + $type: 'place.wisp.fs#directory' as const, 101 + type: 'directory' as const, 102 + entries: isLast ? dirEntries : [] 103 + }; 104 + 105 + existingEntry = { 106 + name: part, 107 + node: newDir 108 + }; 109 + currentEntries.push(existingEntry); 110 + console.log(` ➕ Created directory entry: ${part}`); 111 + } else if ('entries' in existingEntry.node && isLast) { 112 + (existingEntry.node as any).entries.push(...dirEntries); 113 + console.log(` 📝 Added files to existing directory: ${part}`); 114 + } 115 + 116 + if (existingEntry && 'entries' in existingEntry.node) { 117 + currentEntries = (existingEntry.node as any).entries; 118 + } 119 + } 120 + } 121 + 122 + console.log(`✅ Directory structure completed with ${fileCount} total files`); 123 + 124 + const result = { 125 + directory: { 126 + $type: 'place.wisp.fs#directory' as const, 127 + type: 'directory' as const, 128 + entries 129 + }, 130 + fileCount 131 + }; 132 + 133 + console.log('📋 Final directory structure:', JSON.stringify(result, null, 2)); 134 + return result; 135 + } 136 + 137 + /** 138 + * Create the manifest record for a site 139 + */ 140 + export function createManifest( 141 + siteName: string, 142 + root: Directory, 143 + fileCount: number 144 + ): Record { 145 + const manifest: Record = { 146 + $type: 'place.wisp.fs' as const, 147 + site: siteName, 148 + root, 149 + fileCount, 150 + createdAt: new Date().toISOString() 151 + }; 152 + 153 + console.log(`📋 Created manifest for site "${siteName}" with ${fileCount} files`); 154 + console.log('📄 Manifest structure:', JSON.stringify(manifest, null, 2)); 155 + 156 + return manifest; 157 + } 158 + 159 + /** 160 + * Update file blobs in directory structure after upload 161 + */ 162 + export function updateFileBlobs( 163 + directory: Directory, 164 + uploadResults: FileUploadResult[], 165 + filePaths: string[] 166 + ): Directory { 167 + console.log(`🔄 Updating file blobs: ${uploadResults.length} results for ${filePaths.length} paths`); 168 + 169 + const updatedEntries = directory.entries.map(entry => { 170 + if ('type' in entry.node && entry.node.type === 'file') { 171 + const fileIndex = filePaths.findIndex(path => path.endsWith(entry.name)); 172 + if (fileIndex !== -1 && uploadResults[fileIndex]) { 173 + console.log(` 🔗 Updating blob for file: ${entry.name} -> ${uploadResults[fileIndex].hash}`); 174 + return { 175 + ...entry, 176 + node: { 177 + $type: 'place.wisp.fs#file' as const, 178 + type: 'file' as const, 179 + blob: uploadResults[fileIndex].blobRef 180 + } 181 + }; 182 + } else { 183 + console.warn(` ⚠️ Could not find upload result for file: ${entry.name}`); 184 + } 185 + } else if ('type' in entry.node && entry.node.type === 'directory') { 186 + console.log(` 📂 Recursively updating directory: ${entry.name}`); 187 + return { 188 + ...entry, 189 + node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths) 190 + }; 191 + } 192 + return entry; 193 + }) as Entry[]; 194 + 195 + const result = { 196 + $type: 'place.wisp.fs#directory' as const, 197 + type: 'directory' as const, 198 + entries: updatedEntries 199 + }; 200 + 201 + console.log('✅ File blobs updated'); 202 + return result; 203 + }
+25
src/routes/auth.ts
··· 1 + import { Elysia } from 'elysia' 2 + import { NodeOAuthClient } from '@atproto/oauth-client-node' 3 + 4 + export const authRoutes = (client: NodeOAuthClient) => new Elysia() 5 + .post('/api/auth/signin', async (c) => { 6 + try { 7 + const { handle } = await c.request.json() 8 + const state = crypto.randomUUID() 9 + const url = await client.authorize(handle, { state }) 10 + return { url: url.toString() } 11 + } catch (err) { 12 + console.error('Signin error', err) 13 + return { error: 'Authentication failed' } 14 + } 15 + }) 16 + .get('/api/auth/callback', async (c) => { 17 + const params = new URLSearchParams(c.query) 18 + const { session } = await client.callback(params) 19 + if (!session) return { error: 'Authentication failed' } 20 + 21 + const cookieSession = c.cookie 22 + cookieSession.did.value = session.did 23 + 24 + return c.redirect('/editor') 25 + })
+230
src/routes/wisp.ts
··· 1 + import { Elysia } from 'elysia' 2 + import { requireAuth, type AuthenticatedContext } from '../lib/wisp-auth' 3 + import { NodeOAuthClient } from '@atproto/oauth-client-node' 4 + import { Agent } from '@atproto/api' 5 + import { 6 + type UploadedFile, 7 + type FileUploadResult, 8 + processUploadedFiles, 9 + createManifest, 10 + updateFileBlobs 11 + } from '../lib/wisp-utils' 12 + 13 + export const wispRoutes = (client: NodeOAuthClient) => 14 + new Elysia({ prefix: '/wisp' }) 15 + .derive(async ({ cookie }) => { 16 + const auth = await requireAuth(client, cookie) 17 + return { auth } 18 + }) 19 + .post( 20 + '/upload-files', 21 + async ({ body, auth }) => { 22 + const { siteName, files } = body as { 23 + siteName: string; 24 + files: File | File[] 25 + }; 26 + 27 + console.log('🚀 Starting upload process', { siteName, fileCount: Array.isArray(files) ? files.length : 1 }); 28 + 29 + try { 30 + if (!files || (Array.isArray(files) ? files.length === 0 : !files)) { 31 + console.error('❌ No files provided'); 32 + throw new Error('No files provided') 33 + } 34 + 35 + if (!siteName) { 36 + console.error('❌ Site name is required'); 37 + throw new Error('Site name is required') 38 + } 39 + 40 + console.log('✅ Initial validation passed'); 41 + 42 + // Create agent with OAuth session 43 + console.log('🔐 Creating agent with OAuth session'); 44 + const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) 45 + console.log('✅ Agent created successfully'); 46 + 47 + // Convert File objects to UploadedFile format 48 + // Elysia gives us File objects directly, handle both single file and array 49 + const fileArray = Array.isArray(files) ? files : [files]; 50 + console.log(`📁 Processing ${fileArray.length} files`); 51 + const uploadedFiles: UploadedFile[] = []; 52 + 53 + // Define allowed file extensions for static site hosting 54 + const allowedExtensions = new Set([ 55 + // HTML 56 + '.html', '.htm', 57 + // CSS 58 + '.css', 59 + // JavaScript 60 + '.js', '.mjs', '.jsx', '.ts', '.tsx', 61 + // Images 62 + '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif', 63 + // Fonts 64 + '.woff', '.woff2', '.ttf', '.otf', '.eot', 65 + // Documents 66 + '.pdf', '.txt', 67 + // JSON (for config files, but not .map files) 68 + '.json', 69 + // Audio/Video 70 + '.mp3', '.mp4', '.webm', '.ogg', '.wav', 71 + // Other web assets 72 + '.xml', '.rss', '.atom' 73 + ]); 74 + 75 + // Files to explicitly exclude 76 + const excludedFiles = new Set([ 77 + '.map', '.DS_Store', 'Thumbs.db' 78 + ]); 79 + 80 + for (let i = 0; i < fileArray.length; i++) { 81 + const file = fileArray[i]; 82 + const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); 83 + 84 + console.log(`📄 Processing file ${i + 1}/${fileArray.length}: ${file.name} (${file.size} bytes, ${file.type})`); 85 + 86 + // Skip excluded files 87 + if (excludedFiles.has(fileExtension)) { 88 + console.log(`⏭️ Skipping excluded file: ${file.name}`); 89 + continue; 90 + } 91 + 92 + // Skip files that aren't in allowed extensions 93 + if (!allowedExtensions.has(fileExtension)) { 94 + console.log(`⏭️ Skipping non-web file: ${file.name} (${fileExtension})`); 95 + continue; 96 + } 97 + 98 + // Skip files that are too large (limit to 100MB per file) 99 + const maxSize = 100 * 1024 * 1024; // 100MB 100 + if (file.size > maxSize) { 101 + console.log(`⏭️ Skipping large file: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB > 100MB limit)`); 102 + continue; 103 + } 104 + 105 + console.log(`✅ Including file: ${file.name}`); 106 + const arrayBuffer = await file.arrayBuffer(); 107 + uploadedFiles.push({ 108 + name: file.name, 109 + content: Buffer.from(arrayBuffer), 110 + mimeType: file.type || 'application/octet-stream', 111 + size: file.size 112 + }); 113 + } 114 + 115 + // Check total size limit (300MB) 116 + const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0); 117 + const maxTotalSize = 300 * 1024 * 1024; // 300MB 118 + 119 + console.log(`📊 Filtered to ${uploadedFiles.length} files from ${fileArray.length} total files`); 120 + console.log(`📦 Total size: ${(totalSize / 1024 / 1024).toFixed(2)}MB (limit: 300MB)`); 121 + 122 + if (totalSize > maxTotalSize) { 123 + throw new Error(`Total upload size ${(totalSize / 1024 / 1024).toFixed(2)}MB exceeds 300MB limit`); 124 + } 125 + 126 + if (uploadedFiles.length === 0) { 127 + throw new Error('No valid web files found to upload. Allowed types: HTML, CSS, JS, images, fonts, PDFs, and other web assets.'); 128 + } 129 + 130 + console.log('✅ File conversion completed'); 131 + 132 + // Process files into directory structure 133 + console.log('🏗️ Building directory structure'); 134 + const { directory, fileCount } = processUploadedFiles(uploadedFiles); 135 + console.log(`✅ Directory structure created with ${fileCount} files`); 136 + 137 + // Upload files as blobs 138 + const uploadResults: FileUploadResult[] = []; 139 + const filePaths: string[] = []; 140 + 141 + console.log('⬆️ Starting blob upload process'); 142 + for (let i = 0; i < uploadedFiles.length; i++) { 143 + const file = uploadedFiles[i]; 144 + console.log(`📤 Uploading blob ${i + 1}/${uploadedFiles.length}: ${file.name}`); 145 + 146 + try { 147 + console.log(`🔍 Upload details:`, { 148 + fileName: file.name, 149 + fileSize: file.size, 150 + mimeType: file.mimeType, 151 + contentLength: file.content.length 152 + }); 153 + 154 + const uploadResult = await agent.com.atproto.repo.uploadBlob( 155 + file.content, 156 + { 157 + encoding: file.mimeType 158 + } 159 + ); 160 + 161 + console.log(`✅ Upload successful for ${file.name}:`, { 162 + hash: uploadResult.data.blob.ref.toString(), 163 + mimeType: uploadResult.data.blob.mimeType, 164 + size: uploadResult.data.blob.size 165 + }); 166 + 167 + uploadResults.push({ 168 + hash: uploadResult.data.blob.ref.toString(), 169 + blobRef: uploadResult.data.blob 170 + }); 171 + 172 + filePaths.push(file.name); 173 + } catch (uploadError) { 174 + console.error(`❌ Upload failed for file ${file.name}:`, uploadError); 175 + console.error('Upload error details:', { 176 + fileName: file.name, 177 + fileSize: file.size, 178 + mimeType: file.mimeType, 179 + error: uploadError 180 + }); 181 + throw uploadError; 182 + } 183 + } 184 + 185 + console.log('✅ All blobs uploaded successfully'); 186 + 187 + // Update directory with file blobs 188 + console.log('🔄 Updating file blobs in directory structure'); 189 + const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths); 190 + console.log('✅ File blobs updated'); 191 + 192 + // Create manifest 193 + console.log('📋 Creating manifest'); 194 + const manifest = createManifest(siteName, updatedDirectory, fileCount); 195 + console.log('✅ Manifest created'); 196 + 197 + // Create the record 198 + console.log('📝 Creating record in repo'); 199 + const record = await agent.com.atproto.repo.createRecord({ 200 + repo: auth.did, 201 + collection: 'place.wisp.fs', 202 + record: manifest 203 + }); 204 + 205 + console.log('✅ Record created successfully:', { 206 + uri: record.data.uri, 207 + cid: record.data.cid 208 + }); 209 + 210 + const result = { 211 + success: true, 212 + uri: record.data.uri, 213 + cid: record.data.cid, 214 + fileCount, 215 + siteName 216 + }; 217 + 218 + console.log('🎉 Upload process completed successfully'); 219 + return result; 220 + } catch (error) { 221 + console.error('❌ Upload error:', error); 222 + console.error('Error details:', { 223 + message: error instanceof Error ? error.message : 'Unknown error', 224 + stack: error instanceof Error ? error.stack : undefined, 225 + name: error instanceof Error ? error.name : undefined 226 + }); 227 + throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`); 228 + } 229 + } 230 + )