pushwork 2.0.0-a.sub.1 → 2.0.0-preview

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 (251) hide show
  1. package/dist/branches.d.ts +19 -0
  2. package/dist/branches.d.ts.map +1 -0
  3. package/dist/branches.js +111 -0
  4. package/dist/branches.js.map +1 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +238 -272
  8. package/dist/cli.js.map +1 -1
  9. package/dist/config.d.ts +17 -0
  10. package/dist/config.d.ts.map +1 -0
  11. package/dist/config.js +84 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/fs-tree.d.ts +6 -0
  14. package/dist/fs-tree.d.ts.map +1 -0
  15. package/dist/fs-tree.js +99 -0
  16. package/dist/fs-tree.js.map +1 -0
  17. package/dist/ignore.d.ts +6 -0
  18. package/dist/ignore.d.ts.map +1 -0
  19. package/dist/ignore.js +74 -0
  20. package/dist/ignore.js.map +1 -0
  21. package/dist/index.d.ts +8 -4
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +34 -4
  24. package/dist/index.js.map +1 -1
  25. package/dist/log.d.ts +3 -0
  26. package/dist/log.d.ts.map +1 -0
  27. package/dist/log.js +14 -0
  28. package/dist/log.js.map +1 -0
  29. package/dist/pushwork.d.ts +115 -0
  30. package/dist/pushwork.d.ts.map +1 -0
  31. package/dist/pushwork.js +918 -0
  32. package/dist/pushwork.js.map +1 -0
  33. package/dist/repo.d.ts +14 -0
  34. package/dist/repo.d.ts.map +1 -0
  35. package/dist/repo.js +60 -0
  36. package/dist/repo.js.map +1 -0
  37. package/dist/shapes/custom.d.ts +3 -0
  38. package/dist/shapes/custom.d.ts.map +1 -0
  39. package/dist/shapes/custom.js +57 -0
  40. package/dist/shapes/custom.js.map +1 -0
  41. package/dist/shapes/file.d.ts +20 -0
  42. package/dist/shapes/file.d.ts.map +1 -0
  43. package/dist/shapes/file.js +140 -0
  44. package/dist/shapes/file.js.map +1 -0
  45. package/dist/shapes/index.d.ts +10 -0
  46. package/dist/shapes/index.d.ts.map +1 -0
  47. package/dist/shapes/index.js +35 -0
  48. package/dist/shapes/index.js.map +1 -0
  49. package/dist/shapes/patchwork-folder.d.ts +3 -0
  50. package/dist/shapes/patchwork-folder.d.ts.map +1 -0
  51. package/dist/shapes/patchwork-folder.js +160 -0
  52. package/dist/shapes/patchwork-folder.js.map +1 -0
  53. package/dist/shapes/types.d.ts +37 -0
  54. package/dist/shapes/types.d.ts.map +1 -0
  55. package/dist/shapes/types.js +52 -0
  56. package/dist/shapes/types.js.map +1 -0
  57. package/dist/shapes/vfs.d.ts +3 -0
  58. package/dist/shapes/vfs.d.ts.map +1 -0
  59. package/dist/shapes/vfs.js +88 -0
  60. package/dist/shapes/vfs.js.map +1 -0
  61. package/dist/stash.d.ts +23 -0
  62. package/dist/stash.d.ts.map +1 -0
  63. package/dist/stash.js +118 -0
  64. package/dist/stash.js.map +1 -0
  65. package/flake.lock +128 -0
  66. package/flake.nix +66 -0
  67. package/package.json +15 -48
  68. package/patches/@automerge__automerge-repo@2.6.0-subduction.15.patch +26 -0
  69. package/pnpm-workspace.yaml +5 -0
  70. package/src/branches.ts +93 -0
  71. package/src/cli.ts +258 -408
  72. package/src/config.ts +64 -0
  73. package/src/fs-tree.ts +70 -0
  74. package/src/ignore.ts +33 -0
  75. package/src/index.ts +38 -4
  76. package/src/log.ts +8 -0
  77. package/src/pushwork.ts +1055 -0
  78. package/src/repo.ts +76 -0
  79. package/src/shapes/custom.ts +29 -0
  80. package/src/shapes/file.ts +115 -0
  81. package/src/shapes/index.ts +19 -0
  82. package/src/shapes/patchwork-folder.ts +156 -0
  83. package/src/shapes/types.ts +79 -0
  84. package/src/shapes/vfs.ts +93 -0
  85. package/src/stash.ts +106 -0
  86. package/test/integration/branches.test.ts +389 -0
  87. package/test/integration/pushwork.test.ts +547 -0
  88. package/test/setup.ts +29 -0
  89. package/test/unit/doc-shape.test.ts +612 -0
  90. package/tsconfig.json +2 -3
  91. package/vitest.config.ts +14 -0
  92. package/ARCHITECTURE-ACCORDING-TO-CLAUDE.md +0 -248
  93. package/CLAUDE.md +0 -141
  94. package/README.md +0 -221
  95. package/babel.config.js +0 -5
  96. package/dist/cli/commands.d.ts +0 -71
  97. package/dist/cli/commands.d.ts.map +0 -1
  98. package/dist/cli/commands.js +0 -794
  99. package/dist/cli/commands.js.map +0 -1
  100. package/dist/cli/index.d.ts +0 -2
  101. package/dist/cli/index.d.ts.map +0 -1
  102. package/dist/cli/index.js +0 -19
  103. package/dist/cli/index.js.map +0 -1
  104. package/dist/commands.d.ts +0 -61
  105. package/dist/commands.d.ts.map +0 -1
  106. package/dist/commands.js +0 -861
  107. package/dist/commands.js.map +0 -1
  108. package/dist/config/index.d.ts +0 -71
  109. package/dist/config/index.d.ts.map +0 -1
  110. package/dist/config/index.js +0 -314
  111. package/dist/config/index.js.map +0 -1
  112. package/dist/core/change-detection.d.ts +0 -80
  113. package/dist/core/change-detection.d.ts.map +0 -1
  114. package/dist/core/change-detection.js +0 -523
  115. package/dist/core/change-detection.js.map +0 -1
  116. package/dist/core/config.d.ts +0 -81
  117. package/dist/core/config.d.ts.map +0 -1
  118. package/dist/core/config.js +0 -258
  119. package/dist/core/config.js.map +0 -1
  120. package/dist/core/index.d.ts +0 -6
  121. package/dist/core/index.d.ts.map +0 -1
  122. package/dist/core/index.js +0 -6
  123. package/dist/core/index.js.map +0 -1
  124. package/dist/core/move-detection.d.ts +0 -34
  125. package/dist/core/move-detection.d.ts.map +0 -1
  126. package/dist/core/move-detection.js +0 -121
  127. package/dist/core/move-detection.js.map +0 -1
  128. package/dist/core/snapshot.d.ts +0 -105
  129. package/dist/core/snapshot.d.ts.map +0 -1
  130. package/dist/core/snapshot.js +0 -217
  131. package/dist/core/snapshot.js.map +0 -1
  132. package/dist/core/sync-engine.d.ts +0 -157
  133. package/dist/core/sync-engine.d.ts.map +0 -1
  134. package/dist/core/sync-engine.js +0 -1379
  135. package/dist/core/sync-engine.js.map +0 -1
  136. package/dist/types/config.d.ts +0 -99
  137. package/dist/types/config.d.ts.map +0 -1
  138. package/dist/types/config.js +0 -5
  139. package/dist/types/config.js.map +0 -1
  140. package/dist/types/documents.d.ts +0 -88
  141. package/dist/types/documents.d.ts.map +0 -1
  142. package/dist/types/documents.js +0 -20
  143. package/dist/types/documents.js.map +0 -1
  144. package/dist/types/index.d.ts +0 -4
  145. package/dist/types/index.d.ts.map +0 -1
  146. package/dist/types/index.js +0 -4
  147. package/dist/types/index.js.map +0 -1
  148. package/dist/types/snapshot.d.ts +0 -64
  149. package/dist/types/snapshot.d.ts.map +0 -1
  150. package/dist/types/snapshot.js +0 -2
  151. package/dist/types/snapshot.js.map +0 -1
  152. package/dist/utils/content-similarity.d.ts +0 -53
  153. package/dist/utils/content-similarity.d.ts.map +0 -1
  154. package/dist/utils/content-similarity.js +0 -155
  155. package/dist/utils/content-similarity.js.map +0 -1
  156. package/dist/utils/content.d.ts +0 -10
  157. package/dist/utils/content.d.ts.map +0 -1
  158. package/dist/utils/content.js +0 -31
  159. package/dist/utils/content.js.map +0 -1
  160. package/dist/utils/directory.d.ts +0 -24
  161. package/dist/utils/directory.d.ts.map +0 -1
  162. package/dist/utils/directory.js +0 -52
  163. package/dist/utils/directory.js.map +0 -1
  164. package/dist/utils/fs.d.ts +0 -74
  165. package/dist/utils/fs.d.ts.map +0 -1
  166. package/dist/utils/fs.js +0 -248
  167. package/dist/utils/fs.js.map +0 -1
  168. package/dist/utils/index.d.ts +0 -5
  169. package/dist/utils/index.d.ts.map +0 -1
  170. package/dist/utils/index.js +0 -5
  171. package/dist/utils/index.js.map +0 -1
  172. package/dist/utils/mime-types.d.ts +0 -13
  173. package/dist/utils/mime-types.d.ts.map +0 -1
  174. package/dist/utils/mime-types.js +0 -209
  175. package/dist/utils/mime-types.js.map +0 -1
  176. package/dist/utils/network-sync.d.ts +0 -36
  177. package/dist/utils/network-sync.d.ts.map +0 -1
  178. package/dist/utils/network-sync.js +0 -250
  179. package/dist/utils/network-sync.js.map +0 -1
  180. package/dist/utils/node-polyfills.d.ts +0 -9
  181. package/dist/utils/node-polyfills.d.ts.map +0 -1
  182. package/dist/utils/node-polyfills.js +0 -9
  183. package/dist/utils/node-polyfills.js.map +0 -1
  184. package/dist/utils/output.d.ts +0 -129
  185. package/dist/utils/output.d.ts.map +0 -1
  186. package/dist/utils/output.js +0 -368
  187. package/dist/utils/output.js.map +0 -1
  188. package/dist/utils/repo-factory.d.ts +0 -13
  189. package/dist/utils/repo-factory.d.ts.map +0 -1
  190. package/dist/utils/repo-factory.js +0 -46
  191. package/dist/utils/repo-factory.js.map +0 -1
  192. package/dist/utils/string-similarity.d.ts +0 -14
  193. package/dist/utils/string-similarity.d.ts.map +0 -1
  194. package/dist/utils/string-similarity.js +0 -39
  195. package/dist/utils/string-similarity.js.map +0 -1
  196. package/dist/utils/text-diff.d.ts +0 -37
  197. package/dist/utils/text-diff.d.ts.map +0 -1
  198. package/dist/utils/text-diff.js +0 -93
  199. package/dist/utils/text-diff.js.map +0 -1
  200. package/dist/utils/trace.d.ts +0 -19
  201. package/dist/utils/trace.d.ts.map +0 -1
  202. package/dist/utils/trace.js +0 -63
  203. package/dist/utils/trace.js.map +0 -1
  204. package/src/commands.ts +0 -1134
  205. package/src/core/change-detection.ts +0 -712
  206. package/src/core/config.ts +0 -313
  207. package/src/core/index.ts +0 -5
  208. package/src/core/move-detection.ts +0 -169
  209. package/src/core/snapshot.ts +0 -275
  210. package/src/core/sync-engine.ts +0 -1795
  211. package/src/types/config.ts +0 -111
  212. package/src/types/documents.ts +0 -91
  213. package/src/types/index.ts +0 -3
  214. package/src/types/snapshot.ts +0 -67
  215. package/src/utils/content.ts +0 -34
  216. package/src/utils/directory.ts +0 -73
  217. package/src/utils/fs.ts +0 -297
  218. package/src/utils/index.ts +0 -4
  219. package/src/utils/mime-types.ts +0 -244
  220. package/src/utils/network-sync.ts +0 -319
  221. package/src/utils/node-polyfills.ts +0 -8
  222. package/src/utils/output.ts +0 -450
  223. package/src/utils/repo-factory.ts +0 -73
  224. package/src/utils/string-similarity.ts +0 -54
  225. package/src/utils/text-diff.ts +0 -101
  226. package/src/utils/trace.ts +0 -70
  227. package/test/integration/README.md +0 -328
  228. package/test/integration/clone-test.sh +0 -310
  229. package/test/integration/conflict-resolution-test.sh +0 -309
  230. package/test/integration/debug-both-nested.sh +0 -74
  231. package/test/integration/debug-concurrent-nested.sh +0 -87
  232. package/test/integration/debug-nested.sh +0 -73
  233. package/test/integration/deletion-behavior-test.sh +0 -487
  234. package/test/integration/deletion-sync-test-simple.sh +0 -193
  235. package/test/integration/deletion-sync-test.sh +0 -297
  236. package/test/integration/exclude-patterns.test.ts +0 -144
  237. package/test/integration/full-integration-test.sh +0 -363
  238. package/test/integration/fuzzer.test.ts +0 -818
  239. package/test/integration/in-memory-sync.test.ts +0 -830
  240. package/test/integration/init-sync.test.ts +0 -89
  241. package/test/integration/manual-sync-test.sh +0 -84
  242. package/test/integration/sync-deletion.test.ts +0 -280
  243. package/test/integration/sync-flow.test.ts +0 -291
  244. package/test/jest.setup.ts +0 -34
  245. package/test/run-tests.sh +0 -225
  246. package/test/unit/deletion-behavior.test.ts +0 -249
  247. package/test/unit/enhanced-mime-detection.test.ts +0 -244
  248. package/test/unit/snapshot.test.ts +0 -404
  249. package/test/unit/sync-convergence.test.ts +0 -298
  250. package/test/unit/sync-timing.test.ts +0 -134
  251. package/test/unit/utils.test.ts +0 -366
