pushwork 1.0.5 → 1.0.11

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 (196) hide show
  1. package/README.md +87 -335
  2. package/babel.config.js +5 -0
  3. package/dist/cli/commands.d.ts +9 -15
  4. package/dist/cli/commands.d.ts.map +1 -1
  5. package/dist/cli/commands.js +37 -170
  6. package/dist/cli/commands.js.map +1 -1
  7. package/dist/cli/output.d.ts +11 -25
  8. package/dist/cli/output.d.ts.map +1 -1
  9. package/dist/cli/output.js +55 -61
  10. package/dist/cli/output.js.map +1 -1
  11. package/dist/cli.js +208 -213
  12. package/dist/cli.js.map +1 -1
  13. package/dist/commands.d.ts +51 -0
  14. package/dist/commands.d.ts.map +1 -0
  15. package/dist/commands.js +799 -0
  16. package/dist/commands.js.map +1 -0
  17. package/dist/core/change-detection.d.ts +7 -23
  18. package/dist/core/change-detection.d.ts.map +1 -1
  19. package/dist/core/change-detection.js +108 -122
  20. package/dist/core/change-detection.js.map +1 -1
  21. package/dist/core/config.d.ts +81 -0
  22. package/dist/core/config.d.ts.map +1 -0
  23. package/dist/core/config.js +296 -0
  24. package/dist/core/config.js.map +1 -0
  25. package/dist/core/index.d.ts +1 -0
  26. package/dist/core/index.d.ts.map +1 -1
  27. package/dist/core/index.js +1 -1
  28. package/dist/core/index.js.map +1 -1
  29. package/dist/core/move-detection.d.ts +4 -3
  30. package/dist/core/move-detection.d.ts.map +1 -1
  31. package/dist/core/move-detection.js +8 -7
  32. package/dist/core/move-detection.js.map +1 -1
  33. package/dist/core/snapshot.d.ts +0 -4
  34. package/dist/core/snapshot.d.ts.map +1 -1
  35. package/dist/core/snapshot.js +2 -11
  36. package/dist/core/snapshot.js.map +1 -1
  37. package/dist/core/sync-engine.d.ts +41 -12
  38. package/dist/core/sync-engine.d.ts.map +1 -1
  39. package/dist/core/sync-engine.js +522 -359
  40. package/dist/core/sync-engine.js.map +1 -1
  41. package/dist/index.d.ts +0 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +0 -6
  44. package/dist/index.js.map +1 -1
  45. package/dist/types/config.d.ts +24 -88
  46. package/dist/types/config.d.ts.map +1 -1
  47. package/dist/types/config.js +6 -0
  48. package/dist/types/config.js.map +1 -1
  49. package/dist/types/documents.d.ts +15 -2
  50. package/dist/types/documents.d.ts.map +1 -1
  51. package/dist/types/documents.js.map +1 -1
  52. package/dist/types/index.d.ts.map +1 -1
  53. package/dist/types/index.js +0 -3
  54. package/dist/types/index.js.map +1 -1
  55. package/dist/types/snapshot.d.ts +0 -21
  56. package/dist/types/snapshot.d.ts.map +1 -1
  57. package/dist/types/snapshot.js +0 -14
  58. package/dist/types/snapshot.js.map +1 -1
  59. package/dist/utils/content.d.ts.map +1 -1
  60. package/dist/utils/content.js +2 -6
  61. package/dist/utils/content.js.map +1 -1
  62. package/dist/utils/directory.d.ts +24 -0
  63. package/dist/utils/directory.d.ts.map +1 -0
  64. package/dist/utils/directory.js +56 -0
  65. package/dist/utils/directory.js.map +1 -0
  66. package/dist/utils/fs.d.ts +15 -2
  67. package/dist/utils/fs.d.ts.map +1 -1
  68. package/dist/utils/fs.js +53 -20
  69. package/dist/utils/fs.js.map +1 -1
  70. package/dist/utils/index.d.ts +1 -0
  71. package/dist/utils/index.d.ts.map +1 -1
  72. package/dist/utils/index.js +1 -3
  73. package/dist/utils/index.js.map +1 -1
  74. package/dist/utils/keyhive.d.ts +9 -0
  75. package/dist/utils/keyhive.d.ts.map +1 -0
  76. package/dist/utils/keyhive.js +26 -0
  77. package/dist/utils/keyhive.js.map +1 -0
  78. package/dist/utils/mime-types.d.ts.map +1 -1
  79. package/dist/utils/mime-types.js +11 -4
  80. package/dist/utils/mime-types.js.map +1 -1
  81. package/dist/utils/network-sync.d.ts +16 -7
  82. package/dist/utils/network-sync.d.ts.map +1 -1
  83. package/dist/utils/network-sync.js +158 -99
  84. package/dist/utils/network-sync.js.map +1 -1
  85. package/dist/utils/output.d.ts +129 -0
  86. package/dist/utils/output.d.ts.map +1 -0
  87. package/dist/utils/output.js +375 -0
  88. package/dist/utils/output.js.map +1 -0
  89. package/dist/utils/repo-factory.d.ts +2 -6
  90. package/dist/utils/repo-factory.d.ts.map +1 -1
  91. package/dist/utils/repo-factory.js +8 -31
  92. package/dist/utils/repo-factory.js.map +1 -1
  93. package/dist/utils/string-similarity.js +2 -2
  94. package/dist/utils/string-similarity.js.map +1 -1
  95. package/dist/utils/trace.d.ts +19 -0
  96. package/dist/utils/trace.d.ts.map +1 -0
  97. package/dist/utils/trace.js +68 -0
  98. package/dist/utils/trace.js.map +1 -0
  99. package/package.json +21 -11
  100. package/src/cli.ts +276 -308
  101. package/src/commands.ts +988 -0
  102. package/src/core/change-detection.ts +226 -246
  103. package/src/{config/index.ts → core/config.ts} +65 -82
  104. package/src/core/index.ts +1 -1
  105. package/src/core/move-detection.ts +10 -8
  106. package/src/core/snapshot.ts +2 -12
  107. package/src/core/sync-engine.ts +630 -478
  108. package/src/index.ts +0 -10
  109. package/src/types/config.ts +28 -93
  110. package/src/types/documents.ts +16 -2
  111. package/src/types/index.ts +0 -5
  112. package/src/types/snapshot.ts +0 -23
  113. package/src/utils/content.ts +2 -6
  114. package/src/utils/directory.ts +73 -0
  115. package/src/utils/fs.ts +57 -23
  116. package/src/utils/index.ts +1 -5
  117. package/src/utils/mime-types.ts +12 -4
  118. package/src/utils/network-sync.ts +216 -138
  119. package/src/utils/output.ts +450 -0
  120. package/src/utils/repo-factory.ts +13 -44
  121. package/src/utils/string-similarity.ts +2 -2
  122. package/src/utils/trace.ts +70 -0
  123. package/test/integration/exclude-patterns.test.ts +6 -15
  124. package/test/integration/fuzzer.test.ts +308 -391
  125. package/test/integration/in-memory-sync.test.ts +435 -0
  126. package/test/integration/init-sync.test.ts +89 -0
  127. package/test/integration/sync-deletion.test.ts +2 -61
  128. package/test/integration/sync-flow.test.ts +4 -24
  129. package/test/jest.setup.ts +34 -0
  130. package/test/unit/deletion-behavior.test.ts +3 -14
  131. package/test/unit/enhanced-mime-detection.test.ts +0 -22
  132. package/test/unit/snapshot.test.ts +2 -29
  133. package/test/unit/sync-convergence.test.ts +3 -198
  134. package/test/unit/sync-timing.test.ts +0 -44
  135. package/test/unit/utils.test.ts +0 -2
  136. package/tsconfig.json +3 -3
  137. package/bench/filesystem.bench.ts +0 -78
  138. package/bench/hashing.bench.ts +0 -60
  139. package/bench/move-detection.bench.ts +0 -130
  140. package/bench/runner.ts +0 -49
  141. package/dist/browser/browser-sync-engine.d.ts +0 -64
  142. package/dist/browser/browser-sync-engine.d.ts.map +0 -1
  143. package/dist/browser/browser-sync-engine.js +0 -303
  144. package/dist/browser/browser-sync-engine.js.map +0 -1
  145. package/dist/browser/filesystem-adapter.d.ts +0 -84
  146. package/dist/browser/filesystem-adapter.d.ts.map +0 -1
  147. package/dist/browser/filesystem-adapter.js +0 -413
  148. package/dist/browser/filesystem-adapter.js.map +0 -1
  149. package/dist/browser/index.d.ts +0 -36
  150. package/dist/browser/index.d.ts.map +0 -1
  151. package/dist/browser/index.js +0 -90
  152. package/dist/browser/index.js.map +0 -1
  153. package/dist/browser/types.d.ts +0 -70
  154. package/dist/browser/types.d.ts.map +0 -1
  155. package/dist/browser/types.js +0 -6
  156. package/dist/browser/types.js.map +0 -1
  157. package/dist/config/remote-manager.d.ts +0 -65
  158. package/dist/config/remote-manager.d.ts.map +0 -1
  159. package/dist/config/remote-manager.js +0 -243
  160. package/dist/config/remote-manager.js.map +0 -1
  161. package/dist/core/isomorphic-snapshot.d.ts +0 -58
  162. package/dist/core/isomorphic-snapshot.d.ts.map +0 -1
  163. package/dist/core/isomorphic-snapshot.js +0 -204
  164. package/dist/core/isomorphic-snapshot.js.map +0 -1
  165. package/dist/platform/browser-filesystem.d.ts +0 -26
  166. package/dist/platform/browser-filesystem.d.ts.map +0 -1
  167. package/dist/platform/browser-filesystem.js +0 -91
  168. package/dist/platform/browser-filesystem.js.map +0 -1
  169. package/dist/platform/filesystem.d.ts +0 -29
  170. package/dist/platform/filesystem.d.ts.map +0 -1
  171. package/dist/platform/filesystem.js +0 -65
  172. package/dist/platform/filesystem.js.map +0 -1
  173. package/dist/platform/node-filesystem.d.ts +0 -21
  174. package/dist/platform/node-filesystem.d.ts.map +0 -1
  175. package/dist/platform/node-filesystem.js +0 -93
  176. package/dist/platform/node-filesystem.js.map +0 -1
  177. package/dist/utils/fs-browser.d.ts +0 -57
  178. package/dist/utils/fs-browser.d.ts.map +0 -1
  179. package/dist/utils/fs-browser.js +0 -311
  180. package/dist/utils/fs-browser.js.map +0 -1
  181. package/dist/utils/fs-node.d.ts +0 -53
  182. package/dist/utils/fs-node.d.ts.map +0 -1
  183. package/dist/utils/fs-node.js +0 -220
  184. package/dist/utils/fs-node.js.map +0 -1
  185. package/dist/utils/isomorphic.d.ts +0 -29
  186. package/dist/utils/isomorphic.d.ts.map +0 -1
  187. package/dist/utils/isomorphic.js +0 -139
  188. package/dist/utils/isomorphic.js.map +0 -1
  189. package/dist/utils/pure.d.ts +0 -25
  190. package/dist/utils/pure.d.ts.map +0 -1
  191. package/dist/utils/pure.js +0 -112
  192. package/dist/utils/pure.js.map +0 -1
  193. package/src/cli/commands.ts +0 -1030
  194. package/src/cli/index.ts +0 -2
  195. package/src/cli/output.ts +0 -244
  196. package/test/README-TESTING-GAPS.md +0 -174
