automerge daemon to keep local files in sync
at main 7.8 kB view raw
1#!/usr/bin/env bun 2 3import { AutomergeUrl, Repo } from "@automerge/automerge-repo" 4import { BrowserWebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket" 5import type { ImportedFile, Folder, FolderItem } from './types'; 6import { Command, createArgument } from 'commander'; 7import ora from 'ora'; 8import path from 'path'; 9import { mkdir, readdir, stat, copyFile, readFile, writeFile } from 'fs/promises'; 10import { cosmiconfig } from 'cosmiconfig'; 11import ignore, { Ignore } from 'ignore'; 12import type { Stats } from 'fs'; 13import isBinaryPath from 'is-binary-path'; 14import mime from "mime-types"; 15 16 17const repo = new Repo({ 18 network: [ 19 new BrowserWebSocketClientAdapter("wss://sync.automerge.org") 20 ] 21}); 22 23interface FileInfo { 24 path: string; 25 relativePath: string; 26 stats: Stats; 27} 28 29interface Config { 30 defaultDestination?: string; 31 defaultSource?: string; 32} 33 34interface CommandOptions { 35 dest?: string; 36} 37 38// Global ignore patterns 39let ig: Ignore; 40 41const initIgnorePatterns = async (ignoreFile = '.gitignore'): Promise<void> => { 42 ig = ignore(); 43 44 // Default patterns 45 ig.add(['node_modules', '.git', '.DS_Store']); 46 47 try { 48 const patterns = await readFile(ignoreFile, 'utf8'); 49 ig.add(patterns); 50 } catch (err) { 51 // If ignore file doesn't exist, just use defaults 52 if (err.code !== 'ENOENT') { 53 console.warn(`Warning: Error reading ignore file: ${err.message}`); 54 } 55 } 56}; 57 58// Configuration explorer 59const explorer = cosmiconfig('filesync'); 60 61const loadConfig = async (): Promise<Config> => { 62 try { 63 const result = await explorer.search(); 64 return result?.config ?? {}; 65 } catch (err) { 66 console.error('Error loading config:', err instanceof Error ? err.message : err); 67 return {}; 68 } 69}; 70 71async function createAutomergeDocuments(startPath: string) { 72 // Create the root folder handle that will accumulate all documents 73 const folderHandle = repo.create<Folder>({ 74 name: path.basename(startPath), 75 contentType: "application/vnd.automerge.folder", 76 contents: [] 77 }) 78 79 async function dfs(currentPath: string, parentHandle = folderHandle) { 80 const stats = await stat(currentPath) 81 82 const relativePath = path.relative(startPath, currentPath) 83 // Skip if path matches gitignore rules 84 if (relativePath && ig.ignores(relativePath)) { 85 console.log("ignoring: " + currentPath) 86 return parentHandle 87 } 88 89 console.log("recursing: " + currentPath) 90 91 if (stats.isFile()) { 92 const fileHandle = repo.create<ImportedFile>() 93 94 const isBinary = isBinaryPath(currentPath); 95 const buffer = await readFile(currentPath); 96 const mimeType = mime.lookup(currentPath); 97 98 99 console.log({ currentPath, mimeType, isBinary }) 100 101 if (isBinary) { 102 fileHandle.change(d => { 103 d.contents = Uint8Array.from(buffer) 104 d.contentType = mimeType || "application/octet-stream" 105 d.name = path.basename(currentPath) 106 d.executable = !!(stats.mode & 0o111) 107 }) 108 } else { 109 const contents = await readFile(currentPath, 'utf-8') 110 fileHandle.change(d => { 111 d.contents = contents 112 d.contentType = mimeType || "text/plain" 113 d.name = path.basename(currentPath) 114 d.executable = !!(stats.mode & 0o111) 115 }) 116 } 117 118 parentHandle.change(d => { 119 d.contents.push({ 120 name: path.basename(currentPath), 121 automergeUrl: fileHandle.url 122 }) 123 }) 124 125 return parentHandle 126 } 127 128 if (stats.isDirectory()) { 129 const dirHandle = repo.create<Folder>({ 130 name: path.basename(currentPath), 131 contentType: "application/vnd.automerge.folder", 132 contents: [] 133 }) 134 135 const children = await readdir(currentPath) 136 137 for (const child of children) { 138 await dfs(path.join(currentPath, child), dirHandle) 139 } 140 141 parentHandle.change(d => { 142 d.contents.push({ 143 name: path.basename(currentPath), 144 automergeUrl: dirHandle.url 145 }) 146 }) 147 148 return parentHandle 149 } 150 } 151 152 await dfs(startPath) 153 return folderHandle 154} 155 156async function downloadAutomergeDocuments( 157 rootUrl: AutomergeUrl, 158 outputPath: string 159) { 160 console.log(rootUrl) 161 const rootHandle = repo.find<Folder | ImportedFile>(rootUrl) 162 const rootDoc = await rootHandle.doc() 163 console.log(rootHandle.state) 164 console.log(rootDoc) 165 166 async function downloadItem(doc: Folder | ImportedFile, currentPath: string) { 167 // TODO: 168 // We need to check mimetypes 169 if ('contents' in doc && Array.isArray(doc.contents)) { 170 // This is a folder 171 const folderPath = path.join(currentPath, doc.name) 172 console.log(folderPath) 173 await mkdir(folderPath, { recursive: true }) 174 175 // Recursively process all items in the folder 176 for (const item of doc.contents) { 177 const itemHandle = repo.find(item.automergeUrl) 178 const itemDoc = await itemHandle.doc() 179 await downloadItem(itemDoc, folderPath) 180 } 181 } else { 182 // This is a file 183 const filePath = path.join(currentPath, doc.name) 184 185 if (typeof doc.contents === 'string') { 186 await writeFile(filePath, doc.contents, 'utf-8') 187 } else if (doc.contents instanceof Uint8Array) { 188 await writeFile(filePath, doc.contents) 189 } 190 191 //if (doc.executable) { 192 // await chmod(filePath, 0o755) 193 //} 194 } 195 } 196 197 await downloadItem(rootDoc, outputPath) 198} 199 200 201async function* walk(dir: string, root = dir): AsyncGenerator<FileInfo> { 202 const files = await readdir(dir); 203 204 for (const file of files) { 205 const filepath = path.join(dir, file); 206 const relativePath = path.relative(root, filepath); 207 208 // Skip if file matches ignore patterns 209 if (ig.ignores(relativePath)) { 210 continue; 211 } 212 213 const stats = await stat(filepath); 214 215 if (stats.isDirectory()) { 216 yield* walk(filepath, root); 217 } else { 218 yield { 219 path: filepath, 220 relativePath, 221 stats 222 }; 223 } 224 } 225} 226 227const processFile = async (fileInfo: FileInfo, destDir: string): Promise<string> => { 228 const destPath = path.join(destDir, fileInfo.relativePath); 229 console.log({ destPath, fileInfo }) 230 // await mkdir(path.dirname(destPath), { recursive: true }); 231 // await copyFile(fileInfo.path, destPath); 232 return destPath; 233}; 234 235 236const pull = async (source: string, path: string): Promise<void> => { 237 console.log(`Listing all files in ${source}:`); 238 const s = <AutomergeUrl>(source) 239 240 try { 241 const folderHandle = await downloadAutomergeDocuments(s, path.dest) 242 repo.shutdown() 243 } catch (err) { 244 console.error('List failed:', err instanceof Error ? err.message : err); 245 process.exit(1); 246 } 247}; 248 249const push = async (source: string): Promise<void> => { 250 console.log(`Listing all files in ${source}:`); 251 252 try { 253 const folderHandle = await createAutomergeDocuments(source) 254 console.log(folderHandle.url) 255 repo.shutdown() 256 } catch (err) { 257 console.error('List failed:', err instanceof Error ? err.message : err); 258 process.exit(1); 259 } 260}; 261 262 263const program = new Command(); 264 265// Global ignore file option 266program 267 .name('filesync') 268 .description('CLI to sync files between directories') 269 .version('0.1.0') 270 .option('-i, --ignore <path>', 'Path to ignore file (defaults to .gitignore)'); 271 272// Initialize ignore patterns before running commands 273program.hook('preAction', async () => { 274 await initIgnorePatterns(program.opts().ignore); 275}); 276 277program.command('pull') 278 .description('Pull files from source') 279 .argument('<source>', 'Source Automerge URL') 280 .option('-d, --dest <path>', 'Destination directory') 281 .action(pull); 282 283program.command('push') 284 .description('Push all files in directory into Automerge') 285 .argument('<source>', 'Source directory') 286 .action(push); 287 288program.parse();