automerge daemon to keep local files in sync
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();