@@ -1,1030 +0,0 @@
1
- import * as path from "path";
2
- import * as fs from "fs/promises";
3
- import { Repo, AutomergeUrl } from "@automerge/automerge-repo";
4
- import * as diffLib from "diff";
5
- import {
6
- CloneOptions,
7
- SyncOptions,
8
- DiffOptions,
9
- LogOptions,
10
- CheckoutOptions,
11
- InitOptions,
12
- CommitOptions,
13
- StatusOptions,
14
- UrlOptions,
15
- ListOptions,
16
- ConfigOptions,
17
- DebugOptions,
18
- DirectoryConfig,
19
- DirectoryDocument,
20
- } from "../types";
21
- import { SyncEngine } from "../core";
22
- import { pathExists, ensureDirectoryExists } from "../utils";
23
- import { ConfigManager } from "../config";
24
- import { createRepo } from "../utils/repo-factory";
25
- import { Output } from "./output";
26
-
27
- /**
28
- * Simple key transformation for debug output: snake_case -> Title Case
29
- */
30
- function prettifyKey(key: string): string {
31
- return (
32
- key
33
- .split("_")
34
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
35
- .join(" ") + ":"
36
- );
37
- }
38
-
39
- /**
40
- * Format timing value with percentage and optional metadata
41
- */
42
- function formatTimingValue(
43
- value: any,
44
- key: string,
45
- total: number,
46
- timings?: Record<string, any>
47
- ): string {
48
- // Skip non-timing values
49
- if (key === "documents_to_sync") return "";
50
- if (key === "total") return `${(value / 1000).toFixed(3)}s`;
51
-
52
- const timeStr = `${(value / 1000).toFixed(3)}s`;
53
- const pctStr = `(${((value / total) * 100).toFixed(1)}%)`;
54
-
55
- return `${timeStr} ${pctStr}`;
56
- }
57
-
58
- /**
59
- * Shared context that commands can use
60
- */
61
- export interface CommandContext {
62
- repo: Repo;
63
- syncEngine: SyncEngine;
64
- config: DirectoryConfig;
65
- workingDir: string;
66
- }
67
-
68
- /**
69
- * Validate that sync server options are used together
70
- */
71
- function validateSyncServerOptions(
72
- syncServer?: string,
73
- syncServerStorageId?: string
74
- ): void {
75
- const hasSyncServer = !!syncServer;
76
- const hasSyncServerStorageId = !!syncServerStorageId;
77
-
78
- if (hasSyncServer && !hasSyncServerStorageId) {
79
- throw new Error(
80
- "--sync-server requires --sync-server-storage-id\nBoth arguments must be provided together."
81
- );
82
- }
83
-
84
- if (hasSyncServerStorageId && !hasSyncServer) {
85
- throw new Error(
86
- "--sync-server-storage-id requires --sync-server\nBoth arguments must be provided together."
87
- );
88
- }
89
- }
90
-
91
- /**
92
- * Shared pre-action that ensures repository and sync engine are properly initialized
93
- * This function always works, with or without network connectivity
94
- */
95
- export async function setupCommandContext(
96
- workingDir: string = process.cwd(),
97
- customSyncServer?: string,
98
- customStorageId?: string,
99
- enableNetwork: boolean = true
100
- ): Promise<CommandContext> {
101
- const resolvedPath = path.resolve(workingDir);
102
-
103
- // Check if initialized
104
- const syncToolDir = path.join(resolvedPath, ".pushwork");
105
- if (!(await pathExists(syncToolDir))) {
106
- throw new Error(
107
- 'Directory not initialized for sync. Run "pushwork init" first.'
108
- );
109
- }
110
-
111
- // Load configuration
112
- const configManager = new ConfigManager(resolvedPath);
113
- const config = await configManager.getMerged();
114
-
115
- // Create repo with configurable network setting
116
- const repo = await createRepo(resolvedPath, {
117
- enableNetwork,
118
- syncServer: customSyncServer,
119
- syncServerStorageId: customStorageId,
120
- });
121
-
122
- // Create sync engine with configurable network sync
123
- const syncEngine = new SyncEngine(
124
- repo,
125
- resolvedPath,
126
- config.defaults.exclude_patterns,
127
- enableNetwork,
128
- config.sync_server_storage_id
129
- );
130
-
131
- return {
132
- repo,
133
- syncEngine,
134
- config,
135
- workingDir: resolvedPath,
136
- };
137
- }
138
-
139
- /**
140
- * Safely shutdown a repository with proper error handling
141
- */
142
- export async function safeRepoShutdown(
143
- repo: Repo,
144
- context?: string
145
- ): Promise<void> {
146
- try {
147
- await repo.shutdown();
148
- } catch (shutdownError) {
149
- // WebSocket errors during shutdown are common and non-critical
150
- // Silently ignore them - they don't affect data integrity
151
- const errorMessage =
152
- shutdownError instanceof Error
153
- ? shutdownError.message
154
- : String(shutdownError);
155
-
156
- // Ignore WebSocket-related errors entirely
157
- if (
158
- errorMessage.includes("WebSocket") ||
159
- errorMessage.includes("connection was established") ||
160
- errorMessage.includes("was closed")
161
- ) {
162
- // Silently ignore WebSocket shutdown errors
163
- return;
164
- }
165
-
166
- // Only warn about truly unexpected shutdown errors
167
- console.warn(
168
- `Warning: Repository shutdown failed${
169
- context ? ` (${context})` : ""
170
- }: ${shutdownError}`
171
- );
172
- }
173
- }
174
-
175
- /**
176
- * Initialize sync in a directory
177
- */
178
- export async function init(
179
- targetPath: string,
180
- options: InitOptions = {}
181
- ): Promise<void> {
182
- // Validate sync server options
183
- validateSyncServerOptions(options.syncServer, options.syncServerStorageId);
184
-
185
- const out = new Output();
186
-
187
- try {
188
- const resolvedPath = path.resolve(targetPath);
189
-
190
- out.task(`Initializing ${resolvedPath}`);
191
-
192
- await ensureDirectoryExists(resolvedPath);
193
-
194
- // Check if already initialized
195
- const syncToolDir = path.join(resolvedPath, ".pushwork");
196
- if (await pathExists(syncToolDir)) {
197
- out.error("Directory already initialized for sync");
198
- out.exit(1);
199
- }
200
-
201
- out.update("Creating sync directory");
202
- await ensureDirectoryExists(syncToolDir);
203
- await ensureDirectoryExists(path.join(syncToolDir, "automerge"));
204
-
205
- out.update("Setting up configuration");
206
- const configManager = new ConfigManager(resolvedPath);
207
- const defaultSyncServer = options.syncServer || "wss://sync3.automerge.org";
208
- const defaultStorageId =
209
- options.syncServerStorageId || "3760df37-a4c6-4f66-9ecd-732039a9385d";
210
- const config: DirectoryConfig = {
211
- sync_server: defaultSyncServer,
212
- sync_server_storage_id: defaultStorageId,
213
- sync_enabled: true,
214
- defaults: {
215
- exclude_patterns: [".git", "node_modules", "*.tmp", ".pushwork"],
216
- large_file_threshold: "100MB",
217
- },
218
- diff: {
219
- show_binary: false,
220
- },
221
- sync: {
222
- move_detection_threshold: 0.8,
223
- prompt_threshold: 0.5,
224
- auto_sync: false,
225
- parallel_operations: 4,
226
- },
227
- };
228
- await configManager.save(config);
229
-
230
- out.update("Creating root directory");
231
- const repo = await createRepo(resolvedPath, {
232
- enableNetwork: true,
233
- syncServer: options.syncServer,
234
- syncServerStorageId: options.syncServerStorageId,
235
- });
236
-
237
- const rootDoc: DirectoryDocument = {
238
- "@patchwork": { type: "folder" },
239
- docs: [],
240
- };
241
- const rootHandle = repo.create(rootDoc);
242
-
243
- out.update("Scanning existing files");
244
- const syncEngine = new SyncEngine(
245
- repo,
246
- resolvedPath,
247
- config.defaults.exclude_patterns,
248
- true,
249
- config.sync_server_storage_id
250
- );
251
-
252
- await syncEngine.setRootDirectoryUrl(rootHandle.url);
253
- const result = await syncEngine.sync(false);
254
-
255
- out.update("Writing to disk");
256
- await safeRepoShutdown(repo, "init");
257
-
258
- out.done();
259
-
260
- out.pair("Sync", defaultSyncServer);
261
- if (result.filesChanged > 0) {
262
- out.pair("Files", `${result.filesChanged} added`);
263
- }
264
- out.success("INITIALIZED", rootHandle.url);
265
-
266
- // Show timing breakdown if debug mode is enabled
267
- if (
268
- options.debug &&
269
- result.timings &&
270
- Object.keys(result.timings).length > 0
271
- ) {
272
- const total = result.timings.total || 0;
273
- out.log("");
274
- out.special("TIMING", "");
275
- out.obj(result.timings, prettifyKey, (value, key) =>
276
- formatTimingValue(value, key, total, result.timings)
277
- );
278
- }
279
-
280
- out.log("");
281
- out.log(`Run 'pushwork sync' to start synchronizing`);
282
- } catch (error) {
283
- out.error("FAILED", "Initialization failed");
284
- out.log(` ${error}`);
285
- out.exit(1);
286
- }
287
- process.exit();
288
- }
289
-
290
- /**
291
- * Run bidirectional sync
292
- */
293
- export async function sync(
294
- targetPath = ".",
295
- options: SyncOptions
296
- ): Promise<void> {
297
- const out = new Output();
298
-
299
- try {
300
- out.task("Syncing");
301
-
302
- const { repo, syncEngine, workingDir } = await setupCommandContext(
303
- targetPath
304
- );
305
-
306
- if (options.dryRun) {
307
- out.update("Analyzing changes");
308
- const preview = await syncEngine.previewChanges();
309
-
310
- out.done();
311
-
312
- if (preview.changes.length === 0 && preview.moves.length === 0) {
313
- out.info("No changes detected");
314
- out.log("Everything is already in sync");
315
- return;
316
- }
317
-
318
- out.info("CHANGES", "Pending");
319
- out.pair("Directory", workingDir);
320
- out.pair("Changes", preview.changes.length.toString());
321
- if (preview.moves.length > 0) {
322
- out.pair("Moves", preview.moves.length.toString());
323
- }
324
-
325
- out.log("");
326
- out.log("Files:");
327
- for (const change of preview.changes.slice(0, 10)) {
328
- const prefix =
329
- change.changeType === "local_only"
330
- ? "[local] "
331
- : change.changeType === "remote_only"
332
- ? "[remote] "
333
- : "[conflict]";
334
- out.log(` ${prefix} ${change.path}`);
335
- }
336
- if (preview.changes.length > 10) {
337
- out.log(` ... and ${preview.changes.length - 10} more`);
338
- }
339
-
340
- if (preview.moves.length > 0) {
341
- out.log("");
342
- out.log("Moves:");
343
- for (const move of preview.moves.slice(0, 5)) {
344
- out.log(` ${move.fromPath} → ${move.toPath}`);
345
- }
346
- if (preview.moves.length > 5) {
347
- out.log(` ... and ${preview.moves.length - 5} more`);
348
- }
349
- }
350
-
351
- out.log("");
352
- out.log("Run without --dry-run to apply these changes");
353
- } else {
354
- out.update("Synchronizing");
355
- const result = await syncEngine.sync(false);
356
-
357
- out.update("Writing to disk");
358
- await safeRepoShutdown(repo, "sync");
359
-
360
- if (result.success) {
361
- if (result.filesChanged === 0 && result.directoriesChanged === 0) {
362
- out.done();
363
- out.success("Already in sync");
364
- } else {
365
- out.done();
366
- out.success(
367
- "SYNCED",
368
- `${result.filesChanged} file${
369
- result.filesChanged !== 1 ? "s" : ""
370
- } updated`
371
- );
372
- out.pair("Files", result.filesChanged.toString());
373
- }
374
-
375
- if (result.warnings.length > 0) {
376
- out.log("");
377
- out.warn("WARNINGS", `${result.warnings.length} warnings`);
378
- for (const warning of result.warnings.slice(0, 5)) {
379
- out.log(` ${warning}`);
380
- }
381
- if (result.warnings.length > 5) {
382
- out.log(` ... and ${result.warnings.length - 5} more`);
383
- }
384
- }
385
-
386
- // Show timing breakdown if debug mode is enabled
387
- if (
388
- options.debug &&
389
- result.timings &&
390
- Object.keys(result.timings).length > 0
391
- ) {
392
- const total = result.timings.total || 0;
393
- out.log("");
394
- out.special("TIMING", "");
395
- out.obj(result.timings, prettifyKey, (value, key) =>
396
- formatTimingValue(value, key, total, result.timings)
397
- );
398
- }
399
- } else {
400
- out.done("partial", false);
401
- out.warn(
402
- "PARTIAL",
403
- `${result.filesChanged} updated, ${result.errors.length} errors`
404
- );
405
- out.pair("Files", result.filesChanged.toString());
406
- out.pair("Errors", result.errors.length.toString());
407
-
408
- out.log("");
409
- for (const error of result.errors.slice(0, 5)) {
410
- out.error("ERROR", error.path);
411
- out.log(` ${error.error.message}`);
412
- out.log("");
413
- }
414
- if (result.errors.length > 5) {
415
- out.log(`... and ${result.errors.length - 5} more errors`);
416
- }
417
- }
418
- }
419
- } catch (error) {
420
- out.error("FAILED", "Sync failed");
421
- out.log(` ${error}`);
422
- out.exit(1);
423
- }
424
- process.exit();
425
- }
426
-
427
- /**
428
- * Show differences between local and remote
429
- */
430
- export async function diff(
431
- targetPath = ".",
432
- options: DiffOptions
433
- ): Promise<void> {
434
- const out = new Output();
435
-
436
- try {
437
- out.task("Analyzing changes");
438
-
439
- const { repo, syncEngine } = await setupCommandContext(
440
- targetPath,
441
- undefined,
442
- undefined,
443
- false
444
- );
445
- const preview = await syncEngine.previewChanges();
446
-
447
- out.done();
448
-
449
- if (options.nameOnly) {
450
- for (const change of preview.changes) {
451
- console.log(change.path);
452
- }
453
- return;
454
- }
455
-
456
- if (preview.changes.length === 0) {
457
- out.success("No changes detected");
458
- await safeRepoShutdown(repo, "diff");
459
- out.exit();
460
- return;
461
- }
462
-
463
- out.warn(`${preview.changes.length} changes detected`);
464
-
465
- for (const change of preview.changes) {
466
- const prefix =
467
- change.changeType === "local_only"
468
- ? "[local] "
469
- : change.changeType === "remote_only"
470
- ? "[remote] "
471
- : "[conflict]";
472
-
473
- if (!options.tool) {
474
- try {
475
- // Get old content (from snapshot/remote)
476
- const oldContent = change.remoteContent || "";
477
- // Get new content (current local)
478
- const newContent = change.localContent || "";
479
-
480
- // Convert binary content to string representation if needed
481
- const oldText =
482
- typeof oldContent === "string"
483
- ? oldContent
484
- : `<binary content: ${oldContent.length} bytes>`;
485
- const newText =
486
- typeof newContent === "string"
487
- ? newContent
488
- : `<binary content: ${newContent.length} bytes>`;
489
-
490
- // Generate unified diff
491
- const diffResult = diffLib.createPatch(
492
- change.path,
493
- oldText,
494
- newText,
495
- "previous",
496
- "current"
497
- );
498
-
499
- // Skip the header lines and process the diff
500
- const lines = diffResult.split("\n").slice(4); // Skip index, ===, ---, +++ lines
501
-
502
- if (lines.length === 0 || (lines.length === 1 && lines[0] === "")) {
503
- out.log(`${prefix}${change.path} (content identical)`, "cyan");
504
- continue;
505
- }
506
-
507
- // Extract first hunk header and show inline with path
508
- let firstHunk = "";
509
- let diffLines = lines;
510
- if (lines[0]?.startsWith("@@")) {
511
- firstHunk = ` ${lines[0]}`;
512
- diffLines = lines.slice(1);
513
- }
514
-
515
- out.log(`${prefix}${change.path}${firstHunk}`, "cyan");
516
-
517
- for (const line of diffLines) {
518
- if (line.startsWith("@@")) {
519
- // Additional hunk headers
520
- out.log(line, "dim");
521
- } else if (line.startsWith("+")) {
522
- // Added line
523
- out.log(line, "green");
524
- } else if (line.startsWith("-")) {
525
- // Removed line
526
- out.log(line, "red");
527
- } else if (line.startsWith(" ") || line === "") {
528
- // Context line or empty
529
- out.log(line, "dim");
530
- }
531
- }
532
- } catch (error) {
533
- out.log(`${prefix}${change.path} (diff error: ${error})`, "cyan");
534
- }
535
- } else {
536
- out.log(`${prefix} ${change.path}`);
537
- }
538
- }
539
-
540
- await safeRepoShutdown(repo, "diff");
541
- } catch (error) {
542
- out.error(`Diff failed: ${error}`);
543
- out.exit(1);
544
- }
545
- }
546
-
547
- /**
548
- * Show sync status
549
- */
550
- export async function status(
551
- targetPath: string = ".",
552
- options: StatusOptions = {}
553
- ): Promise<void> {
554
- const out = new Output();
555
-
556
- try {
557
- out.task("Loading status");
558
-
559
- const { repo, syncEngine, workingDir, config } = await setupCommandContext(
560
- targetPath,
561
- undefined,
562
- undefined,
563
- false
564
- );
565
- const syncStatus = await syncEngine.getStatus();
566
-
567
- out.done();
568
-
569
- out.info("STATUS", workingDir);
570
-
571
- if (syncStatus.snapshot?.rootDirectoryUrl) {
572
- out.pair("URL", syncStatus.snapshot.rootDirectoryUrl);
573
- }
574
-
575
- if (syncStatus.snapshot) {
576
- const fileCount = syncStatus.snapshot.files.size;
577
- out.pair("Files", `${fileCount} tracked`);
578
- }
579
-
580
- out.pair("Sync", config?.sync_server || "wss://sync3.automerge.org");
581
-
582
- if (syncStatus.hasChanges) {
583
- out.pair("Changes", `${syncStatus.changeCount} pending`);
584
- out.log("");
585
- out.log("Run 'pushwork diff' to see changes");
586
- } else {
587
- out.pair("Status", "up to date");
588
- }
589
-
590
- await safeRepoShutdown(repo, "status");
591
- } catch (error) {
592
- out.error(`Status check failed: ${error}`);
593
- out.exit(1);
594
- }
595
- }
596
-
597
- /**
598
- * Show sync history
599
- */
600
- export async function log(
601
- targetPath = ".",
602
- options: LogOptions
603
- ): Promise<void> {
604
- const out = new Output();
605
-
606
- try {
607
- const { repo: logRepo, workingDir } = await setupCommandContext(
608
- targetPath,
609
- undefined,
610
- undefined,
611
- false
612
- );
613
-
614
- // TODO: Implement history tracking
615
- const snapshotPath = path.join(workingDir, ".pushwork", "snapshot.json");
616
- if (await pathExists(snapshotPath)) {
617
- const stats = await fs.stat(snapshotPath);
618
- out.info("HISTORY", "Sync history (stub)");
619
- out.pair("Last sync", stats.mtime.toISOString());
620
- } else {
621
- out.info("No sync history found");
622
- }
623
-
624
- await safeRepoShutdown(logRepo, "log");
625
- } catch (error) {
626
- out.error(`Log failed: ${error}`);
627
- out.exit(1);
628
- }
629
- }
630
-
631
- /**
632
- * Checkout/restore from previous sync
633
- */
634
- export async function checkout(
635
- syncId: string,
636
- targetPath = ".",
637
- options: CheckoutOptions
638
- ): Promise<void> {
639
- const out = new Output();
640
-
641
- try {
642
- const { workingDir } = await setupCommandContext(targetPath);
643
-
644
- // TODO: Implement checkout functionality
645
- out.warn("NOT IMPLEMENTED", "Checkout not yet implemented");
646
- out.pair("Sync ID", syncId);
647
- out.pair("Path", workingDir);
648
- } catch (error) {
649
- out.error(`Checkout failed: ${error}`);
650
- out.exit(1);
651
- }
652
- }
653
-
654
- /**
655
- * Clone an existing synced directory from an AutomergeUrl
656
- */
657
- export async function clone(
658
- rootUrl: string,
659
- targetPath: string,
660
- options: CloneOptions
661
- ): Promise<void> {
662
- // Validate sync server options
663
- validateSyncServerOptions(options.syncServer, options.syncServerStorageId);
664
-
665
- const out = new Output();
666
-
667
- try {
668
- const resolvedPath = path.resolve(targetPath);
669
-
670
- out.task(`Cloning ${rootUrl}`);
671
-
672
- // Check if directory exists and handle --force
673
- if (await pathExists(resolvedPath)) {
674
- const files = await fs.readdir(resolvedPath);
675
- if (files.length > 0 && !options.force) {
676
- out.error("Target directory is not empty. Use --force to overwrite");
677
- out.exit(1);
678
- }
679
- } else {
680
- await ensureDirectoryExists(resolvedPath);
681
- }
682
-
683
- // Check if already initialized
684
- const syncToolDir = path.join(resolvedPath, ".pushwork");
685
- if (await pathExists(syncToolDir)) {
686
- if (!options.force) {
687
- out.error("Directory already initialized. Use --force to overwrite");
688
- out.exit(1);
689
- }
690
- await fs.rm(syncToolDir, { recursive: true, force: true });
691
- }
692
-
693
- out.update("Creating sync directory");
694
- await ensureDirectoryExists(syncToolDir);
695
- await ensureDirectoryExists(path.join(syncToolDir, "automerge"));
696
-
697
- out.update("Setting up configuration");
698
- const configManager = new ConfigManager(resolvedPath);
699
- const defaultSyncServer = options.syncServer || "wss://sync3.automerge.org";
700
- const defaultStorageId =
701
- options.syncServerStorageId || "3760df37-a4c6-4f66-9ecd-732039a9385d";
702
- const config: DirectoryConfig = {
703
- sync_server: defaultSyncServer,
704
- sync_server_storage_id: defaultStorageId,
705
- sync_enabled: true,
706
- defaults: {
707
- exclude_patterns: [".git", "node_modules", "*.tmp", ".pushwork"],
708
- large_file_threshold: "100MB",
709
- },
710
- diff: {
711
- show_binary: false,
712
- },
713
- sync: {
714
- move_detection_threshold: 0.8,
715
- prompt_threshold: 0.5,
716
- auto_sync: false,
717
- parallel_operations: 4,
718
- },
719
- };
720
- await configManager.save(config);
721
-
722
- out.update("Connecting to sync server");
723
- const repo = await createRepo(resolvedPath, {
724
- enableNetwork: true,
725
- syncServer: options.syncServer,
726
- syncServerStorageId: options.syncServerStorageId,
727
- });
728
-
729
- out.update("Downloading files");
730
- const syncEngine = new SyncEngine(
731
- repo,
732
- resolvedPath,
733
- config.defaults.exclude_patterns,
734
- true,
735
- defaultStorageId
736
- );
737
-
738
- await syncEngine.setRootDirectoryUrl(rootUrl as AutomergeUrl);
739
- const result = await syncEngine.sync(false);
740
-
741
- out.update("Writing to disk");
742
- await safeRepoShutdown(repo, "clone");
743
-
744
- out.done();
745
-
746
- out.pair("Path", resolvedPath);
747
- out.pair("Files", `${result.filesChanged} downloaded`);
748
- out.pair("Sync", defaultSyncServer);
749
- out.success("CLONED", rootUrl);
750
- } catch (error) {
751
- out.error("FAILED", "Clone failed");
752
- out.log(` ${error}`);
753
- out.exit(1);
754
- }
755
- process.exit();
756
- }
757
-
758
- /**
759
- * Get the root URL for the current pushwork repository
760
- */
761
- export async function url(
762
- targetPath: string = ".",
763
- options: UrlOptions = {}
764
- ): Promise<void> {
765
- const out = new Output();
766
-
767
- try {
768
- const resolvedPath = path.resolve(targetPath);
769
- const syncToolDir = path.join(resolvedPath, ".pushwork");
770
-
771
- if (!(await pathExists(syncToolDir))) {
772
- out.error("Directory not initialized for sync");
773
- out.exit(1);
774
- }
775
-
776
- const snapshotPath = path.join(syncToolDir, "snapshot.json");
777
- if (!(await pathExists(snapshotPath))) {
778
- out.error("No snapshot found");
779
- out.exit(1);
780
- }
781
-
782
- const snapshotData = await fs.readFile(snapshotPath, "utf-8");
783
- const snapshot = JSON.parse(snapshotData);
784
-
785
- if (snapshot.rootDirectoryUrl) {
786
- // Output just the URL for easy use in scripts
787
- console.log(snapshot.rootDirectoryUrl);
788
- } else {
789
- out.error("No root URL found in snapshot");
790
- out.exit(1);
791
- }
792
- } catch (error) {
793
- out.error(`Failed to get URL: ${error}`);
794
- out.exit(1);
795
- }
796
- }
797
-
798
- export async function commit(
799
- targetPath: string,
800
- options: CommitOptions = {}
801
- ): Promise<void> {
802
- const out = new Output();
803
-
804
- try {
805
- out.task("Committing local changes");
806
-
807
- const { repo, syncEngine } = await setupCommandContext(
808
- targetPath,
809
- undefined,
810
- undefined,
811
- false
812
- );
813
-
814
- const result = await syncEngine.commitLocal(options.dryRun || false);
815
- await safeRepoShutdown(repo, "commit");
816
-
817
- out.done();
818
-
819
- if (result.errors.length > 0) {
820
- out.error("ERRORS", `${result.errors.length} errors`);
821
- result.errors.forEach((error) => {
822
- out.log(` ${error.path}: ${error.error.message}`);
823
- });
824
- out.exit(1);
825
- }
826
-
827
- out.success("COMMITTED", `${result.filesChanged} files committed`);
828
- out.pair("Files", result.filesChanged.toString());
829
- out.pair("Directories", result.directoriesChanged.toString());
830
-
831
- if (result.warnings.length > 0) {
832
- out.log("");
833
- out.warn("WARNINGS", `${result.warnings.length} warnings`);
834
- result.warnings.forEach((warning: string) => out.log(` ${warning}`));
835
- }
836
- } catch (error) {
837
- out.error(`Commit failed: ${error}`);
838
- out.exit(1);
839
- }
840
- process.exit();
841
- }
842
-
843
- /**
844
- * Debug command to inspect internal document state
845
- */
846
- export async function debug(
847
- targetPath: string = ".",
848
- options: DebugOptions = {}
849
- ): Promise<void> {
850
- const out = new Output();
851
-
852
- try {
853
- out.task("Loading debug info");
854
-
855
- const { repo, syncEngine, workingDir } = await setupCommandContext(
856
- targetPath,
857
- undefined,
858
- undefined,
859
- false
860
- );
861
- const debugStatus = await syncEngine.getStatus();
862
-
863
- out.done("done");
864
-
865
- out.info("DEBUG", workingDir);
866
-
867
- if (debugStatus.snapshot?.rootDirectoryUrl) {
868
- out.pair("URL", debugStatus.snapshot.rootDirectoryUrl);
869
-
870
- try {
871
- const rootHandle = await repo.find<DirectoryDocument>(
872
- debugStatus.snapshot.rootDirectoryUrl
873
- );
874
- const rootDoc = await rootHandle.doc();
875
-
876
- if (rootDoc) {
877
- out.pair("Entries", rootDoc.docs.length.toString());
878
- if (rootDoc.lastSyncAt) {
879
- const lastSyncDate = new Date(rootDoc.lastSyncAt);
880
- out.pair("Last sync", lastSyncDate.toISOString());
881
- }
882
-
883
- if (options.verbose) {
884
- out.log("");
885
- out.log("Document:");
886
- out.log(JSON.stringify(rootDoc, null, 2));
887
- out.log("");
888
- out.log("Heads:");
889
- out.log(JSON.stringify(rootHandle.heads(), null, 2));
890
- }
891
- }
892
- } catch (error) {
893
- out.warn(`Error loading root document: ${error}`);
894
- }
895
- }
896
-
897
- if (debugStatus.snapshot) {
898
- out.pair("Files", debugStatus.snapshot.files.size.toString());
899
- out.pair("Directories", debugStatus.snapshot.directories.size.toString());
900
-
901
- if (options.verbose) {
902
- out.log("");
903
- out.log("All tracked files:");
904
- debugStatus.snapshot.files.forEach((entry, filePath) => {
905
- out.log(` ${filePath} -> ${entry.url}`);
906
- });
907
- }
908
- }
909
-
910
- await safeRepoShutdown(repo, "debug");
911
- } catch (error) {
912
- out.error(`Debug failed: ${error}`);
913
- out.exit(1);
914
- }
915
- }
916
-
917
- /**
918
- * List tracked files
919
- */
920
- export async function ls(
921
- targetPath: string = ".",
922
- options: ListOptions = {}
923
- ): Promise<void> {
924
- const out = new Output();
925
-
926
- try {
927
- const { repo, syncEngine } = await setupCommandContext(
928
- targetPath,
929
- undefined,
930
- undefined,
931
- false
932
- );
933
- const syncStatus = await syncEngine.getStatus();
934
-
935
- if (!syncStatus.snapshot) {
936
- out.error("No snapshot found");
937
- await safeRepoShutdown(repo, "ls");
938
- out.exit(1);
939
- return;
940
- }
941
-
942
- const files = Array.from(syncStatus.snapshot.files.entries()).sort(
943
- ([pathA], [pathB]) => pathA.localeCompare(pathB)
944
- );
945
-
946
- if (files.length === 0) {
947
- out.info("No tracked files");
948
- await safeRepoShutdown(repo, "ls");
949
- return;
950
- }
951
-
952
- if (options.long) {
953
- // Long format with URLs
954
- for (const [filePath, entry] of files) {
955
- const url = entry?.url || "unknown";
956
- console.log(`${filePath} -> ${url}`);
957
- }
958
- } else {
959
- // Simple list
960
- for (const [filePath] of files) {
961
- console.log(filePath);
962
- }
963
- }
964
-
965
- await safeRepoShutdown(repo, "ls");
966
- } catch (error) {
967
- out.error(`List failed: ${error}`);
968
- out.exit(1);
969
- }
970
- }
971
-
972
- /**
973
- * View or edit configuration
974
- */
975
- export async function config(
976
- targetPath: string = ".",
977
- options: ConfigOptions = {}
978
- ): Promise<void> {
979
- const out = new Output();
980
-
981
- try {
982
- const resolvedPath = path.resolve(targetPath);
983
- const syncToolDir = path.join(resolvedPath, ".pushwork");
984
-
985
- if (!(await pathExists(syncToolDir))) {
986
- out.error("Directory not initialized for sync");
987
- out.exit(1);
988
- }
989
-
990
- const configManager = new ConfigManager(resolvedPath);
991
- const config = await configManager.getMerged();
992
-
993
- if (options.list) {
994
- // List all configuration
995
- out.info("CONFIGURATION", "Full configuration");
996
- out.log(JSON.stringify(config, null, 2));
997
- } else if (options.get) {
998
- // Get specific config value
999
- const keys = options.get.split(".");
1000
- let value: any = config;
1001
- for (const key of keys) {
1002
- value = value?.[key];
1003
- }
1004
- if (value !== undefined) {
1005
- console.log(
1006
- typeof value === "object" ? JSON.stringify(value, null, 2) : value
1007
- );
1008
- } else {
1009
- out.error(`Config key not found: ${options.get}`);
1010
- out.exit(1);
1011
- }
1012
- } else {
1013
- // Show basic config info
1014
- out.info("CONFIGURATION", resolvedPath);
1015
- out.pair("Sync server", config.sync_server || "default");
1016
- out.pair("Sync enabled", config.sync_enabled ? "yes" : "no");
1017
- out.pair(
1018
- "Exclusions",
1019
- config.defaults.exclude_patterns.length.toString()
1020
- );
1021
- out.log("");
1022
- out.log("Use --list to see full configuration");
1023
- }
1024
- } catch (error) {
1025
- out.error(`Config failed: ${error}`);
1026
- out.exit(1);
1027
- }
1028
- }
1029
-
1030
- // TODO: Add push and pull commands later