pushwork 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/README.md +460 -0
  2. package/dist/browser/browser-sync-engine.d.ts +64 -0
  3. package/dist/browser/browser-sync-engine.d.ts.map +1 -0
  4. package/dist/browser/browser-sync-engine.js +303 -0
  5. package/dist/browser/browser-sync-engine.js.map +1 -0
  6. package/dist/browser/filesystem-adapter.d.ts +84 -0
  7. package/dist/browser/filesystem-adapter.d.ts.map +1 -0
  8. package/dist/browser/filesystem-adapter.js +413 -0
  9. package/dist/browser/filesystem-adapter.js.map +1 -0
  10. package/dist/browser/index.d.ts +36 -0
  11. package/dist/browser/index.d.ts.map +1 -0
  12. package/dist/browser/index.js +90 -0
  13. package/dist/browser/index.js.map +1 -0
  14. package/dist/browser/types.d.ts +70 -0
  15. package/dist/browser/types.d.ts.map +1 -0
  16. package/dist/browser/types.js +6 -0
  17. package/dist/browser/types.js.map +1 -0
  18. package/dist/cli/commands.d.ts +71 -0
  19. package/dist/cli/commands.d.ts.map +1 -0
  20. package/dist/cli/commands.js +794 -0
  21. package/dist/cli/commands.js.map +1 -0
  22. package/dist/cli/index.d.ts +2 -0
  23. package/dist/cli/index.d.ts.map +1 -0
  24. package/dist/cli/index.js +19 -0
  25. package/dist/cli/index.js.map +1 -0
  26. package/dist/cli.d.ts +3 -0
  27. package/dist/cli.d.ts.map +1 -0
  28. package/dist/cli.js +199 -0
  29. package/dist/cli.js.map +1 -0
  30. package/dist/config/index.d.ts +71 -0
  31. package/dist/config/index.d.ts.map +1 -0
  32. package/dist/config/index.js +314 -0
  33. package/dist/config/index.js.map +1 -0
  34. package/dist/core/change-detection.d.ts +78 -0
  35. package/dist/core/change-detection.d.ts.map +1 -0
  36. package/dist/core/change-detection.js +370 -0
  37. package/dist/core/change-detection.js.map +1 -0
  38. package/dist/core/index.d.ts +5 -0
  39. package/dist/core/index.d.ts.map +1 -0
  40. package/dist/core/index.js +22 -0
  41. package/dist/core/index.js.map +1 -0
  42. package/dist/core/isomorphic-snapshot.d.ts +58 -0
  43. package/dist/core/isomorphic-snapshot.d.ts.map +1 -0
  44. package/dist/core/isomorphic-snapshot.js +204 -0
  45. package/dist/core/isomorphic-snapshot.js.map +1 -0
  46. package/dist/core/move-detection.d.ts +72 -0
  47. package/dist/core/move-detection.d.ts.map +1 -0
  48. package/dist/core/move-detection.js +200 -0
  49. package/dist/core/move-detection.js.map +1 -0
  50. package/dist/core/snapshot.d.ts +109 -0
  51. package/dist/core/snapshot.d.ts.map +1 -0
  52. package/dist/core/snapshot.js +263 -0
  53. package/dist/core/snapshot.js.map +1 -0
  54. package/dist/core/sync-engine.d.ts +110 -0
  55. package/dist/core/sync-engine.d.ts.map +1 -0
  56. package/dist/core/sync-engine.js +817 -0
  57. package/dist/core/sync-engine.js.map +1 -0
  58. package/dist/index.d.ts +6 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +27 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/platform/browser-filesystem.d.ts +26 -0
  63. package/dist/platform/browser-filesystem.d.ts.map +1 -0
  64. package/dist/platform/browser-filesystem.js +91 -0
  65. package/dist/platform/browser-filesystem.js.map +1 -0
  66. package/dist/platform/filesystem.d.ts +29 -0
  67. package/dist/platform/filesystem.d.ts.map +1 -0
  68. package/dist/platform/filesystem.js +65 -0
  69. package/dist/platform/filesystem.js.map +1 -0
  70. package/dist/platform/node-filesystem.d.ts +21 -0
  71. package/dist/platform/node-filesystem.d.ts.map +1 -0
  72. package/dist/platform/node-filesystem.js +93 -0
  73. package/dist/platform/node-filesystem.js.map +1 -0
  74. package/dist/types/config.d.ts +119 -0
  75. package/dist/types/config.d.ts.map +1 -0
  76. package/dist/types/config.js +3 -0
  77. package/dist/types/config.js.map +1 -0
  78. package/dist/types/documents.d.ts +70 -0
  79. package/dist/types/documents.d.ts.map +1 -0
  80. package/dist/types/documents.js +23 -0
  81. package/dist/types/documents.js.map +1 -0
  82. package/dist/types/index.d.ts +4 -0
  83. package/dist/types/index.d.ts.map +1 -0
  84. package/dist/types/index.js +23 -0
  85. package/dist/types/index.js.map +1 -0
  86. package/dist/types/snapshot.d.ts +81 -0
  87. package/dist/types/snapshot.d.ts.map +1 -0
  88. package/dist/types/snapshot.js +17 -0
  89. package/dist/types/snapshot.js.map +1 -0
  90. package/dist/utils/content-similarity.d.ts +53 -0
  91. package/dist/utils/content-similarity.d.ts.map +1 -0
  92. package/dist/utils/content-similarity.js +155 -0
  93. package/dist/utils/content-similarity.js.map +1 -0
  94. package/dist/utils/content.d.ts +5 -0
  95. package/dist/utils/content.d.ts.map +1 -0
  96. package/dist/utils/content.js +30 -0
  97. package/dist/utils/content.js.map +1 -0
  98. package/dist/utils/fs-browser.d.ts +57 -0
  99. package/dist/utils/fs-browser.d.ts.map +1 -0
  100. package/dist/utils/fs-browser.js +311 -0
  101. package/dist/utils/fs-browser.js.map +1 -0
  102. package/dist/utils/fs-node.d.ts +53 -0
  103. package/dist/utils/fs-node.d.ts.map +1 -0
  104. package/dist/utils/fs-node.js +220 -0
  105. package/dist/utils/fs-node.js.map +1 -0
  106. package/dist/utils/fs.d.ts +62 -0
  107. package/dist/utils/fs.d.ts.map +1 -0
  108. package/dist/utils/fs.js +293 -0
  109. package/dist/utils/fs.js.map +1 -0
  110. package/dist/utils/index.d.ts +4 -0
  111. package/dist/utils/index.d.ts.map +1 -0
  112. package/dist/utils/index.js +23 -0
  113. package/dist/utils/index.js.map +1 -0
  114. package/dist/utils/isomorphic.d.ts +29 -0
  115. package/dist/utils/isomorphic.d.ts.map +1 -0
  116. package/dist/utils/isomorphic.js +139 -0
  117. package/dist/utils/isomorphic.js.map +1 -0
  118. package/dist/utils/mime-types.d.ts +13 -0
  119. package/dist/utils/mime-types.d.ts.map +1 -0
  120. package/dist/utils/mime-types.js +240 -0
  121. package/dist/utils/mime-types.js.map +1 -0
  122. package/dist/utils/network-sync.d.ts +12 -0
  123. package/dist/utils/network-sync.d.ts.map +1 -0
  124. package/dist/utils/network-sync.js +149 -0
  125. package/dist/utils/network-sync.js.map +1 -0
  126. package/dist/utils/pure.d.ts +25 -0
  127. package/dist/utils/pure.d.ts.map +1 -0
  128. package/dist/utils/pure.js +112 -0
  129. package/dist/utils/pure.js.map +1 -0
  130. package/dist/utils/repo-factory.d.ts +11 -0
  131. package/dist/utils/repo-factory.d.ts.map +1 -0
  132. package/dist/utils/repo-factory.js +77 -0
  133. package/dist/utils/repo-factory.js.map +1 -0
  134. package/package.json +83 -0
  135. package/src/cli/commands.ts +1053 -0
  136. package/src/cli/index.ts +2 -0
  137. package/src/cli.ts +287 -0
  138. package/src/config/index.ts +334 -0
  139. package/src/core/change-detection.ts +484 -0
  140. package/src/core/index.ts +5 -0
  141. package/src/core/move-detection.ts +269 -0
  142. package/src/core/snapshot.ts +285 -0
  143. package/src/core/sync-engine.ts +1167 -0
  144. package/src/index.ts +14 -0
  145. package/src/types/config.ts +130 -0
  146. package/src/types/documents.ts +72 -0
  147. package/src/types/index.ts +8 -0
  148. package/src/types/snapshot.ts +88 -0
  149. package/src/utils/content-similarity.ts +194 -0
  150. package/src/utils/content.ts +28 -0
  151. package/src/utils/fs.ts +289 -0
  152. package/src/utils/index.ts +8 -0
  153. package/src/utils/mime-types.ts +236 -0
  154. package/src/utils/network-sync.ts +153 -0
  155. package/src/utils/repo-factory.ts +58 -0
  156. package/test/README-TESTING-GAPS.md +174 -0
  157. package/test/integration/README.md +328 -0
  158. package/test/integration/clone-test.sh +310 -0
  159. package/test/integration/conflict-resolution-test.sh +309 -0
  160. package/test/integration/deletion-behavior-test.sh +487 -0
  161. package/test/integration/deletion-sync-test-simple.sh +193 -0
  162. package/test/integration/deletion-sync-test.sh +297 -0
  163. package/test/integration/exclude-patterns.test.ts +152 -0
  164. package/test/integration/full-integration-test.sh +363 -0
  165. package/test/integration/sync-deletion.test.ts +339 -0
  166. package/test/integration/sync-flow.test.ts +309 -0
  167. package/test/run-tests.sh +225 -0
  168. package/test/unit/content-similarity.test.ts +236 -0
  169. package/test/unit/deletion-behavior.test.ts +260 -0
  170. package/test/unit/enhanced-mime-detection.test.ts +266 -0
  171. package/test/unit/snapshot.test.ts +431 -0
  172. package/test/unit/sync-timing.test.ts +178 -0
  173. package/test/unit/utils.test.ts +368 -0
  174. package/tools/browser-sync/README.md +116 -0
  175. package/tools/browser-sync/package.json +44 -0
  176. package/tools/browser-sync/patchwork.json +1 -0
  177. package/tools/browser-sync/pnpm-lock.yaml +4202 -0
  178. package/tools/browser-sync/src/components/BrowserSyncTool.tsx +599 -0
  179. package/tools/browser-sync/src/index.ts +20 -0
  180. package/tools/browser-sync/src/polyfills.ts +31 -0
  181. package/tools/browser-sync/src/styles.css +290 -0
  182. package/tools/browser-sync/src/types.ts +27 -0
  183. package/tools/browser-sync/vite.config.ts +25 -0
  184. package/tsconfig.json +22 -0
