pushwork 1.0.5 → 1.0.7

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 (204) hide show
  1. package/README.md +87 -335
  2. package/dist/.pushwork/automerge/3P/Dm3ekE2pmjGnWvDaG3vSR7ww98/snapshot/aa2349c94955ea561f698720142f9d884a6872d9f82dc332d578c216beb0df0e +0 -0
  3. package/dist/.pushwork/automerge/st/orage-adapter-id +1 -0
  4. package/dist/.pushwork/config.json +15 -0
  5. package/dist/.pushwork/snapshot.json +7 -0
  6. package/dist/cli.js +208 -213
  7. package/dist/cli.js.map +1 -1
  8. package/dist/commands.d.ts +51 -0
  9. package/dist/commands.d.ts.map +1 -0
  10. package/dist/commands.js +799 -0
  11. package/dist/commands.js.map +1 -0
  12. package/dist/core/change-detection.d.ts +2 -23
  13. package/dist/core/change-detection.d.ts.map +1 -1
  14. package/dist/core/change-detection.js +73 -115
  15. package/dist/core/change-detection.js.map +1 -1
  16. package/dist/{config/index.d.ts → core/config.d.ts} +13 -3
  17. package/dist/core/config.d.ts.map +1 -0
  18. package/dist/{config/index.js → core/config.js} +55 -73
  19. package/dist/core/config.js.map +1 -0
  20. package/dist/core/index.d.ts +1 -0
  21. package/dist/core/index.d.ts.map +1 -1
  22. package/dist/core/index.js +1 -1
  23. package/dist/core/index.js.map +1 -1
  24. package/dist/core/move-detection.d.ts +4 -3
  25. package/dist/core/move-detection.d.ts.map +1 -1
  26. package/dist/core/move-detection.js +8 -7
  27. package/dist/core/move-detection.js.map +1 -1
  28. package/dist/core/snapshot.d.ts +0 -4
  29. package/dist/core/snapshot.d.ts.map +1 -1
  30. package/dist/core/snapshot.js +2 -11
  31. package/dist/core/snapshot.js.map +1 -1
  32. package/dist/core/sync-engine.d.ts +5 -11
  33. package/dist/core/sync-engine.d.ts.map +1 -1
  34. package/dist/core/sync-engine.js +211 -308
  35. package/dist/core/sync-engine.js.map +1 -1
  36. package/dist/index.d.ts +0 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +0 -6
  39. package/dist/index.js.map +1 -1
  40. package/dist/types/config.d.ts +24 -88
  41. package/dist/types/config.d.ts.map +1 -1
  42. package/dist/types/config.js +6 -0
  43. package/dist/types/config.js.map +1 -1
  44. package/dist/types/documents.d.ts +15 -2
  45. package/dist/types/documents.d.ts.map +1 -1
  46. package/dist/types/documents.js.map +1 -1
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/types/index.js +0 -3
  49. package/dist/types/index.js.map +1 -1
  50. package/dist/types/snapshot.d.ts +0 -21
  51. package/dist/types/snapshot.d.ts.map +1 -1
  52. package/dist/types/snapshot.js +0 -14
  53. package/dist/types/snapshot.js.map +1 -1
  54. package/dist/utils/content.d.ts.map +1 -1
  55. package/dist/utils/content.js +2 -6
  56. package/dist/utils/content.js.map +1 -1
  57. package/dist/utils/directory.d.ts +10 -0
  58. package/dist/utils/directory.d.ts.map +1 -0
  59. package/dist/utils/directory.js +37 -0
  60. package/dist/utils/directory.js.map +1 -0
  61. package/dist/utils/fs.d.ts +15 -2
  62. package/dist/utils/fs.d.ts.map +1 -1
  63. package/dist/utils/fs.js +54 -20
  64. package/dist/utils/fs.js.map +1 -1
  65. package/dist/utils/index.d.ts +1 -0
  66. package/dist/utils/index.d.ts.map +1 -1
  67. package/dist/utils/index.js +1 -3
  68. package/dist/utils/index.js.map +1 -1
  69. package/dist/utils/mime-types.d.ts.map +1 -1
  70. package/dist/utils/mime-types.js +11 -4
  71. package/dist/utils/mime-types.js.map +1 -1
  72. package/dist/utils/network-sync.d.ts +0 -6
  73. package/dist/utils/network-sync.d.ts.map +1 -1
  74. package/dist/utils/network-sync.js +55 -99
  75. package/dist/utils/network-sync.js.map +1 -1
  76. package/dist/utils/output.d.ts +129 -0
  77. package/dist/utils/output.d.ts.map +1 -0
  78. package/dist/utils/output.js +375 -0
  79. package/dist/utils/output.js.map +1 -0
  80. package/dist/utils/repo-factory.d.ts +2 -6
  81. package/dist/utils/repo-factory.d.ts.map +1 -1
  82. package/dist/utils/repo-factory.js +8 -31
  83. package/dist/utils/repo-factory.js.map +1 -1
  84. package/dist/utils/string-similarity.js +2 -2
  85. package/dist/utils/string-similarity.js.map +1 -1
  86. package/dist/utils/trace.d.ts +19 -0
  87. package/dist/utils/trace.d.ts.map +1 -0
  88. package/dist/utils/trace.js +68 -0
  89. package/dist/utils/trace.js.map +1 -0
  90. package/package.json +11 -11
  91. package/src/cli.ts +276 -308
  92. package/src/commands.ts +988 -0
  93. package/src/core/change-detection.ts +182 -240
  94. package/src/{config/index.ts → core/config.ts} +65 -82
  95. package/src/core/index.ts +1 -1
  96. package/src/core/move-detection.ts +10 -8
  97. package/src/core/snapshot.ts +2 -12
  98. package/src/core/sync-engine.ts +237 -427
  99. package/src/index.ts +0 -10
  100. package/src/types/config.ts +28 -93
  101. package/src/types/documents.ts +16 -2
  102. package/src/types/index.ts +0 -5
  103. package/src/types/snapshot.ts +0 -23
  104. package/src/utils/content.ts +2 -6
  105. package/src/utils/directory.ts +50 -0
  106. package/src/utils/fs.ts +58 -23
  107. package/src/utils/index.ts +1 -5
  108. package/src/utils/mime-types.ts +12 -4
  109. package/src/utils/network-sync.ts +79 -137
  110. package/src/utils/output.ts +450 -0
  111. package/src/utils/repo-factory.ts +13 -44
  112. package/src/utils/string-similarity.ts +2 -2
  113. package/src/utils/trace.ts +70 -0
  114. package/test/integration/exclude-patterns.test.ts +6 -15
  115. package/test/integration/fuzzer.test.ts +308 -391
  116. package/test/integration/init-sync.test.ts +89 -0
  117. package/test/integration/sync-deletion.test.ts +2 -61
  118. package/test/integration/sync-flow.test.ts +4 -24
  119. package/test/jest.setup.ts +34 -0
  120. package/test/unit/deletion-behavior.test.ts +3 -14
  121. package/test/unit/enhanced-mime-detection.test.ts +0 -22
  122. package/test/unit/snapshot.test.ts +2 -29
  123. package/test/unit/sync-convergence.test.ts +3 -198
  124. package/test/unit/sync-timing.test.ts +0 -44
  125. package/test/unit/utils.test.ts +0 -2
  126. package/tsconfig.json +3 -3
  127. package/bench/filesystem.bench.ts +0 -78
  128. package/bench/hashing.bench.ts +0 -60
  129. package/bench/move-detection.bench.ts +0 -130
  130. package/bench/runner.ts +0 -49
  131. package/dist/browser/browser-sync-engine.d.ts +0 -64
  132. package/dist/browser/browser-sync-engine.d.ts.map +0 -1
  133. package/dist/browser/browser-sync-engine.js +0 -303
  134. package/dist/browser/browser-sync-engine.js.map +0 -1
  135. package/dist/browser/filesystem-adapter.d.ts +0 -84
  136. package/dist/browser/filesystem-adapter.d.ts.map +0 -1
  137. package/dist/browser/filesystem-adapter.js +0 -413
  138. package/dist/browser/filesystem-adapter.js.map +0 -1
  139. package/dist/browser/index.d.ts +0 -36
  140. package/dist/browser/index.d.ts.map +0 -1
  141. package/dist/browser/index.js +0 -90
  142. package/dist/browser/index.js.map +0 -1
  143. package/dist/browser/types.d.ts +0 -70
  144. package/dist/browser/types.d.ts.map +0 -1
  145. package/dist/browser/types.js +0 -6
  146. package/dist/browser/types.js.map +0 -1
  147. package/dist/cli/commands.d.ts +0 -67
  148. package/dist/cli/commands.d.ts.map +0 -1
  149. package/dist/cli/commands.js +0 -794
  150. package/dist/cli/commands.js.map +0 -1
  151. package/dist/cli/index.d.ts +0 -2
  152. package/dist/cli/index.d.ts.map +0 -1
  153. package/dist/cli/index.js +0 -19
  154. package/dist/cli/index.js.map +0 -1
  155. package/dist/cli/output.d.ts +0 -75
  156. package/dist/cli/output.d.ts.map +0 -1
  157. package/dist/cli/output.js +0 -182
  158. package/dist/cli/output.js.map +0 -1
  159. package/dist/config/index.d.ts.map +0 -1
  160. package/dist/config/index.js.map +0 -1
  161. package/dist/config/remote-manager.d.ts +0 -65
  162. package/dist/config/remote-manager.d.ts.map +0 -1
  163. package/dist/config/remote-manager.js +0 -243
  164. package/dist/config/remote-manager.js.map +0 -1
  165. package/dist/core/isomorphic-snapshot.d.ts +0 -58
  166. package/dist/core/isomorphic-snapshot.d.ts.map +0 -1
  167. package/dist/core/isomorphic-snapshot.js +0 -204
  168. package/dist/core/isomorphic-snapshot.js.map +0 -1
  169. package/dist/platform/browser-filesystem.d.ts +0 -26
  170. package/dist/platform/browser-filesystem.d.ts.map +0 -1
  171. package/dist/platform/browser-filesystem.js +0 -91
  172. package/dist/platform/browser-filesystem.js.map +0 -1
  173. package/dist/platform/filesystem.d.ts +0 -29
  174. package/dist/platform/filesystem.d.ts.map +0 -1
  175. package/dist/platform/filesystem.js +0 -65
  176. package/dist/platform/filesystem.js.map +0 -1
  177. package/dist/platform/node-filesystem.d.ts +0 -21
  178. package/dist/platform/node-filesystem.d.ts.map +0 -1
  179. package/dist/platform/node-filesystem.js +0 -93
  180. package/dist/platform/node-filesystem.js.map +0 -1
  181. package/dist/utils/content-similarity.d.ts +0 -53
  182. package/dist/utils/content-similarity.d.ts.map +0 -1
  183. package/dist/utils/content-similarity.js +0 -155
  184. package/dist/utils/content-similarity.js.map +0 -1
  185. package/dist/utils/fs-browser.d.ts +0 -57
  186. package/dist/utils/fs-browser.d.ts.map +0 -1
  187. package/dist/utils/fs-browser.js +0 -311
  188. package/dist/utils/fs-browser.js.map +0 -1
  189. package/dist/utils/fs-node.d.ts +0 -53
  190. package/dist/utils/fs-node.d.ts.map +0 -1
  191. package/dist/utils/fs-node.js +0 -220
  192. package/dist/utils/fs-node.js.map +0 -1
  193. package/dist/utils/isomorphic.d.ts +0 -29
  194. package/dist/utils/isomorphic.d.ts.map +0 -1
  195. package/dist/utils/isomorphic.js +0 -139
  196. package/dist/utils/isomorphic.js.map +0 -1
  197. package/dist/utils/pure.d.ts +0 -25
  198. package/dist/utils/pure.d.ts.map +0 -1
  199. package/dist/utils/pure.js +0 -112
  200. package/dist/utils/pure.js.map +0 -1
  201. package/src/cli/commands.ts +0 -1030
  202. package/src/cli/index.ts +0 -2
  203. package/src/cli/output.ts +0 -244
  204. 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