package/dist/commands.js DELETED
@@ -1,861 +0,0 @@
1
- import * as path from "path";
2
- import * as fs from "fs/promises";
3
- import * as fsSync from "fs";
4
- import * as diffLib from "diff";
5
- import { spawn } from "child_process";
6
- import { SyncEngine } from "./core/index.js";
7
- import { pathExists, ensureDirectoryExists, formatRelativePath } from "./utils/index.js";
8
- import { ConfigManager } from "./core/config.js";
9
- import { createRepo, createEphemeralRepo } from "./utils/repo-factory.js";
10
- import { out } from "./utils/output.js";
11
- import { waitForSync } from "./utils/network-sync.js";
12
- import { readDocContent } from "./utils/text-diff.js";
13
- import { DEFAULT_SYNC_SERVER } from "./types/config.js";
14
- import chalk from "chalk";
15
- /**
16
- * Initialize repository directory structure and configuration
17
- * Shared logic for init and clone commands
18
- */
19
- async function initializeRepository(resolvedPath, overrides) {
20
- // Create .pushwork directory structure
21
- const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR);
22
- await ensureDirectoryExists(syncToolDir);
23
- await ensureDirectoryExists(path.join(syncToolDir, "automerge"));
24
- // Create configuration with overrides
25
- const configManager = new ConfigManager(resolvedPath);
26
- const config = await configManager.initializeWithOverrides(overrides);
27
- // Create repository and sync engine
28
- const repo = await createRepo(resolvedPath, config);
29
- const syncEngine = new SyncEngine(repo, resolvedPath, config);
30
- return { config, repo, syncEngine };
31
- }
32
- /**
33
- * Shared pre-action that ensures repository and sync engine are properly initialized
34
- * This function always works, with or without network connectivity
35
- */
36
- async function setupCommandContext(workingDir = process.cwd(), options) {
37
- const resolvedPath = path.resolve(workingDir);
38
- // Check if initialized
39
- const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR);
40
- if (!(await pathExists(syncToolDir))) {
41
- throw new Error('Directory not initialized for sync. Run "pushwork init" first.');
42
- }
43
- // Load configuration
44
- const configManager = new ConfigManager(resolvedPath);
45
- let config;
46
- if (options?.forceDefaults) {
47
- // Force mode: use defaults, only preserving root_directory_url from local config
48
- const localConfig = await configManager.load();
49
- config = configManager.getDefaultDirectoryConfig();
50
- if (localConfig?.root_directory_url) {
51
- config.root_directory_url = localConfig.root_directory_url;
52
- }
53
- }
54
- else {
55
- config = await configManager.getMerged();
56
- }
57
- // Override sync_enabled if explicitly specified (e.g., for local-only operations)
58
- if (options?.syncEnabled !== undefined) {
59
- config = { ...config, sync_enabled: options.syncEnabled };
60
- }
61
- // Create repo with config
62
- const repo = await createRepo(resolvedPath, config);
63
- // Create sync engine
64
- const syncEngine = new SyncEngine(repo, resolvedPath, config);
65
- return {
66
- repo,
67
- syncEngine,
68
- config,
69
- workingDir: resolvedPath,
70
- };
71
- }
72
- /**
73
- * Safely shutdown a repository with proper error handling
74
- */
75
- async function safeRepoShutdown(repo) {
76
- // Handle uncaught WebSocket errors that occur during shutdown
77
- const uncaughtErrorHandler = (err) => {
78
- if (err.message.includes("WebSocket")) {
79
- // Silently suppress WebSocket errors during shutdown
80
- return;
81
- }
82
- // Re-throw non-WebSocket errors
83
- throw err;
84
- };
85
- // Add the error handler before shutdown
86
- process.on("uncaughtException", uncaughtErrorHandler);
87
- try {
88
- await repo.shutdown();
89
- }
90
- catch (shutdownError) {
91
- // WebSocket errors during shutdown are common and non-critical
92
- // Silently ignore them - they don't affect data integrity
93
- const errorMessage = shutdownError instanceof Error
94
- ? shutdownError.message
95
- : String(shutdownError);
96
- // Ignore WebSocket-related errors entirely
97
- if (errorMessage.includes("WebSocket")) {
98
- // Silently ignore WebSocket shutdown errors
99
- return;
100
- }
101
- }
102
- finally {
103
- process.off("uncaughtException", uncaughtErrorHandler);
104
- }
105
- }
106
- /**
107
- * Initialize sync in a directory
108
- */
109
- export async function init(targetPath, options = {}) {
110
- const resolvedPath = path.resolve(targetPath);
111
- out.task(`Initializing`);
112
- await ensureDirectoryExists(resolvedPath);
113
- // Check if already initialized
114
- const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR);
115
- if (await pathExists(syncToolDir)) {
116
- out.error("Directory already initialized for sync");
117
- out.exit(1);
118
- }
119
- // Initialize repository with optional CLI overrides
120
- out.update("Setting up repository");
121
- const { repo, syncEngine, config } = await initializeRepository(resolvedPath, {
122
- sync_server: options.syncServer,
123
- });
124
- // Create new root directory document
125
- out.update("Creating root directory");
126
- const dirName = path.basename(resolvedPath);
127
- const rootDoc = {
128
- "@patchwork": { type: "folder" },
129
- name: dirName,
130
- title: dirName,
131
- docs: [],
132
- };
133
- const rootHandle = repo.create(rootDoc);
134
- // Set root directory URL in snapshot
135
- await syncEngine.setRootDirectoryUrl(rootHandle.url);
136
- // Wait for root document to sync to server if sync is enabled
137
- // This ensures the document is uploaded before we exit
138
- // waitForSync() verifies the server has the document by comparing local and remote heads
139
- if (config.sync_enabled && config.sync_server) {
140
- out.update("Syncing to server");
141
- const { failed } = await waitForSync([rootHandle]);
142
- if (failed.length > 0) {
143
- out.taskLine("Root document failed to sync to server", true);
144
- // Continue anyway - the document is created locally and will sync later
145
- }
146
- }
147
- // Run initial sync to capture existing files
148
- out.update("Running initial sync");
149
- const result = await syncEngine.sync();
150
- out.update("Writing to disk");
151
- await safeRepoShutdown(repo);
152
- out.done("Initialized");
153
- out.successBlock("INITIALIZED", rootHandle.url);
154
- if (result.filesChanged > 0) {
155
- out.info(`Synced ${result.filesChanged} ${plural("file", result.filesChanged)}`);
156
- }
157
- process.exit();
158
- }
159
- /**
160
- * Run bidirectional sync
161
- */
162
- export async function sync(targetPath = ".", options) {
163
- out.task(options.nuclear
164
- ? "Nuclear syncing"
165
- : options.gentle
166
- ? "Gentle syncing"
167
- : "Syncing");
168
- const { repo, syncEngine } = await setupCommandContext(targetPath, {
169
- forceDefaults: !options.gentle,
170
- });
171
- if (options.nuclear) {
172
- await syncEngine.nuclearReset();
173
- }
174
- if (options.dryRun) {
175
- out.update("Analyzing changes");
176
- const preview = await syncEngine.previewChanges();
177
- if (preview.changes.length === 0 && preview.moves.length === 0) {
178
- out.done("Already synced");
179
- return;
180
- }
181
- out.done();
182
- out.infoBlock("CHANGES");
183
- out.obj({
184
- Changes: preview.changes.length.toString(),
185
- Moves: preview.moves.length > 0 ? preview.moves.length.toString() : undefined,
186
- });
187
- out.log("");
188
- out.log("Files:");
189
- for (const change of preview.changes.slice(0, 10)) {
190
- const prefix = change.changeType === "local_only"
191
- ? "[local] "
192
- : change.changeType === "remote_only"
193
- ? "[remote] "
194
- : "[conflict]";
195
- out.log(` ${prefix} ${change.path}`);
196
- }
197
- if (preview.changes.length > 10) {
198
- out.log(` ... and ${preview.changes.length - 10} more`);
199
- }
200
- if (preview.moves.length > 0) {
201
- out.log("");
202
- out.log("Moves:");
203
- for (const move of preview.moves.slice(0, 5)) {
204
- out.log(` ${move.fromPath} → ${move.toPath}`);
205
- }
206
- if (preview.moves.length > 5) {
207
- out.log(` ... and ${preview.moves.length - 5} more`);
208
- }
209
- }
210
- out.log("");
211
- out.log("Run without --dry-run to apply these changes");
212
- }
213
- else {
214
- const result = await syncEngine.sync();
215
- out.taskLine("Writing to disk");
216
- await safeRepoShutdown(repo);
217
- if (result.success) {
218
- out.done("Synced");
219
- if (result.filesChanged === 0 && result.directoriesChanged === 0) {
220
- }
221
- else {
222
- out.successBlock("SYNCED", `${result.filesChanged} ${plural("file", result.filesChanged)}`);
223
- }
224
- if (result.warnings.length > 0) {
225
- out.log("");
226
- out.warnBlock("WARNINGS", `${result.warnings.length} warnings`);
227
- for (const warning of result.warnings.slice(0, 5)) {
228
- out.log(` ${warning}`);
229
- }
230
- if (result.warnings.length > 5) {
231
- out.log(` ... and ${result.warnings.length - 5} more`);
232
- }
233
- }
234
- }
235
- else {
236
- out.done("partial", false);
237
- out.warnBlock("PARTIAL", `${result.filesChanged} updated, ${result.errors.length} errors`);
238
- out.obj({
239
- Files: result.filesChanged,
240
- Errors: result.errors.length,
241
- });
242
- result.errors
243
- .slice(0, 5)
244
- .forEach((error) => out.error(`${error.path}: ${error.error.message}`));
245
- if (result.errors.length > 5) {
246
- out.warn(`... and ${result.errors.length - 5} more errors`);
247
- }
248
- }
249
- }
250
- process.exit();
251
- }
252
- /**
253
- * Show differences between local and remote
254
- */
255
- export async function diff(targetPath = ".", options) {
256
- out.task("Analyzing changes");
257
- const { repo, syncEngine } = await setupCommandContext(targetPath, { syncEnabled: false });
258
- const preview = await syncEngine.previewChanges();
259
- out.done();
260
- if (options.nameOnly) {
261
- for (const change of preview.changes) {
262
- out.log(change.path);
263
- }
264
- return;
265
- }
266
- if (preview.changes.length === 0) {
267
- out.success("No changes detected");
268
- await safeRepoShutdown(repo);
269
- out.exit();
270
- return;
271
- }
272
- out.warn(`${preview.changes.length} changes detected`);
273
- for (const change of preview.changes) {
274
- const prefix = change.changeType === "local_only"
275
- ? "[local] "
276
- : change.changeType === "remote_only"
277
- ? "[remote] "
278
- : "[conflict]";
279
- try {
280
- // Get old content (from snapshot/remote)
281
- const oldContent = change.remoteContent || "";
282
- // Get new content (current local)
283
- const newContent = change.localContent || "";
284
- // Convert binary content to string representation if needed
285
- const oldText = typeof oldContent === "string"
286
- ? oldContent
287
- : `<binary content: ${oldContent.length} bytes>`;
288
- const newText = typeof newContent === "string"
289
- ? newContent
290
- : `<binary content: ${newContent.length} bytes>`;
291
- // Generate unified diff
292
- const diffResult = diffLib.createPatch(change.path, oldText, newText, "previous", "current");
293
- // Skip the header lines and process the diff
294
- const lines = diffResult.split("\n").slice(4); // Skip index, ===, ---, +++ lines
295
- if (lines.length === 0 || (lines.length === 1 && lines[0] === "")) {
296
- out.log(`${prefix}${change.path} (content identical)`, "cyan");
297
- continue;
298
- }
299
- // Extract first hunk header and show inline with path
300
- let firstHunk = "";
301
- let diffLines = lines;
302
- if (lines[0]?.startsWith("@@")) {
303
- firstHunk = ` ${lines[0]}`;
304
- diffLines = lines.slice(1);
305
- }
306
- out.log(`${prefix}${change.path}${firstHunk}`, "cyan");
307
- for (const line of diffLines) {
308
- if (line.startsWith("@@")) {
309
- // Additional hunk headers
310
- out.log(line, "dim");
311
- }
312
- else if (line.startsWith("+")) {
313
- // Added line
314
- out.log(line, "green");
315
- }
316
- else if (line.startsWith("-")) {
317
- // Removed line
318
- out.log(line, "red");
319
- }
320
- else if (line.startsWith(" ") || line === "") {
321
- // Context line or empty
322
- out.log(line, "dim");
323
- }
324
- }
325
- }
326
- catch (error) {
327
- out.log(`${prefix}${change.path} (diff error: ${error})`, "cyan");
328
- }
329
- }
330
- await safeRepoShutdown(repo);
331
- }
332
- /**
333
- * Show sync status
334
- */
335
- export async function status(targetPath = ".", options = {}) {
336
- const { repo, syncEngine, config } = await setupCommandContext(targetPath, { syncEnabled: false });
337
- const syncStatus = await syncEngine.getStatus();
338
- out.infoBlock("STATUS");
339
- const statusInfo = {};
340
- const fileCount = syncStatus.snapshot?.files.size || 0;
341
- statusInfo["URL"] = syncStatus.snapshot?.rootDirectoryUrl;
342
- statusInfo["Files"] = syncStatus.snapshot
343
- ? `${fileCount} tracked`
344
- : undefined;
345
- statusInfo["Sync"] = config?.sync_server;
346
- // Add more detailed info in verbose mode
347
- if (options.verbose && syncStatus.snapshot?.rootDirectoryUrl) {
348
- try {
349
- const rootHandle = await repo.find(syncStatus.snapshot.rootDirectoryUrl);
350
- const rootDoc = await rootHandle.doc();
351
- if (rootDoc) {
352
- statusInfo["Entries"] = rootDoc.docs.length;
353
- statusInfo["Directories"] = syncStatus.snapshot.directories.size;
354
- if (rootDoc.lastSyncAt) {
355
- const lastSyncDate = new Date(rootDoc.lastSyncAt);
356
- statusInfo["Last sync"] = lastSyncDate.toISOString();
357
- }
358
- }
359
- }
360
- catch (error) {
361
- out.warn(`Warning: Could not load detailed info: ${error}`);
362
- }
363
- }
364
- statusInfo["Changes"] = syncStatus.hasChanges
365
- ? `${syncStatus.changeCount} pending`
366
- : undefined;
367
- statusInfo["Status"] = !syncStatus.hasChanges ? "up to date" : undefined;
368
- out.obj(statusInfo);
369
- // Show verbose details if requested
370
- if (options.verbose && syncStatus.snapshot?.rootDirectoryUrl) {
371
- const rootHandle = await repo.find(syncStatus.snapshot.rootDirectoryUrl);
372
- const rootDoc = await rootHandle.doc();
373
- if (rootDoc) {
374
- out.infoBlock("HEADS");
375
- out.arr(rootHandle.heads());
376
- if (syncStatus.snapshot && syncStatus.snapshot.files.size > 0) {
377
- out.infoBlock("TRACKED FILES");
378
- const filesObj = {};
379
- syncStatus.snapshot.files.forEach((entry, filePath) => {
380
- filesObj[filePath] = entry.url;
381
- });
382
- out.obj(filesObj);
383
- }
384
- }
385
- }
386
- if (syncStatus.hasChanges && !options.verbose) {
387
- out.info("Run 'pushwork diff' to see changes");
388
- }
389
- await safeRepoShutdown(repo);
390
- }
391
- /**
392
- * Show sync history
393
- */
394
- export async function log(targetPath = ".", _options) {
395
- const { repo: logRepo, workingDir } = await setupCommandContext(targetPath, { syncEnabled: false });
396
- // TODO: Implement history tracking
397
- const snapshotPath = path.join(workingDir, ConfigManager.CONFIG_DIR, "snapshot.json");
398
- if (await pathExists(snapshotPath)) {
399
- const stats = await fs.stat(snapshotPath);
400
- out.infoBlock("HISTORY", "Sync history (stub)");
401
- out.obj({ "Last sync": stats.mtime.toISOString() });
402
- }
403
- else {
404
- out.info("No sync history found");
405
- }
406
- await safeRepoShutdown(logRepo);
407
- }
408
- /**
409
- * Checkout/restore from previous sync
410
- */
411
- export async function checkout(syncId, targetPath = ".", _options) {
412
- const { workingDir } = await setupCommandContext(targetPath);
413
- // TODO: Implement checkout functionality
414
- out.warnBlock("NOT IMPLEMENTED", "Checkout not yet implemented");
415
- out.obj({
416
- "Sync ID": syncId,
417
- Path: workingDir,
418
- });
419
- }
420
- /**
421
- * Clone an existing synced directory from an AutomergeUrl
422
- */
423
- export async function clone(rootUrl, targetPath, options) {
424
- // Validate that rootUrl is actually an Automerge URL
425
- if (!rootUrl.startsWith("automerge:")) {
426
- out.error(`Invalid Automerge URL: ${rootUrl}\n` +
427
- `Expected format: automerge:XXXXX\n` +
428
- `Usage: pushwork clone <automerge-url> <path>`);
429
- out.exit(1);
430
- }
431
- const resolvedPath = path.resolve(targetPath);
432
- out.task(`Cloning ${rootUrl}`);
433
- // Check if directory exists and handle --force
434
- if (await pathExists(resolvedPath)) {
435
- const files = await fs.readdir(resolvedPath);
436
- if (files.length > 0 && !options.force) {
437
- out.error("Target directory is not empty. Use --force to overwrite");
438
- out.exit(1);
439
- }
440
- }
441
- else {
442
- await ensureDirectoryExists(resolvedPath);
443
- }
444
- // Check if already initialized
445
- const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR);
446
- if (await pathExists(syncToolDir)) {
447
- if (!options.force) {
448
- out.error("Directory already initialized. Use --force to overwrite");
449
- out.exit(1);
450
- }
451
- await fs.rm(syncToolDir, { recursive: true, force: true });
452
- }
453
- // Initialize repository with optional CLI overrides
454
- out.update("Setting up repository");
455
- const { config, repo, syncEngine } = await initializeRepository(resolvedPath, {
456
- sync_server: options.syncServer,
457
- });
458
- // Connect to existing root directory and download files
459
- out.update("Downloading files");
460
- await syncEngine.setRootDirectoryUrl(rootUrl);
461
- const result = await syncEngine.sync();
462
- out.update("Writing to disk");
463
- await safeRepoShutdown(repo);
464
- out.done();
465
- out.obj({
466
- Path: resolvedPath,
467
- Files: `${result.filesChanged} downloaded`,
468
- Sync: config.sync_server,
469
- });
470
- out.successBlock("CLONED", rootUrl);
471
- process.exit();
472
- }
473
- /**
474
- * Get the root URL for the current pushwork repository
475
- */
476
- export async function url(targetPath = ".") {
477
- const resolvedPath = path.resolve(targetPath);
478
- const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR);
479
- if (!(await pathExists(syncToolDir))) {
480
- out.error("Directory not initialized for sync");
481
- out.exit(1);
482
- }
483
- const snapshotPath = path.join(syncToolDir, "snapshot.json");
484
- if (!(await pathExists(snapshotPath))) {
485
- out.error("No snapshot found");
486
- out.exit(1);
487
- }
488
- const snapshotData = await fs.readFile(snapshotPath, "utf-8");
489
- const snapshot = JSON.parse(snapshotData);
490
- if (snapshot.rootDirectoryUrl) {
491
- // Output just the URL for easy use in scripts
492
- out.log(snapshot.rootDirectoryUrl);
493
- }
494
- else {
495
- out.error("No root URL found in snapshot");
496
- out.exit(1);
497
- }
498
- }
499
- /**
500
- * Remove local pushwork data and log URL for recovery
501
- */
502
- export async function rm(targetPath = ".") {
503
- const resolvedPath = path.resolve(targetPath);
504
- const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR);
505
- if (!(await pathExists(syncToolDir))) {
506
- out.error("Directory not initialized for sync");
507
- out.exit(1);
508
- }
509
- // Read the URL before deletion for recovery
510
- let recoveryUrl = "";
511
- const snapshotPath = path.join(syncToolDir, "snapshot.json");
512
- if (await pathExists(snapshotPath)) {
513
- try {
514
- const snapshotData = await fs.readFile(snapshotPath, "utf-8");
515
- const snapshot = JSON.parse(snapshotData);
516
- recoveryUrl = snapshot.rootDirectoryUrl || null;
517
- }
518
- catch (error) {
519
- out.error(`Remove failed: ${error}`);
520
- out.exit(1);
521
- return;
522
- }
523
- }
524
- out.task("Removing local pushwork data");
525
- await fs.rm(syncToolDir, { recursive: true, force: true });
526
- out.done();
527
- out.warnBlock("REMOVED", recoveryUrl);
528
- process.exit();
529
- }
530
- export async function commit(targetPath, _options = {}) {
531
- out.task("Committing local changes");
532
- const { repo, syncEngine } = await setupCommandContext(targetPath, { syncEnabled: false });
533
- const result = await syncEngine.commitLocal();
534
- await safeRepoShutdown(repo);
535
- out.done();
536
- if (result.errors.length > 0) {
537
- out.errorBlock("ERROR", `${result.errors.length} errors`);
538
- result.errors.forEach((error) => out.error(error));
539
- out.exit(1);
540
- }
541
- out.successBlock("COMMITTED", `${result.filesChanged} files`);
542
- out.obj({
543
- Files: result.filesChanged,
544
- Directories: result.directoriesChanged,
545
- });
546
- if (result.warnings.length > 0) {
547
- result.warnings.forEach((warning) => out.warn(warning));
548
- }
549
- process.exit();
550
- }
551
- /**
552
- * List tracked files
553
- */
554
- export async function ls(targetPath = ".", options = {}) {
555
- const { repo, syncEngine } = await setupCommandContext(targetPath, { syncEnabled: false });
556
- const syncStatus = await syncEngine.getStatus();
557
- if (!syncStatus.snapshot) {
558
- out.error("No snapshot found");
559
- await safeRepoShutdown(repo);
560
- out.exit(1);
561
- return;
562
- }
563
- const files = Array.from(syncStatus.snapshot.files.entries()).sort(([pathA], [pathB]) => pathA.localeCompare(pathB));
564
- if (files.length === 0) {
565
- out.info("No tracked files");
566
- await safeRepoShutdown(repo);
567
- return;
568
- }
569
- if (options.verbose) {
570
- // Long format with URLs
571
- for (const [filePath, entry] of files) {
572
- const url = entry?.url || "unknown";
573
- out.log(`${filePath} -> ${url}`);
574
- }
575
- }
576
- else {
577
- // Simple list
578
- for (const [filePath] of files) {
579
- out.log(filePath);
580
- }
581
- }
582
- await safeRepoShutdown(repo);
583
- }
584
- /**
585
- * View or edit configuration
586
- */
587
- export async function config(targetPath = ".", options = {}) {
588
- const resolvedPath = path.resolve(targetPath);
589
- const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR);
590
- if (!(await pathExists(syncToolDir))) {
591
- out.error("Directory not initialized for sync");
592
- out.exit(1);
593
- }
594
- const configManager = new ConfigManager(resolvedPath);
595
- const config = await configManager.getMerged();
596
- if (options.list) {
597
- // List all configuration
598
- out.infoBlock("CONFIGURATION", "Full configuration");
599
- out.log(JSON.stringify(config, null, 2));
600
- }
601
- else if (options.get) {
602
- // Get specific config value
603
- const keys = options.get.split(".");
604
- let value = config;
605
- for (const key of keys) {
606
- value = value?.[key];
607
- }
608
- if (value !== undefined) {
609
- out.log(typeof value === "object" ? JSON.stringify(value, null, 2) : value);
610
- }
611
- else {
612
- out.error(`Config key not found: ${options.get}`);
613
- out.exit(1);
614
- }
615
- }
616
- else {
617
- // Show basic config info
618
- out.infoBlock("CONFIGURATION");
619
- out.obj({
620
- "Sync server": config.sync_server || "default",
621
- "Sync enabled": config.sync_enabled ? "yes" : "no",
622
- Exclusions: config.exclude_patterns?.length,
623
- });
624
- out.log("");
625
- out.log("Use --list to see full configuration");
626
- }
627
- }
628
- /**
629
- * Watch a directory and sync after build script completes
630
- */
631
- export async function watch(targetPath = ".", options = {}) {
632
- const script = options.script || "pnpm build";
633
- const watchDir = options.watchDir || "src"; // Default to watching 'src' directory
634
- const verbose = options.verbose || false;
635
- const { repo, syncEngine, workingDir } = await setupCommandContext(targetPath);
636
- const absoluteWatchDir = path.resolve(workingDir, watchDir);
637
- // Check if watch directory exists
638
- if (!(await pathExists(absoluteWatchDir))) {
639
- out.error(`Watch directory does not exist: ${watchDir}`);
640
- await safeRepoShutdown(repo);
641
- out.exit(1);
642
- return;
643
- }
644
- out.spicyBlock("WATCHING", `${chalk.underline(formatRelativePath(watchDir))} for changes...`);
645
- out.info(`Build script: ${script}`);
646
- out.info(`Working directory: ${workingDir}`);
647
- let isProcessing = false;
648
- let pendingChange = false;
649
- // Function to run build and sync
650
- const runBuildAndSync = async () => {
651
- if (isProcessing) {
652
- pendingChange = true;
653
- return;
654
- }
655
- isProcessing = true;
656
- pendingChange = false;
657
- try {
658
- out.spicy(`[${new Date().toLocaleTimeString()}] Changes detected...`);
659
- // Run build script
660
- const buildResult = await runScript(script, workingDir, verbose);
661
- if (!buildResult.success) {
662
- out.warn("Build script failed");
663
- if (buildResult.output) {
664
- out.log("");
665
- out.log(buildResult.output);
666
- }
667
- isProcessing = false;
668
- if (pendingChange) {
669
- setImmediate(() => runBuildAndSync());
670
- }
671
- return;
672
- }
673
- out.info("Build completed...");
674
- // Run sync
675
- out.task("Syncing");
676
- const result = await syncEngine.sync();
677
- if (result.success) {
678
- if (result.filesChanged === 0 && result.directoriesChanged === 0) {
679
- out.done("Already synced");
680
- }
681
- else {
682
- out.done(`Synced ${result.filesChanged} ${plural("file", result.filesChanged)}`);
683
- }
684
- }
685
- else {
686
- out.warn(`⚠ Partial sync: ${result.filesChanged} updated, ${result.errors.length} errors`);
687
- result.errors
688
- .slice(0, 3)
689
- .forEach((error) => out.error(` ${error.path}: ${error.error.message}`));
690
- if (result.errors.length > 3) {
691
- out.warn(` ... and ${result.errors.length - 3} more errors`);
692
- }
693
- }
694
- if (result.warnings.length > 0) {
695
- result.warnings
696
- .slice(0, 3)
697
- .forEach((warning) => out.warn(` ${warning}`));
698
- if (result.warnings.length > 3) {
699
- out.warn(` ... and ${result.warnings.length - 3} more warnings`);
700
- }
701
- }
702
- }
703
- catch (error) {
704
- out.error(`Error during build/sync: ${error}`);
705
- }
706
- finally {
707
- isProcessing = false;
708
- // If changes occurred while we were processing, run again
709
- if (pendingChange) {
710
- setImmediate(() => runBuildAndSync());
711
- }
712
- }
713
- };
714
- // Set up file watcher - watches everything in the specified directory
715
- const watcher = fsSync.watch(absoluteWatchDir, { recursive: true }, (_eventType, filename) => {
716
- if (filename) {
717
- runBuildAndSync();
718
- }
719
- });
720
- // Handle graceful shutdown
721
- const shutdown = async () => {
722
- out.log("");
723
- out.info("Shutting down...");
724
- watcher.close();
725
- await safeRepoShutdown(repo);
726
- out.rainbow("Goodbye!");
727
- process.exit(0);
728
- };
729
- process.on("SIGINT", shutdown);
730
- process.on("SIGTERM", shutdown);
731
- // Run initial build and sync
732
- await runBuildAndSync();
733
- // Keep process alive
734
- await new Promise(() => { }); // Never resolves, keeps watching
735
- }
736
- /**
737
- * Run a shell script and wait for completion
738
- */
739
- async function runScript(script, cwd, verbose) {
740
- return new Promise((resolve) => {
741
- const [command, ...args] = script.split(" ");
742
- const child = spawn(command, args, {
743
- cwd,
744
- stdio: verbose ? "inherit" : "pipe", // Show output directly if verbose, otherwise capture
745
- shell: true,
746
- });
747
- let output = "";
748
- // Capture output if not verbose (so we can show it on error)
749
- if (!verbose) {
750
- child.stdout?.on("data", (data) => {
751
- output += data.toString();
752
- });
753
- child.stderr?.on("data", (data) => {
754
- output += data.toString();
755
- });
756
- }
757
- child.on("close", (code) => {
758
- resolve({
759
- success: code === 0,
760
- output: !verbose ? output : undefined,
761
- });
762
- });
763
- child.on("error", (error) => {
764
- out.error(`Failed to run script: ${error.message}`);
765
- resolve({
766
- success: false,
767
- output: !verbose ? output : undefined,
768
- });
769
- });
770
- });
771
- }
772
- /**
773
- * Set root directory URL for an existing or new pushwork directory
774
- */
775
- export async function root(rootUrl, targetPath = ".", options = {}) {
776
- if (!rootUrl.startsWith("automerge:")) {
777
- out.error(`Invalid Automerge URL: ${rootUrl}\n` +
778
- `Expected format: automerge:XXXXX`);
779
- out.exit(1);
780
- }
781
- const resolvedPath = path.resolve(targetPath);
782
- const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR);
783
- if (await pathExists(syncToolDir)) {
784
- if (!options.force) {
785
- out.error("Directory already initialized for pushwork. Use --force to overwrite");
786
- out.exit(1);
787
- }
788
- }
789
- await ensureDirectoryExists(syncToolDir);
790
- await ensureDirectoryExists(path.join(syncToolDir, "automerge"));
791
- // Create minimal snapshot with just the root URL
792
- const snapshotPath = path.join(syncToolDir, "snapshot.json");
793
- const snapshot = {
794
- timestamp: Date.now(),
795
- rootPath: resolvedPath,
796
- rootDirectoryUrl: rootUrl,
797
- files: [],
798
- directories: [],
799
- };
800
- await fs.writeFile(snapshotPath, JSON.stringify(snapshot, null, 2), "utf-8");
801
- // Ensure config exists
802
- const configManager = new ConfigManager(resolvedPath);
803
- await configManager.initializeWithOverrides({});
804
- out.successBlock("ROOT SET", rootUrl);
805
- process.exit();
806
- }
807
- /**
808
- * Read a file document by its Automerge URL and print its content
809
- */
810
- export async function read(docUrl, options = {}) {
811
- if (!docUrl.startsWith("automerge:")) {
812
- out.error(`Invalid Automerge URL: ${docUrl}\n` +
813
- `Expected format: automerge:XXXXX`);
814
- out.exit(1);
815
- }
816
- let repo;
817
- if (options.remote) {
818
- // Create an ephemeral repo with Subduction to fetch from sync server
819
- repo = await createEphemeralRepo(DEFAULT_SYNC_SERVER);
820
- }
821
- else {
822
- // Read from local pushwork storage
823
- const ctx = await setupCommandContext(".", { syncEnabled: false });
824
- repo = ctx.repo;
825
- }
826
- try {
827
- const handle = await repo.find(docUrl);
828
- const doc = await handle.doc();
829
- if (!doc) {
830
- out.error("Document not found or unavailable");
831
- await safeRepoShutdown(repo);
832
- out.exit(1);
833
- return;
834
- }
835
- const content = readDocContent(doc.content);
836
- if (content === null) {
837
- out.error("Document has no content");
838
- await safeRepoShutdown(repo);
839
- out.exit(1);
840
- return;
841
- }
842
- if (content instanceof Uint8Array) {
843
- process.stdout.write(content);
844
- }
845
- else {
846
- process.stdout.write(content);
847
- }
848
- }
849
- catch (error) {
850
- out.error(`Failed to read document: ${error}`);
851
- await safeRepoShutdown(repo);
852
- out.exit(1);
853
- return;
854
- }
855
- await safeRepoShutdown(repo);
856
- process.exit();
857
- }
858
- function plural(word, count) {
859
- return count === 1 ? word : `${word}s`;
860
- }
861
- //# sourceMappingURL=commands.js.map