@@ -0,0 +1,817 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SyncEngine = void 0;
4
+ const automerge_repo_1 = require("@automerge/automerge-repo");
5
+ const types_1 = require("../types");
6
+ const utils_1 = require("../utils");
7
+ const content_1 = require("../utils/content");
8
+ const network_sync_1 = require("../utils/network-sync");
9
+ const snapshot_1 = require("./snapshot");
10
+ const change_detection_1 = require("./change-detection");
11
+ const move_detection_1 = require("./move-detection");
12
+ /**
13
+ * Bidirectional sync engine implementing two-phase sync
14
+ */
15
+ class SyncEngine {
16
+ constructor(repo, rootPath, excludePatterns = [], networkSyncEnabled = true, syncServerStorageId) {
17
+ this.repo = repo;
18
+ this.rootPath = rootPath;
19
+ this.networkSyncEnabled = true;
20
+ this.handlesToWaitOn = [];
21
+ this.snapshotManager = new snapshot_1.SnapshotManager(rootPath);
22
+ this.changeDetector = new change_detection_1.ChangeDetector(repo, rootPath, excludePatterns);
23
+ this.moveDetector = new move_detection_1.MoveDetector();
24
+ this.networkSyncEnabled = networkSyncEnabled;
25
+ this.syncServerStorageId = syncServerStorageId;
26
+ }
27
+ /**
28
+ * Determine if content should be treated as text for Automerge text operations
29
+ * Note: This method checks the runtime type. File type detection happens
30
+ * during reading with isEnhancedTextFile() which now has better dev file support.
31
+ */
32
+ isTextContent(content) {
33
+ // Simply check the actual type of the content
34
+ return typeof content === "string";
35
+ }
36
+ /**
37
+ * Set the root directory URL in the snapshot
38
+ */
39
+ async setRootDirectoryUrl(url) {
40
+ let snapshot = await this.snapshotManager.load();
41
+ if (!snapshot) {
42
+ snapshot = this.snapshotManager.createEmpty();
43
+ }
44
+ snapshot.rootDirectoryUrl = url;
45
+ await this.snapshotManager.save(snapshot);
46
+ }
47
+ /**
48
+ * Commit local changes only (no network sync)
49
+ */
50
+ async commitLocal(dryRun = false) {
51
+ console.log(`🚀 Starting local commit process (dryRun: ${dryRun})`);
52
+ const result = {
53
+ success: false,
54
+ filesChanged: 0,
55
+ directoriesChanged: 0,
56
+ errors: [],
57
+ warnings: [],
58
+ };
59
+ try {
60
+ // Load current snapshot
61
+ console.log(`📸 Loading current snapshot...`);
62
+ let snapshot = await this.snapshotManager.load();
63
+ if (!snapshot) {
64
+ console.log(`📸 No snapshot found, creating empty one`);
65
+ snapshot = this.snapshotManager.createEmpty();
66
+ }
67
+ else {
68
+ console.log(`📸 Snapshot loaded with ${snapshot.files.size} files`);
69
+ if (snapshot.rootDirectoryUrl) {
70
+ console.log(`🔗 Root directory URL: ${snapshot.rootDirectoryUrl}`);
71
+ }
72
+ }
73
+ // Backup snapshot before starting
74
+ if (!dryRun) {
75
+ console.log(`💾 Backing up snapshot...`);
76
+ await this.snapshotManager.backup();
77
+ }
78
+ // Detect all changes
79
+ console.log(`🔍 Detecting changes...`);
80
+ const changes = await this.changeDetector.detectChanges(snapshot);
81
+ console.log(`🔍 Found ${changes.length} changes`);
82
+ // Detect moves
83
+ console.log(`📦 Detecting moves...`);
84
+ const { moves, remainingChanges } = await this.moveDetector.detectMoves(changes, snapshot, this.rootPath);
85
+ console.log(`📦 Found ${moves.length} moves, ${remainingChanges.length} remaining changes`);
86
+ // Apply local changes only (no network sync)
87
+ console.log(`💾 Committing local changes...`);
88
+ const commitResult = await this.pushLocalChanges(remainingChanges, moves, snapshot, dryRun);
89
+ console.log(`💾 Commit complete: ${commitResult.filesChanged} files changed`);
90
+ result.filesChanged += commitResult.filesChanged;
91
+ result.directoriesChanged += commitResult.directoriesChanged;
92
+ result.errors.push(...commitResult.errors);
93
+ result.warnings.push(...commitResult.warnings);
94
+ // Save updated snapshot if not dry run
95
+ if (!dryRun) {
96
+ await this.snapshotManager.save(snapshot);
97
+ }
98
+ result.success = result.errors.length === 0;
99
+ console.log(`💾 Local commit ${result.success ? "completed" : "failed"}`);
100
+ return result;
101
+ }
102
+ catch (error) {
103
+ console.error(`❌ Local commit failed: ${error}`);
104
+ result.errors.push({
105
+ path: this.rootPath,
106
+ operation: "commitLocal",
107
+ error: error instanceof Error ? error : new Error(String(error)),
108
+ recoverable: true,
109
+ });
110
+ result.success = false;
111
+ return result;
112
+ }
113
+ }
114
+ /**
115
+ * Run full bidirectional sync
116
+ */
117
+ async sync(dryRun = false) {
118
+ const result = {
119
+ success: false,
120
+ filesChanged: 0,
121
+ directoriesChanged: 0,
122
+ errors: [],
123
+ warnings: [],
124
+ };
125
+ // Reset handles to wait on
126
+ this.handlesToWaitOn = [];
127
+ try {
128
+ // Load current snapshot
129
+ let snapshot = await this.snapshotManager.load();
130
+ if (!snapshot) {
131
+ snapshot = this.snapshotManager.createEmpty();
132
+ }
133
+ // Backup snapshot before starting
134
+ if (!dryRun) {
135
+ await this.snapshotManager.backup();
136
+ }
137
+ // Detect all changes
138
+ const changes = await this.changeDetector.detectChanges(snapshot);
139
+ // Detect moves
140
+ const { moves, remainingChanges } = await this.moveDetector.detectMoves(changes, snapshot, this.rootPath);
141
+ if (changes.length > 0) {
142
+ console.log(`🔄 Syncing ${changes.length} changes...`);
143
+ }
144
+ // Phase 1: Push local changes to remote
145
+ const phase1Result = await this.pushLocalChanges(remainingChanges, moves, snapshot, dryRun);
146
+ result.filesChanged += phase1Result.filesChanged;
147
+ result.directoriesChanged += phase1Result.directoriesChanged;
148
+ result.errors.push(...phase1Result.errors);
149
+ result.warnings.push(...phase1Result.warnings);
150
+ // Always wait for network sync when enabled (not just when local changes exist)
151
+ // This is critical for clone scenarios where we need to pull remote changes
152
+ if (!dryRun && this.networkSyncEnabled) {
153
+ try {
154
+ // If we have a root directory URL, wait for it to sync
155
+ if (snapshot.rootDirectoryUrl) {
156
+ const rootHandle = await this.repo.find(snapshot.rootDirectoryUrl);
157
+ this.handlesToWaitOn.push(rootHandle);
158
+ }
159
+ if (this.handlesToWaitOn.length > 0) {
160
+ await (0, network_sync_1.waitForSync)(this.handlesToWaitOn, (0, network_sync_1.getSyncServerStorageId)(this.syncServerStorageId));
161
+ }
162
+ }
163
+ catch (error) {
164
+ console.error(`❌ Network sync failed: ${error}`);
165
+ result.warnings.push(`Network sync failed: ${error}`);
166
+ }
167
+ }
168
+ // Re-detect remote changes after network sync to ensure fresh state
169
+ // This fixes race conditions where we detect changes before server propagation
170
+ const freshChanges = await this.changeDetector.detectChanges(snapshot);
171
+ const freshRemoteChanges = freshChanges.filter((c) => c.changeType === types_1.ChangeType.REMOTE_ONLY ||
172
+ c.changeType === types_1.ChangeType.BOTH_CHANGED);
173
+ // Phase 2: Pull remote changes to local using fresh detection
174
+ const phase2Result = await this.pullRemoteChanges(freshRemoteChanges, snapshot, dryRun);
175
+ result.filesChanged += phase2Result.filesChanged;
176
+ result.directoriesChanged += phase2Result.directoriesChanged;
177
+ result.errors.push(...phase2Result.errors);
178
+ result.warnings.push(...phase2Result.warnings);
179
+ // Save updated snapshot if not dry run
180
+ if (!dryRun) {
181
+ await this.snapshotManager.save(snapshot);
182
+ }
183
+ result.success = result.errors.length === 0;
184
+ return result;
185
+ }
186
+ catch (error) {
187
+ result.errors.push({
188
+ path: "sync",
189
+ operation: "full-sync",
190
+ error: error,
191
+ recoverable: false,
192
+ });
193
+ return result;
194
+ }
195
+ }
196
+ /**
197
+ * Phase 1: Push local changes to Automerge documents
198
+ */
199
+ async pushLocalChanges(changes, moves, snapshot, dryRun) {
200
+ const result = {
201
+ success: true,
202
+ filesChanged: 0,
203
+ directoriesChanged: 0,
204
+ errors: [],
205
+ warnings: [],
206
+ };
207
+ // Process moves first
208
+ for (const move of moves) {
209
+ if (this.moveDetector.shouldAutoApply(move)) {
210
+ try {
211
+ await this.applyMoveToRemote(move, snapshot, dryRun);
212
+ result.filesChanged++;
213
+ }
214
+ catch (error) {
215
+ result.errors.push({
216
+ path: move.fromPath,
217
+ operation: "move",
218
+ error: error,
219
+ recoverable: true,
220
+ });
221
+ }
222
+ }
223
+ else if (this.moveDetector.shouldPromptUser(move)) {
224
+ // Instead of creating a persistent loop, perform delete+create semantics
225
+ // so the working tree converges even without auto-apply.
226
+ result.warnings.push(`Potential move detected: ${this.moveDetector.formatMove(move)} (${Math.round(move.similarity * 100)}% similar)`);
227
+ }
228
+ }
229
+ // Process local changes
230
+ const localChanges = changes.filter((c) => c.changeType === types_1.ChangeType.LOCAL_ONLY ||
231
+ c.changeType === types_1.ChangeType.BOTH_CHANGED);
232
+ for (const change of localChanges) {
233
+ try {
234
+ await this.applyLocalChangeToRemote(change, snapshot, dryRun);
235
+ result.filesChanged++;
236
+ }
237
+ catch (error) {
238
+ result.errors.push({
239
+ path: change.path,
240
+ operation: "local-to-remote",
241
+ error: error,
242
+ recoverable: true,
243
+ });
244
+ }
245
+ }
246
+ return result;
247
+ }
248
+ /**
249
+ * Phase 2: Pull remote changes to local filesystem
250
+ */
251
+ async pullRemoteChanges(changes, snapshot, dryRun) {
252
+ const result = {
253
+ success: true,
254
+ filesChanged: 0,
255
+ directoriesChanged: 0,
256
+ errors: [],
257
+ warnings: [],
258
+ };
259
+ // Process remote changes
260
+ const remoteChanges = changes.filter((c) => c.changeType === types_1.ChangeType.REMOTE_ONLY ||
261
+ c.changeType === types_1.ChangeType.BOTH_CHANGED);
262
+ // Sort changes by dependency order (parents before children)
263
+ const sortedChanges = this.sortChangesByDependency(remoteChanges);
264
+ for (const change of sortedChanges) {
265
+ try {
266
+ await this.applyRemoteChangeToLocal(change, snapshot, dryRun);
267
+ result.filesChanged++;
268
+ }
269
+ catch (error) {
270
+ result.errors.push({
271
+ path: change.path,
272
+ operation: "remote-to-local",
273
+ error: error,
274
+ recoverable: true,
275
+ });
276
+ }
277
+ }
278
+ return result;
279
+ }
280
+ /**
281
+ * Apply local file change to remote Automerge document
282
+ */
283
+ async applyLocalChangeToRemote(change, snapshot, dryRun) {
284
+ const snapshotEntry = snapshot.files.get(change.path);
285
+ if (!change.localContent) {
286
+ // File was deleted locally
287
+ if (snapshotEntry) {
288
+ console.log(`🗑️ ${change.path}`);
289
+ await this.deleteRemoteFile(snapshotEntry.url, dryRun, snapshot, change.path);
290
+ // Remove from directory document
291
+ await this.removeFileFromDirectory(snapshot, change.path, dryRun);
292
+ if (!dryRun) {
293
+ this.snapshotManager.removeFileEntry(snapshot, change.path);
294
+ }
295
+ }
296
+ return;
297
+ }
298
+ if (!snapshotEntry) {
299
+ // New file
300
+ console.log(`➕ ${change.path}`);
301
+ const handle = await this.createRemoteFile(change, dryRun);
302
+ if (!dryRun && handle) {
303
+ await this.addFileToDirectory(snapshot, change.path, handle.url, dryRun);
304
+ this.snapshotManager.updateFileEntry(snapshot, change.path, {
305
+ path: (0, utils_1.normalizePath)(this.rootPath + "/" + change.path),
306
+ url: handle.url,
307
+ head: handle.heads(),
308
+ extension: (0, utils_1.getFileExtension)(change.path),
309
+ mimeType: (0, utils_1.getEnhancedMimeType)(change.path),
310
+ });
311
+ }
312
+ }
313
+ else {
314
+ // Update existing file
315
+ console.log(`📝 ${change.path}`);
316
+ await this.updateRemoteFile(snapshotEntry.url, change.localContent, dryRun, snapshot, change.path);
317
+ }
318
+ }
319
+ /**
320
+ * Apply remote change to local filesystem
321
+ */
322
+ async applyRemoteChangeToLocal(change, snapshot, dryRun) {
323
+ const localPath = (0, utils_1.normalizePath)(this.rootPath + "/" + change.path);
324
+ if (!change.remoteHead) {
325
+ throw new Error(`No remote head found for remote change to${change.path}`);
326
+ }
327
+ if (!change.remoteContent) {
328
+ // File was deleted remotely
329
+ console.log(`🗑️ ${change.path}`);
330
+ if (!dryRun) {
331
+ await (0, utils_1.removePath)(localPath);
332
+ this.snapshotManager.removeFileEntry(snapshot, change.path);
333
+ }
334
+ return;
335
+ }
336
+ // Create or update local file
337
+ if (change.changeType === types_1.ChangeType.REMOTE_ONLY) {
338
+ console.log(`⬇️ ${change.path}`);
339
+ }
340
+ else {
341
+ console.log(`🔀 ${change.path}`);
342
+ }
343
+ if (!dryRun) {
344
+ await (0, utils_1.writeFileContent)(localPath, change.remoteContent);
345
+ // Update or create snapshot entry for this file
346
+ const snapshotEntry = snapshot.files.get(change.path);
347
+ if (snapshotEntry) {
348
+ // Update existing entry
349
+ snapshotEntry.head = change.remoteHead;
350
+ }
351
+ else {
352
+ // Create new snapshot entry for newly discovered remote file
353
+ // We need to find the remote file's URL from the directory hierarchy
354
+ if (snapshot.rootDirectoryUrl) {
355
+ try {
356
+ const fileEntry = await this.findFileInDirectoryHierarchy(snapshot.rootDirectoryUrl, change.path);
357
+ if (fileEntry) {
358
+ this.snapshotManager.updateFileEntry(snapshot, change.path, {
359
+ path: localPath,
360
+ url: fileEntry.url,
361
+ head: change.remoteHead,
362
+ extension: (0, utils_1.getFileExtension)(change.path),
363
+ mimeType: (0, utils_1.getEnhancedMimeType)(change.path),
364
+ });
365
+ }
366
+ }
367
+ catch (error) {
368
+ console.warn(`Failed to update snapshot for remote file ${change.path}: ${error}`);
369
+ }
370
+ }
371
+ }
372
+ }
373
+ }
374
+ /**
375
+ * Apply move to remote documents
376
+ */
377
+ async applyMoveToRemote(move, snapshot, dryRun) {
378
+ const fromEntry = snapshot.files.get(move.fromPath);
379
+ if (!fromEntry)
380
+ return;
381
+ // Parse paths
382
+ const fromParts = move.fromPath.split("/");
383
+ const fromFileName = fromParts.pop() || "";
384
+ const fromDirPath = fromParts.join("/");
385
+ const toParts = move.toPath.split("/");
386
+ const toFileName = toParts.pop() || "";
387
+ const toDirPath = toParts.join("/");
388
+ if (!dryRun) {
389
+ // 1) Remove file entry from old directory document
390
+ if (move.fromPath !== move.toPath) {
391
+ await this.removeFileFromDirectory(snapshot, move.fromPath, dryRun);
392
+ }
393
+ // 2) Ensure destination directory document exists and add file entry there
394
+ const destDirUrl = await this.ensureDirectoryDocument(snapshot, toDirPath, dryRun);
395
+ await this.addFileToDirectory(snapshot, move.toPath, fromEntry.url, dryRun);
396
+ // 3) Update the FileDocument name to match new basename
397
+ try {
398
+ const handle = await this.repo.find(fromEntry.url);
399
+ const heads = fromEntry.head;
400
+ if (heads && heads.length > 0) {
401
+ handle.changeAt(heads, (doc) => {
402
+ doc.name = toFileName;
403
+ });
404
+ }
405
+ else {
406
+ handle.change((doc) => {
407
+ doc.name = toFileName;
408
+ });
409
+ }
410
+ // Track file handle for network sync
411
+ this.handlesToWaitOn.push(handle);
412
+ }
413
+ catch (e) {
414
+ console.warn(`Failed to update file name for move ${move.fromPath} -> ${move.toPath}: ${e}`);
415
+ }
416
+ // 4) Update snapshot entries
417
+ this.snapshotManager.removeFileEntry(snapshot, move.fromPath);
418
+ this.snapshotManager.updateFileEntry(snapshot, move.toPath, {
419
+ ...fromEntry,
420
+ path: (0, utils_1.normalizePath)(this.rootPath + "/" + move.toPath),
421
+ head: fromEntry.head, // will be updated later when heads advance
422
+ });
423
+ }
424
+ }
425
+ /**
426
+ * Create new remote file document
427
+ */
428
+ async createRemoteFile(change, dryRun) {
429
+ if (dryRun || !change.localContent)
430
+ return null;
431
+ const isText = this.isTextContent(change.localContent);
432
+ // Create initial document structure
433
+ const fileDoc = {
434
+ "@patchwork": { type: "file" },
435
+ name: change.path.split("/").pop() || "",
436
+ extension: (0, utils_1.getFileExtension)(change.path),
437
+ mimeType: (0, utils_1.getEnhancedMimeType)(change.path),
438
+ content: isText ? "" : change.localContent, // Empty string for text, actual content for binary
439
+ metadata: {
440
+ permissions: 0o644,
441
+ },
442
+ };
443
+ const handle = this.repo.create(fileDoc);
444
+ // For text files, use updateText to set the content properly
445
+ if (isText && typeof change.localContent === "string") {
446
+ handle.change((doc) => {
447
+ (0, automerge_repo_1.updateText)(doc, ["content"], change.localContent);
448
+ });
449
+ }
450
+ // Always track newly created files for network sync
451
+ // (they always represent a change that needs to sync)
452
+ this.handlesToWaitOn.push(handle);
453
+ return handle;
454
+ }
455
+ /**
456
+ * Update existing remote file document
457
+ */
458
+ async updateRemoteFile(url, content, dryRun, snapshot, filePath) {
459
+ if (dryRun)
460
+ return;
461
+ const handle = await this.repo.find(url);
462
+ // Check if content actually changed before tracking for sync
463
+ const doc = await handle.doc();
464
+ const currentContent = doc?.content;
465
+ const contentChanged = !(0, content_1.isContentEqual)(content, currentContent);
466
+ if (!contentChanged) {
467
+ return;
468
+ }
469
+ const snapshotEntry = snapshot.files.get(filePath);
470
+ const heads = snapshotEntry?.head;
471
+ if (!heads) {
472
+ throw new Error(`No heads found for ${url}`);
473
+ }
474
+ handle.changeAt(heads, (doc) => {
475
+ const isText = this.isTextContent(content);
476
+ if (isText && typeof content === "string") {
477
+ (0, automerge_repo_1.updateText)(doc, ["content"], content);
478
+ }
479
+ else {
480
+ doc.content = content;
481
+ }
482
+ });
483
+ if (!dryRun) {
484
+ snapshot.files.set(filePath, {
485
+ ...snapshotEntry,
486
+ head: handle.heads(),
487
+ });
488
+ }
489
+ // Only track files that actually changed content
490
+ this.handlesToWaitOn.push(handle);
491
+ }
492
+ /**
493
+ * Delete remote file document
494
+ */
495
+ async deleteRemoteFile(url, dryRun, snapshot, filePath) {
496
+ if (dryRun)
497
+ return;
498
+ // In Automerge, we don't actually delete documents
499
+ // They become orphaned and will be garbage collected
500
+ // For now, we just mark them as deleted by clearing content
501
+ const handle = await this.repo.find(url);
502
+ // const doc = await handle.doc(); // no longer needed
503
+ let heads;
504
+ if (snapshot && filePath) {
505
+ heads = snapshot.files.get(filePath)?.head;
506
+ }
507
+ if (heads) {
508
+ handle.changeAt(heads, (doc) => {
509
+ doc.content = "";
510
+ });
511
+ }
512
+ else {
513
+ handle.change((doc) => {
514
+ doc.content = "";
515
+ });
516
+ }
517
+ }
518
+ /**
519
+ * Add file entry to appropriate directory document (maintains hierarchy)
520
+ */
521
+ async addFileToDirectory(snapshot, filePath, fileUrl, dryRun) {
522
+ if (dryRun || !snapshot.rootDirectoryUrl)
523
+ return;
524
+ const pathParts = filePath.split("/");
525
+ const fileName = pathParts.pop() || "";
526
+ const directoryPath = pathParts.join("/");
527
+ // Get or create the parent directory document
528
+ const parentDirUrl = await this.ensureDirectoryDocument(snapshot, directoryPath, dryRun);
529
+ console.log(`🔗 Adding ${fileName} (${fileUrl}) to directory ${parentDirUrl} (path: ${directoryPath})`);
530
+ const dirHandle = await this.repo.find(parentDirUrl);
531
+ let didChange = false;
532
+ const snapshotEntry = snapshot.directories.get(directoryPath);
533
+ const heads = snapshotEntry?.head;
534
+ if (heads) {
535
+ dirHandle.changeAt(heads, (doc) => {
536
+ const existingIndex = doc.docs.findIndex((entry) => entry.name === fileName && entry.type === "file");
537
+ if (existingIndex === -1) {
538
+ doc.docs.push({
539
+ name: fileName,
540
+ type: "file",
541
+ url: fileUrl,
542
+ });
543
+ didChange = true;
544
+ }
545
+ });
546
+ }
547
+ else {
548
+ dirHandle.change((doc) => {
549
+ const existingIndex = doc.docs.findIndex((entry) => entry.name === fileName && entry.type === "file");
550
+ if (existingIndex === -1) {
551
+ doc.docs.push({
552
+ name: fileName,
553
+ type: "file",
554
+ url: fileUrl,
555
+ });
556
+ didChange = true;
557
+ }
558
+ });
559
+ }
560
+ if (didChange) {
561
+ this.handlesToWaitOn.push(dirHandle);
562
+ }
563
+ }
564
+ /**
565
+ * Ensure directory document exists for the given path, creating hierarchy as needed
566
+ * First checks for existing shared directories before creating new ones
567
+ */
568
+ async ensureDirectoryDocument(snapshot, directoryPath, dryRun) {
569
+ // Root directory case
570
+ if (!directoryPath || directoryPath === "") {
571
+ return snapshot.rootDirectoryUrl;
572
+ }
573
+ // Check if we already have this directory in snapshot
574
+ const existingDir = snapshot.directories.get(directoryPath);
575
+ if (existingDir) {
576
+ return existingDir.url;
577
+ }
578
+ // Split path into parent and current directory name
579
+ const pathParts = directoryPath.split("/");
580
+ const currentDirName = pathParts.pop() || "";
581
+ const parentPath = pathParts.join("/");
582
+ // Ensure parent directory exists first (recursive)
583
+ const parentDirUrl = await this.ensureDirectoryDocument(snapshot, parentPath, dryRun);
584
+ // DISCOVERY: Check if directory already exists in parent on server
585
+ try {
586
+ const parentHandle = await this.repo.find(parentDirUrl);
587
+ const parentDoc = await parentHandle.doc();
588
+ if (parentDoc) {
589
+ const existingDirEntry = parentDoc.docs.find((entry) => entry.name === currentDirName && entry.type === "folder");
590
+ if (existingDirEntry) {
591
+ // Resolve the actual directory handle and use its current heads
592
+ // Directory entries in parent docs may not carry valid heads
593
+ try {
594
+ const childDirHandle = await this.repo.find(existingDirEntry.url);
595
+ const childHeads = childDirHandle.heads();
596
+ // Update snapshot with discovered directory using validated heads
597
+ if (!dryRun) {
598
+ this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
599
+ path: (0, utils_1.normalizePath)(this.rootPath + "/" + directoryPath),
600
+ url: existingDirEntry.url,
601
+ head: childHeads,
602
+ entries: [],
603
+ });
604
+ }
605
+ return existingDirEntry.url;
606
+ }
607
+ catch (resolveErr) {
608
+ console.warn(`Failed to resolve child directory ${currentDirName} at ${directoryPath}: ${resolveErr}`);
609
+ // Fall through to create a fresh directory document
610
+ }
611
+ }
612
+ }
613
+ }
614
+ catch (error) {
615
+ console.warn(`Failed to check for existing directory ${currentDirName}: ${error}`);
616
+ }
617
+ // CREATE: Directory doesn't exist, create new one
618
+ const dirDoc = {
619
+ "@patchwork": { type: "folder" },
620
+ docs: [],
621
+ };
622
+ const dirHandle = this.repo.create(dirDoc);
623
+ // Add this directory to its parent
624
+ const parentHandle = await this.repo.find(parentDirUrl);
625
+ let didChange = false;
626
+ parentHandle.change((doc) => {
627
+ // Double-check that entry doesn't exist (race condition protection)
628
+ const existingIndex = doc.docs.findIndex((entry) => entry.name === currentDirName && entry.type === "folder");
629
+ if (existingIndex === -1) {
630
+ doc.docs.push({
631
+ name: currentDirName,
632
+ type: "folder",
633
+ url: dirHandle.url,
634
+ });
635
+ didChange = true;
636
+ }
637
+ });
638
+ // Track directory handles for sync
639
+ if (!dryRun) {
640
+ this.handlesToWaitOn.push(dirHandle);
641
+ if (didChange) {
642
+ this.handlesToWaitOn.push(parentHandle);
643
+ }
644
+ // Update snapshot with new directory
645
+ this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
646
+ path: (0, utils_1.normalizePath)(this.rootPath + "/" + directoryPath),
647
+ url: dirHandle.url,
648
+ head: dirHandle.heads(),
649
+ entries: [],
650
+ });
651
+ }
652
+ return dirHandle.url;
653
+ }
654
+ /**
655
+ * Remove file entry from directory document
656
+ */
657
+ async removeFileFromDirectory(snapshot, filePath, dryRun) {
658
+ if (dryRun || !snapshot.rootDirectoryUrl)
659
+ return;
660
+ const pathParts = filePath.split("/");
661
+ const fileName = pathParts.pop() || "";
662
+ const directoryPath = pathParts.join("/");
663
+ // Get the parent directory URL
664
+ let parentDirUrl;
665
+ if (!directoryPath || directoryPath === "") {
666
+ parentDirUrl = snapshot.rootDirectoryUrl;
667
+ }
668
+ else {
669
+ const existingDir = snapshot.directories.get(directoryPath);
670
+ if (!existingDir) {
671
+ console.warn(`Directory ${directoryPath} not found in snapshot for file removal`);
672
+ return;
673
+ }
674
+ parentDirUrl = existingDir.url;
675
+ }
676
+ try {
677
+ const dirHandle = await this.repo.find(parentDirUrl);
678
+ // Track this handle for network sync waiting
679
+ this.handlesToWaitOn.push(dirHandle);
680
+ const snapshotEntry = snapshot.directories.get(directoryPath);
681
+ const heads = snapshotEntry?.head;
682
+ if (heads) {
683
+ dirHandle.changeAt(heads, (doc) => {
684
+ const indexToRemove = doc.docs.findIndex((entry) => entry.name === fileName && entry.type === "file");
685
+ if (indexToRemove !== -1) {
686
+ doc.docs.splice(indexToRemove, 1);
687
+ console.log(`🗑️ Removed ${fileName} from directory ${directoryPath || "root"}`);
688
+ }
689
+ });
690
+ }
691
+ else {
692
+ dirHandle.change((doc) => {
693
+ const indexToRemove = doc.docs.findIndex((entry) => entry.name === fileName && entry.type === "file");
694
+ if (indexToRemove !== -1) {
695
+ doc.docs.splice(indexToRemove, 1);
696
+ console.log(`🗑️ Removed ${fileName} from directory ${directoryPath || "root"}`);
697
+ }
698
+ });
699
+ }
700
+ }
701
+ catch (error) {
702
+ console.warn(`Failed to remove ${fileName} from directory ${directoryPath || "root"}: ${error}`);
703
+ throw error;
704
+ }
705
+ }
706
+ /**
707
+ * Find a file in the directory hierarchy by path
708
+ */
709
+ async findFileInDirectoryHierarchy(directoryUrl, filePath) {
710
+ try {
711
+ const pathParts = filePath.split("/");
712
+ let currentDirUrl = directoryUrl;
713
+ // Navigate through directories to find the parent directory
714
+ for (let i = 0; i < pathParts.length - 1; i++) {
715
+ const dirName = pathParts[i];
716
+ const dirHandle = await this.repo.find(currentDirUrl);
717
+ const dirDoc = await dirHandle.doc();
718
+ if (!dirDoc)
719
+ return null;
720
+ const subDirEntry = dirDoc.docs.find((entry) => entry.name === dirName && entry.type === "folder");
721
+ if (!subDirEntry)
722
+ return null;
723
+ currentDirUrl = subDirEntry.url;
724
+ }
725
+ // Now look for the file in the final directory
726
+ const fileName = pathParts[pathParts.length - 1];
727
+ const finalDirHandle = await this.repo.find(currentDirUrl);
728
+ const finalDirDoc = await finalDirHandle.doc();
729
+ if (!finalDirDoc)
730
+ return null;
731
+ const fileEntry = finalDirDoc.docs.find((entry) => entry.name === fileName && entry.type === "file");
732
+ return fileEntry || null;
733
+ }
734
+ catch (error) {
735
+ console.warn(`Failed to find file ${filePath} in directory hierarchy: ${error}`);
736
+ return null;
737
+ }
738
+ }
739
+ /**
740
+ * Sort changes by dependency order
741
+ */
742
+ sortChangesByDependency(changes) {
743
+ // Sort by path depth (shallower paths first)
744
+ return changes.sort((a, b) => {
745
+ const depthA = a.path.split("/").length;
746
+ const depthB = b.path.split("/").length;
747
+ return depthA - depthB;
748
+ });
749
+ }
750
+ /**
751
+ * Get sync status
752
+ */
753
+ async getStatus() {
754
+ const snapshot = await this.snapshotManager.load();
755
+ if (!snapshot) {
756
+ return {
757
+ snapshot: null,
758
+ hasChanges: false,
759
+ changeCount: 0,
760
+ lastSync: null,
761
+ };
762
+ }
763
+ const changes = await this.changeDetector.detectChanges(snapshot);
764
+ return {
765
+ snapshot,
766
+ hasChanges: changes.length > 0,
767
+ changeCount: changes.length,
768
+ lastSync: new Date(snapshot.timestamp),
769
+ };
770
+ }
771
+ /**
772
+ * Preview changes without applying them
773
+ */
774
+ async previewChanges() {
775
+ const snapshot = await this.snapshotManager.load();
776
+ if (!snapshot) {
777
+ return {
778
+ changes: [],
779
+ moves: [],
780
+ summary: "No snapshot found - run init first",
781
+ };
782
+ }
783
+ const changes = await this.changeDetector.detectChanges(snapshot);
784
+ const { moves } = await this.moveDetector.detectMoves(changes, snapshot, this.rootPath);
785
+ const summary = this.generateChangeSummary(changes, moves);
786
+ return { changes, moves, summary };
787
+ }
788
+ /**
789
+ * Generate human-readable summary of changes
790
+ */
791
+ generateChangeSummary(changes, moves) {
792
+ const localChanges = changes.filter((c) => c.changeType === types_1.ChangeType.LOCAL_ONLY ||
793
+ c.changeType === types_1.ChangeType.BOTH_CHANGED).length;
794
+ const remoteChanges = changes.filter((c) => c.changeType === types_1.ChangeType.REMOTE_ONLY ||
795
+ c.changeType === types_1.ChangeType.BOTH_CHANGED).length;
796
+ const conflicts = changes.filter((c) => c.changeType === types_1.ChangeType.BOTH_CHANGED).length;
797
+ const parts = [];
798
+ if (localChanges > 0) {
799
+ parts.push(`${localChanges} local change${localChanges > 1 ? "s" : ""}`);
800
+ }
801
+ if (remoteChanges > 0) {
802
+ parts.push(`${remoteChanges} remote change${remoteChanges > 1 ? "s" : ""}`);
803
+ }
804
+ if (moves.length > 0) {
805
+ parts.push(`${moves.length} potential move${moves.length > 1 ? "s" : ""}`);
806
+ }
807
+ if (conflicts > 0) {
808
+ parts.push(`${conflicts} conflict${conflicts > 1 ? "s" : ""}`);
809
+ }
810
+ if (parts.length === 0) {
811
+ return "No changes detected";
812
+ }
813
+ return parts.join(", ");
814
+ }
815
+ }
816
+ exports.SyncEngine = SyncEngine;
817
+ //# sourceMappingURL=sync-engine.js.map