pushwork 1.0.3 → 1.0.5

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 (67) hide show
  1. package/README.md +8 -1
  2. package/bench/filesystem.bench.ts +78 -0
  3. package/bench/hashing.bench.ts +60 -0
  4. package/bench/move-detection.bench.ts +130 -0
  5. package/bench/runner.ts +49 -0
  6. package/dist/cli/commands.d.ts +15 -25
  7. package/dist/cli/commands.d.ts.map +1 -1
  8. package/dist/cli/commands.js +409 -519
  9. package/dist/cli/commands.js.map +1 -1
  10. package/dist/cli/output.d.ts +75 -0
  11. package/dist/cli/output.d.ts.map +1 -0
  12. package/dist/cli/output.js +182 -0
  13. package/dist/cli/output.js.map +1 -0
  14. package/dist/cli.js +119 -51
  15. package/dist/cli.js.map +1 -1
  16. package/dist/config/remote-manager.d.ts +65 -0
  17. package/dist/config/remote-manager.d.ts.map +1 -0
  18. package/dist/config/remote-manager.js +243 -0
  19. package/dist/config/remote-manager.js.map +1 -0
  20. package/dist/core/change-detection.d.ts +8 -0
  21. package/dist/core/change-detection.d.ts.map +1 -1
  22. package/dist/core/change-detection.js +63 -0
  23. package/dist/core/change-detection.js.map +1 -1
  24. package/dist/core/move-detection.d.ts +9 -48
  25. package/dist/core/move-detection.d.ts.map +1 -1
  26. package/dist/core/move-detection.js +53 -135
  27. package/dist/core/move-detection.js.map +1 -1
  28. package/dist/core/sync-engine.d.ts.map +1 -1
  29. package/dist/core/sync-engine.js +17 -85
  30. package/dist/core/sync-engine.js.map +1 -1
  31. package/dist/types/config.d.ts +45 -5
  32. package/dist/types/config.d.ts.map +1 -1
  33. package/dist/types/documents.d.ts +0 -1
  34. package/dist/types/documents.d.ts.map +1 -1
  35. package/dist/types/snapshot.d.ts +3 -0
  36. package/dist/types/snapshot.d.ts.map +1 -1
  37. package/dist/types/snapshot.js.map +1 -1
  38. package/dist/utils/fs.d.ts.map +1 -1
  39. package/dist/utils/fs.js +9 -33
  40. package/dist/utils/fs.js.map +1 -1
  41. package/dist/utils/index.d.ts +0 -1
  42. package/dist/utils/index.d.ts.map +1 -1
  43. package/dist/utils/index.js +0 -1
  44. package/dist/utils/index.js.map +1 -1
  45. package/dist/utils/repo-factory.d.ts.map +1 -1
  46. package/dist/utils/repo-factory.js +18 -9
  47. package/dist/utils/repo-factory.js.map +1 -1
  48. package/dist/utils/string-similarity.d.ts +14 -0
  49. package/dist/utils/string-similarity.d.ts.map +1 -0
  50. package/dist/utils/string-similarity.js +43 -0
  51. package/dist/utils/string-similarity.js.map +1 -0
  52. package/package.json +10 -5
  53. package/src/cli/commands.ts +520 -697
  54. package/src/cli/output.ts +244 -0
  55. package/src/cli.ts +182 -73
  56. package/src/core/change-detection.ts +95 -0
  57. package/src/core/move-detection.ts +69 -177
  58. package/src/core/sync-engine.ts +17 -105
  59. package/src/types/config.ts +50 -7
  60. package/src/types/documents.ts +0 -1
  61. package/src/types/snapshot.ts +1 -0
  62. package/src/utils/fs.ts +9 -33
  63. package/src/utils/index.ts +0 -1
  64. package/src/utils/repo-factory.ts +21 -8
  65. package/src/utils/string-similarity.ts +54 -0
  66. package/src/utils/content-similarity.ts +0 -194
  67. package/test/unit/content-similarity.test.ts +0 -236
@@ -1,24 +1,59 @@
1
1
  import * as path from "path";
2
2
  import * as fs from "fs/promises";
3
- import { Repo, StorageId, AutomergeUrl } from "@automerge/automerge-repo";
4
- import chalk from "chalk";
5
- import ora from "ora";
3
+ import { Repo, AutomergeUrl } from "@automerge/automerge-repo";
6
4
  import * as diffLib from "diff";
7
5
  import {
8
- InitOptions,
9
6
  CloneOptions,
10
7
  SyncOptions,
11
8
  DiffOptions,
12
9
  LogOptions,
13
10
  CheckoutOptions,
11
+ InitOptions,
12
+ CommitOptions,
13
+ StatusOptions,
14
+ UrlOptions,
15
+ ListOptions,
16
+ ConfigOptions,
17
+ DebugOptions,
14
18
  DirectoryConfig,
15
19
  DirectoryDocument,
16
20
  } from "../types";
17
21
  import { SyncEngine } from "../core";
18
- import { DetectedChange } from "../core/change-detection";
19
22
  import { pathExists, ensureDirectoryExists } from "../utils";
20
23
  import { ConfigManager } from "../config";
21
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
+ }
22
57
 
23
58
  /**
24
59
  * Shared context that commands can use
@@ -30,6 +65,29 @@ export interface CommandContext {
30
65
  workingDir: string;
31
66
  }
32
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
+
33
91
  /**
34
92
  * Shared pre-action that ensures repository and sync engine are properly initialized
35
93
  * This function always works, with or without network connectivity
@@ -114,131 +172,41 @@ export async function safeRepoShutdown(
114
172
  }
115
173
  }
116
174
 
117
- /**
118
- * Common progress message helpers
119
- */
120
- export const ProgressMessages = {
121
- // Setup messages
122
- directoryFound: () => console.log(chalk.gray(" ✓ Sync directory found")),
123
- configLoaded: () => console.log(chalk.gray(" ✓ Configuration loaded")),
124
- repoConnected: () => console.log(chalk.gray(" ✓ Connected to repository")),
125
-
126
- // Configuration display
127
- syncServer: (server: string) =>
128
- console.log(chalk.gray(` ✓ Sync server: ${server}`)),
129
- storageId: (id: string) => console.log(chalk.gray(` ✓ Storage ID: ${id}`)),
130
- rootUrl: (url: string) => console.log(chalk.gray(` ✓ Root URL: ${url}`)),
131
-
132
- // Operation completion
133
- changesWritten: () =>
134
- console.log(chalk.gray(" ✓ All changes written to disk")),
135
- syncCompleted: (duration: number) =>
136
- console.log(chalk.gray(` ✓ Initial sync completed in ${duration}ms`)),
137
- directoryStructureCreated: () =>
138
- console.log(chalk.gray(" ✓ Created sync directory structure")),
139
- configSaved: () => console.log(chalk.gray(" ✓ Saved configuration")),
140
- repoCreated: () =>
141
- console.log(chalk.gray(" ✓ Created Automerge repository")),
142
- };
143
-
144
- /**
145
- * Show actual content diff for a changed file
146
- */
147
- async function showContentDiff(change: DetectedChange): Promise<void> {
148
- try {
149
- // Get old content (from snapshot/remote)
150
- const oldContent = change.remoteContent || "";
151
-
152
- // Get new content (current local)
153
- const newContent = change.localContent || "";
154
-
155
- // Convert binary content to string representation if needed
156
- const oldText =
157
- typeof oldContent === "string"
158
- ? oldContent
159
- : `<binary content: ${oldContent.length} bytes>`;
160
- const newText =
161
- typeof newContent === "string"
162
- ? newContent
163
- : `<binary content: ${newContent.length} bytes>`;
164
-
165
- // Generate unified diff
166
- const diffResult = diffLib.createPatch(
167
- change.path,
168
- oldText,
169
- newText,
170
- "previous",
171
- "current"
172
- );
173
-
174
- // Skip the header lines and process the diff
175
- const lines = diffResult.split("\n").slice(4); // Skip index, ===, ---, +++ lines
176
-
177
- if (lines.length === 0 || (lines.length === 1 && lines[0] === "")) {
178
- console.log(chalk.gray(" (content identical)"));
179
- return;
180
- }
181
-
182
- for (const line of lines) {
183
- if (line.startsWith("@@")) {
184
- // Hunk header
185
- console.log(chalk.cyan(line));
186
- } else if (line.startsWith("+")) {
187
- // Added line
188
- console.log(chalk.green(line));
189
- } else if (line.startsWith("-")) {
190
- // Removed line
191
- console.log(chalk.red(line));
192
- } else if (line.startsWith(" ")) {
193
- // Context line
194
- console.log(chalk.gray(line));
195
- } else if (line === "") {
196
- // Empty line
197
- console.log("");
198
- }
199
- }
200
- } catch (error) {
201
- console.log(chalk.gray(` (diff error: ${error})`));
202
- }
203
- }
204
-
205
175
  /**
206
176
  * Initialize sync in a directory
207
177
  */
208
178
  export async function init(
209
179
  targetPath: string,
210
- syncServer?: string,
211
- syncServerStorageId?: string
180
+ options: InitOptions = {}
212
181
  ): Promise<void> {
213
- const spinner = ora("Starting initialization...").start();
182
+ // Validate sync server options
183
+ validateSyncServerOptions(options.syncServer, options.syncServerStorageId);
184
+
185
+ const out = new Output();
214
186
 
215
187
  try {
216
188
  const resolvedPath = path.resolve(targetPath);
217
189
 
218
- // Step 1: Directory setup
219
- spinner.text = "Setting up directory structure...";
190
+ out.task(`Initializing ${resolvedPath}`);
191
+
220
192
  await ensureDirectoryExists(resolvedPath);
221
193
 
222
194
  // Check if already initialized
223
195
  const syncToolDir = path.join(resolvedPath, ".pushwork");
224
196
  if (await pathExists(syncToolDir)) {
225
- spinner.fail("Directory already initialized for sync");
226
- return;
197
+ out.error("Directory already initialized for sync");
198
+ out.exit(1);
227
199
  }
228
200
 
229
- // Step 2: Create sync directories
230
- spinner.text = "Creating .pushwork directory...";
201
+ out.update("Creating sync directory");
231
202
  await ensureDirectoryExists(syncToolDir);
232
203
  await ensureDirectoryExists(path.join(syncToolDir, "automerge"));
233
204
 
234
- ProgressMessages.directoryStructureCreated();
235
-
236
- // Step 3: Configuration setup
237
- spinner.text = "Setting up configuration...";
205
+ out.update("Setting up configuration");
238
206
  const configManager = new ConfigManager(resolvedPath);
239
- const defaultSyncServer = syncServer || "wss://sync3.automerge.org";
207
+ const defaultSyncServer = options.syncServer || "wss://sync3.automerge.org";
240
208
  const defaultStorageId =
241
- syncServerStorageId || "3760df37-a4c6-4f66-9ecd-732039a9385d";
209
+ options.syncServerStorageId || "3760df37-a4c6-4f66-9ecd-732039a9385d";
242
210
  const config: DirectoryConfig = {
243
211
  sync_server: defaultSyncServer,
244
212
  sync_server_storage_id: defaultStorageId,
@@ -259,278 +227,201 @@ export async function init(
259
227
  };
260
228
  await configManager.save(config);
261
229
 
262
- ProgressMessages.configSaved();
263
- ProgressMessages.syncServer(defaultSyncServer);
264
- ProgressMessages.storageId(defaultStorageId);
265
-
266
- // Step 4: Initialize Automerge repo and create root directory document
267
- spinner.text = "Creating root directory document...";
230
+ out.update("Creating root directory");
268
231
  const repo = await createRepo(resolvedPath, {
269
232
  enableNetwork: true,
270
- syncServer: syncServer,
271
- syncServerStorageId: syncServerStorageId,
233
+ syncServer: options.syncServer,
234
+ syncServerStorageId: options.syncServerStorageId,
272
235
  });
273
236
 
274
- // Create the root directory document
275
237
  const rootDoc: DirectoryDocument = {
276
238
  "@patchwork": { type: "folder" },
277
239
  docs: [],
278
240
  };
279
241
  const rootHandle = repo.create(rootDoc);
280
242
 
281
- ProgressMessages.repoCreated();
282
- ProgressMessages.rootUrl(rootHandle.url);
283
-
284
- // Step 5: Scan existing files
285
- spinner.text = "Scanning existing files...";
243
+ out.update("Scanning existing files");
286
244
  const syncEngine = new SyncEngine(
287
245
  repo,
288
246
  resolvedPath,
289
247
  config.defaults.exclude_patterns,
290
- true, // Network sync enabled for init
248
+ true,
291
249
  config.sync_server_storage_id
292
250
  );
293
251
 
294
- // Get file count for progress
295
- const dirEntries = await fs.readdir(resolvedPath, { withFileTypes: true });
296
- const fileCount = dirEntries.filter((dirent: any) =>
297
- dirent.isFile()
298
- ).length;
299
-
300
- if (fileCount > 0) {
301
- console.log(chalk.gray(` ✓ Found ${fileCount} existing files`));
302
- spinner.text = `Creating initial snapshot with ${fileCount} files...`;
303
- } else {
304
- spinner.text = "Creating initial empty snapshot...";
305
- }
306
-
307
- // Step 6: Set the root directory URL before creating initial snapshot
308
252
  await syncEngine.setRootDirectoryUrl(rootHandle.url);
253
+ const result = await syncEngine.sync(false);
309
254
 
310
- // Step 7: Create initial snapshot
311
- spinner.text = "Creating initial snapshot...";
312
- const startTime = Date.now();
313
- await syncEngine.sync(false);
314
- const duration = Date.now() - startTime;
255
+ out.update("Writing to disk");
256
+ await safeRepoShutdown(repo, "init");
315
257
 
316
- ProgressMessages.syncCompleted(duration);
258
+ out.done();
317
259
 
318
- // Step 8: Ensure all Automerge operations are flushed to disk
319
- spinner.text = "Flushing changes to disk...";
320
- await safeRepoShutdown(repo, "init");
321
- ProgressMessages.changesWritten();
260
+ out.pair("Sync", defaultSyncServer);
261
+ if (result.filesChanged > 0) {
262
+ out.pair("Files", `${result.filesChanged} added`);
263
+ }
264
+ out.success("INITIALIZED", rootHandle.url);
322
265
 
323
- spinner.succeed(`Initialized sync in ${chalk.green(resolvedPath)}`);
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
+ }
324
279
 
325
- console.log(`\n${chalk.bold("🎉 Sync Directory Created!")}`);
326
- console.log(` 📁 Directory: ${chalk.blue(resolvedPath)}`);
327
- console.log(` 🔗 Sync server: ${chalk.blue(defaultSyncServer)}`);
328
- console.log(
329
- `\n${chalk.green("Initialization complete!")} Run ${chalk.cyan(
330
- "pushwork sync"
331
- )} to start syncing.`
332
- );
280
+ out.log("");
281
+ out.log(`Run 'pushwork sync' to start synchronizing`);
333
282
  } catch (error) {
334
- spinner.fail(`Failed to initialize: ${error}`);
335
- throw error;
283
+ out.error("FAILED", "Initialization failed");
284
+ out.log(` ${error}`);
285
+ out.exit(1);
336
286
  }
287
+ process.exit();
337
288
  }
338
289
 
339
290
  /**
340
291
  * Run bidirectional sync
341
292
  */
342
- export async function sync(options: SyncOptions): Promise<void> {
343
- const spinner = ora("Starting sync operation...").start();
293
+ export async function sync(
294
+ targetPath = ".",
295
+ options: SyncOptions
296
+ ): Promise<void> {
297
+ const out = new Output();
344
298
 
345
299
  try {
346
- // Step 1: Setup shared context
347
- spinner.text = "Setting up sync context...";
348
- const { repo, syncEngine, config, workingDir } =
349
- await setupCommandContext();
350
-
351
- ProgressMessages.directoryFound();
352
- ProgressMessages.configLoaded();
353
- ProgressMessages.syncServer(
354
- config?.sync_server || "wss://sync3.automerge.org"
355
- );
356
- ProgressMessages.repoConnected();
300
+ out.task("Syncing");
357
301
 
358
- // Show root directory URL for context
359
- const syncStatus = await syncEngine.getStatus();
360
- if (syncStatus.snapshot?.rootDirectoryUrl) {
361
- ProgressMessages.rootUrl(syncStatus.snapshot.rootDirectoryUrl);
362
- }
302
+ const { repo, syncEngine, workingDir } = await setupCommandContext(
303
+ targetPath
304
+ );
363
305
 
364
306
  if (options.dryRun) {
365
- // Dry run mode - detailed preview
366
- spinner.text = "Analyzing changes (dry run)...";
367
- const startTime = Date.now();
307
+ out.update("Analyzing changes");
368
308
  const preview = await syncEngine.previewChanges();
369
- const analysisTime = Date.now() - startTime;
370
-
371
- spinner.succeed("Change analysis completed");
372
309
 
373
- console.log(`\n${chalk.bold("📊 Change Analysis")} (${analysisTime}ms):`);
374
- console.log(chalk.gray(` Directory: ${workingDir}`));
375
- console.log(chalk.gray(` Analysis time: ${analysisTime}ms`));
310
+ out.done();
376
311
 
377
312
  if (preview.changes.length === 0 && preview.moves.length === 0) {
378
- console.log(
379
- `\n${chalk.green(" No changes detected")} - everything is in sync!`
380
- );
313
+ out.info("No changes detected");
314
+ out.log("Everything is already in sync");
381
315
  return;
382
316
  }
383
317
 
384
- console.log(`\n${chalk.bold("📋 Summary:")}`);
385
- console.log(` ${preview.summary}`);
386
-
387
- if (preview.changes.length > 0) {
388
- const localChanges = preview.changes.filter(
389
- (c) =>
390
- c.changeType === "local_only" || c.changeType === "both_changed"
391
- ).length;
392
- const remoteChanges = preview.changes.filter(
393
- (c) =>
394
- c.changeType === "remote_only" || c.changeType === "both_changed"
395
- ).length;
396
- const conflicts = preview.changes.filter(
397
- (c) => c.changeType === "both_changed"
398
- ).length;
399
-
400
- console.log(
401
- `\n${chalk.bold("📁 File Changes:")} (${
402
- preview.changes.length
403
- } total)`
404
- );
405
- if (localChanges > 0) {
406
- console.log(` ${chalk.green("📤")} Local changes: ${localChanges}`);
407
- }
408
- if (remoteChanges > 0) {
409
- console.log(` ${chalk.blue("📥")} Remote changes: ${remoteChanges}`);
410
- }
411
- if (conflicts > 0) {
412
- console.log(` ${chalk.yellow("⚠️")} Conflicts: ${conflicts}`);
413
- }
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
+ }
414
324
 
415
- console.log(`\n${chalk.bold("📄 Changed Files:")}`);
416
- for (const change of preview.changes.slice(0, 10)) {
417
- // Show first 10
418
- const typeIcon =
419
- change.changeType === "local_only"
420
- ? chalk.green("📤")
421
- : change.changeType === "remote_only"
422
- ? chalk.blue("📥")
423
- : change.changeType === "both_changed"
424
- ? chalk.yellow("⚠️")
425
- : chalk.gray("➖");
426
- console.log(` ${typeIcon} ${change.path}`);
427
- }
428
- if (preview.changes.length > 10) {
429
- console.log(
430
- ` ${chalk.gray(
431
- `... and ${preview.changes.length - 10} more files`
432
- )}`
433
- );
434
- }
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`);
435
338
  }
436
339
 
437
340
  if (preview.moves.length > 0) {
438
- console.log(
439
- `\n${chalk.bold("🔄 Potential Moves:")} (${preview.moves.length})`
440
- );
341
+ out.log("");
342
+ out.log("Moves:");
441
343
  for (const move of preview.moves.slice(0, 5)) {
442
- // Show first 5
443
- const confidence =
444
- move.confidence === "auto"
445
- ? chalk.green("Auto")
446
- : move.confidence === "prompt"
447
- ? chalk.yellow("Prompt")
448
- : chalk.red("Low");
449
- console.log(` 🔄 ${move.fromPath} → ${move.toPath} (${confidence})`);
344
+ out.log(` ${move.fromPath} ${move.toPath}`);
450
345
  }
451
346
  if (preview.moves.length > 5) {
452
- console.log(
453
- ` ${chalk.gray(`... and ${preview.moves.length - 5} more moves`)}`
454
- );
347
+ out.log(` ... and ${preview.moves.length - 5} more`);
455
348
  }
456
349
  }
457
350
 
458
- console.log(
459
- `\n${chalk.cyan("ℹ️ Run without --dry-run to apply these changes")}`
460
- );
351
+ out.log("");
352
+ out.log("Run without --dry-run to apply these changes");
461
353
  } else {
462
- // Actual sync operation
463
- spinner.text = "Detecting changes...";
464
- const startTime = Date.now();
465
-
354
+ out.update("Synchronizing");
466
355
  const result = await syncEngine.sync(false);
467
- const totalTime = Date.now() - startTime;
468
356
 
469
- if (result.success) {
470
- spinner.succeed(`Sync completed in ${totalTime}ms`);
357
+ out.update("Writing to disk");
358
+ await safeRepoShutdown(repo, "sync");
471
359
 
472
- console.log(`\n${chalk.bold("✅ Sync Results:")}`);
473
- console.log(` 📄 Files changed: ${chalk.yellow(result.filesChanged)}`);
474
- console.log(
475
- ` 📁 Directories changed: ${chalk.yellow(result.directoriesChanged)}`
476
- );
477
- console.log(` ⏱️ Total time: ${chalk.gray(totalTime + "ms")}`);
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
+ }
478
374
 
479
375
  if (result.warnings.length > 0) {
480
- console.log(
481
- `\n${chalk.yellow("⚠️ Warnings:")} (${result.warnings.length})`
482
- );
376
+ out.log("");
377
+ out.warn("WARNINGS", `${result.warnings.length} warnings`);
483
378
  for (const warning of result.warnings.slice(0, 5)) {
484
- console.log(` ${chalk.yellow("⚠️")} ${warning}`);
379
+ out.log(` ${warning}`);
485
380
  }
486
381
  if (result.warnings.length > 5) {
487
- console.log(
488
- ` ${chalk.gray(
489
- `... and ${result.warnings.length - 5} more warnings`
490
- )}`
491
- );
382
+ out.log(` ... and ${result.warnings.length - 5} more`);
492
383
  }
493
384
  }
494
385
 
495
- if (result.filesChanged === 0 && result.directoriesChanged === 0) {
496
- console.log(`\n${chalk.green("✨ Everything already in sync!")}`);
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
+ );
497
398
  }
498
-
499
- // Ensure all changes are flushed to disk
500
- spinner.text = "Flushing changes to disk...";
501
- await safeRepoShutdown(repo, "sync");
502
- ProgressMessages.changesWritten();
503
399
  } else {
504
- spinner.fail("Sync completed with errors");
505
-
506
- console.log(
507
- `\n${chalk.red("❌ Sync Errors:")} (${result.errors.length})`
400
+ out.done("partial", false);
401
+ out.warn(
402
+ "PARTIAL",
403
+ `${result.filesChanged} updated, ${result.errors.length} errors`
508
404
  );
405
+ out.pair("Files", result.filesChanged.toString());
406
+ out.pair("Errors", result.errors.length.toString());
407
+
408
+ out.log("");
509
409
  for (const error of result.errors.slice(0, 5)) {
510
- console.log(
511
- ` ${chalk.red("❌")} ${error.path}: ${error.error.message}`
512
- );
410
+ out.error("ERROR", error.path);
411
+ out.log(` ${error.error.message}`);
412
+ out.log("");
513
413
  }
514
414
  if (result.errors.length > 5) {
515
- console.log(
516
- ` ${chalk.gray(`... and ${result.errors.length - 5} more errors`)}`
517
- );
518
- }
519
-
520
- if (result.filesChanged > 0 || result.directoriesChanged > 0) {
521
- console.log(`\n${chalk.yellow("⚠️ Partial sync completed:")}`);
522
- console.log(` 📄 Files changed: ${result.filesChanged}`);
523
- console.log(` 📁 Directories changed: ${result.directoriesChanged}`);
415
+ out.log(`... and ${result.errors.length - 5} more errors`);
524
416
  }
525
-
526
- // Still try to flush any partial changes
527
- await safeRepoShutdown(repo, "sync-error");
528
417
  }
529
418
  }
530
419
  } catch (error) {
531
- spinner.fail(`Sync failed: ${error}`);
532
- throw error;
420
+ out.error("FAILED", "Sync failed");
421
+ out.log(` ${error}`);
422
+ out.exit(1);
533
423
  }
424
+ process.exit();
534
425
  }
535
426
 
536
427
  /**
@@ -540,8 +431,11 @@ export async function diff(
540
431
  targetPath = ".",
541
432
  options: DiffOptions
542
433
  ): Promise<void> {
434
+ const out = new Output();
435
+
543
436
  try {
544
- // Setup shared context with network disabled for diff check
437
+ out.task("Analyzing changes");
438
+
545
439
  const { repo, syncEngine } = await setupCommandContext(
546
440
  targetPath,
547
441
  undefined,
@@ -550,214 +444,153 @@ export async function diff(
550
444
  );
551
445
  const preview = await syncEngine.previewChanges();
552
446
 
447
+ out.done();
448
+
553
449
  if (options.nameOnly) {
554
- // Show only file names
555
450
  for (const change of preview.changes) {
556
451
  console.log(change.path);
557
452
  }
558
453
  return;
559
454
  }
560
455
 
561
- // Show root directory URL for context
562
- const diffStatus = await syncEngine.getStatus();
563
- if (diffStatus.snapshot?.rootDirectoryUrl) {
564
- console.log(
565
- chalk.gray(`Root URL: ${diffStatus.snapshot.rootDirectoryUrl}`)
566
- );
567
- console.log("");
568
- }
569
-
570
456
  if (preview.changes.length === 0) {
571
- console.log(chalk.green("No changes detected"));
457
+ out.success("No changes detected");
458
+ await safeRepoShutdown(repo, "diff");
459
+ out.exit();
572
460
  return;
573
461
  }
574
462
 
575
- console.log(chalk.bold("Differences:"));
463
+ out.warn(`${preview.changes.length} changes detected`);
576
464
 
577
465
  for (const change of preview.changes) {
578
- const typeLabel =
466
+ const prefix =
579
467
  change.changeType === "local_only"
580
- ? chalk.green("[LOCAL]")
468
+ ? "[local] "
581
469
  : change.changeType === "remote_only"
582
- ? chalk.blue("[REMOTE]")
583
- : change.changeType === "both_changed"
584
- ? chalk.yellow("[CONFLICT]")
585
- : chalk.gray("[NO CHANGE]");
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
+ );
586
498
 
587
- console.log(`\n${typeLabel} ${change.path}`);
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
+ }
588
506
 
589
- if (options.tool) {
590
- console.log(` Use "${options.tool}" to view detailed diff`);
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
+ }
591
535
  } else {
592
- // Show actual diff content
593
- await showContentDiff(change);
536
+ out.log(`${prefix} ${change.path}`);
594
537
  }
595
538
  }
596
539
 
597
- // Cleanup repo resources
598
540
  await safeRepoShutdown(repo, "diff");
599
541
  } catch (error) {
600
- console.error(chalk.red(`Diff failed: ${error}`));
601
- throw error;
542
+ out.error(`Diff failed: ${error}`);
543
+ out.exit(1);
602
544
  }
603
545
  }
604
546
 
605
547
  /**
606
548
  * Show sync status
607
549
  */
608
- export async function status(): Promise<void> {
550
+ export async function status(
551
+ targetPath: string = ".",
552
+ options: StatusOptions = {}
553
+ ): Promise<void> {
554
+ const out = new Output();
555
+
609
556
  try {
610
- const spinner = ora("Loading sync status...").start();
557
+ out.task("Loading status");
611
558
 
612
- // Setup shared context with network disabled for status check
613
- const { repo, syncEngine, workingDir } = await setupCommandContext(
614
- process.cwd(),
559
+ const { repo, syncEngine, workingDir, config } = await setupCommandContext(
560
+ targetPath,
615
561
  undefined,
616
562
  undefined,
617
563
  false
618
564
  );
619
565
  const syncStatus = await syncEngine.getStatus();
620
566
 
621
- spinner.stop();
622
-
623
- console.log(chalk.bold("📊 Sync Status Report"));
624
- console.log(`${"=".repeat(50)}`);
567
+ out.done();
625
568
 
626
- // Directory information
627
- console.log(`\n${chalk.bold("📁 Directory Information:")}`);
628
- console.log(` 📂 Path: ${chalk.blue(workingDir)}`);
629
- console.log(` 🔧 Config: ${path.join(workingDir, ".pushwork")}`);
569
+ out.info("STATUS", workingDir);
630
570
 
631
- // Show root directory URL if available
632
571
  if (syncStatus.snapshot?.rootDirectoryUrl) {
633
- console.log(
634
- ` 🔗 Root URL: ${chalk.cyan(syncStatus.snapshot.rootDirectoryUrl)}`
635
- );
636
-
637
- // Try to show lastSyncAt from root directory document
638
- try {
639
- const rootHandle = await repo.find<DirectoryDocument>(
640
- syncStatus.snapshot.rootDirectoryUrl
641
- );
642
- const rootDoc = await rootHandle.doc();
643
- if (rootDoc?.lastSyncAt) {
644
- const lastSyncDate = new Date(rootDoc.lastSyncAt);
645
- const timeSince = Date.now() - rootDoc.lastSyncAt;
646
- const timeAgo =
647
- timeSince < 60000
648
- ? `${Math.floor(timeSince / 1000)}s ago`
649
- : timeSince < 3600000
650
- ? `${Math.floor(timeSince / 60000)}m ago`
651
- : `${Math.floor(timeSince / 3600000)}h ago`;
652
- console.log(
653
- ` 🕒 Root last touched: ${chalk.green(
654
- lastSyncDate.toLocaleString()
655
- )} (${chalk.gray(timeAgo)})`
656
- );
657
- } else {
658
- console.log(` 🕒 Root last touched: ${chalk.yellow("Never")}`);
659
- }
660
- } catch (error) {
661
- console.log(
662
- ` 🕒 Root last touched: ${chalk.gray("Unable to determine")}`
663
- );
664
- }
665
- } else {
666
- console.log(` 🔗 Root URL: ${chalk.yellow("Not set")}`);
667
- }
668
-
669
- // Sync timing
670
- if (syncStatus.lastSync) {
671
- const timeSince = Date.now() - syncStatus.lastSync.getTime();
672
- const timeAgo =
673
- timeSince < 60000
674
- ? `${Math.floor(timeSince / 1000)}s ago`
675
- : timeSince < 3600000
676
- ? `${Math.floor(timeSince / 60000)}m ago`
677
- : `${Math.floor(timeSince / 3600000)}h ago`;
678
-
679
- console.log(`\n${chalk.bold("⏱️ Sync Timing:")}`);
680
- console.log(
681
- ` 🕐 Last sync: ${chalk.green(syncStatus.lastSync.toLocaleString())}`
682
- );
683
- console.log(` ⏳ Time since: ${chalk.gray(timeAgo)}`);
684
- } else {
685
- console.log(`\n${chalk.bold("⏱️ Sync Timing:")}`);
686
- console.log(` 🕐 Last sync: ${chalk.yellow("Never synced")}`);
687
- console.log(
688
- ` 💡 Run ${chalk.cyan("pushwork sync")} to perform initial sync`
689
- );
572
+ out.pair("URL", syncStatus.snapshot.rootDirectoryUrl);
690
573
  }
691
574
 
692
- // Change status
693
- console.log(`\n${chalk.bold("📝 Change Status:")}`);
694
- if (syncStatus.hasChanges) {
695
- console.log(
696
- ` 📄 Pending changes: ${chalk.yellow(syncStatus.changeCount)}`
697
- );
698
- console.log(` 🔄 Status: ${chalk.yellow("Sync needed")}`);
699
- console.log(` 💡 Run ${chalk.cyan("pushwork diff")} to see details`);
700
- } else {
701
- console.log(` 📄 Pending changes: ${chalk.green("None")}`);
702
- console.log(` ✅ Status: ${chalk.green("Up to date")}`);
703
- }
704
-
705
- // Configuration
706
- console.log(`\n${chalk.bold("⚙️ Configuration:")}`);
707
-
708
- const statusConfigManager2 = new ConfigManager(workingDir);
709
- const statusConfig2 = await statusConfigManager2.load();
710
-
711
- if (statusConfig2?.sync_server) {
712
- console.log(` 🔗 Sync server: ${chalk.blue(statusConfig2.sync_server)}`);
713
- } else {
714
- console.log(
715
- ` 🔗 Sync server: ${chalk.blue("wss://sync3.automerge.org")} (default)`
716
- );
717
- }
718
-
719
- console.log(
720
- ` ⚡ Auto sync: ${
721
- statusConfig2?.sync?.auto_sync
722
- ? chalk.green("Enabled")
723
- : chalk.gray("Disabled")
724
- }`
725
- );
726
-
727
- // Snapshot information
728
575
  if (syncStatus.snapshot) {
729
576
  const fileCount = syncStatus.snapshot.files.size;
730
- const dirCount = syncStatus.snapshot.directories.size;
731
-
732
- console.log(`\n${chalk.bold("📊 Repository Statistics:")}`);
733
- console.log(` 📄 Tracked files: ${chalk.yellow(fileCount)}`);
734
- console.log(` 📁 Tracked directories: ${chalk.yellow(dirCount)}`);
735
- console.log(
736
- ` 🏷️ Snapshot timestamp: ${chalk.gray(
737
- new Date(syncStatus.snapshot.timestamp).toLocaleString()
738
- )}`
739
- );
577
+ out.pair("Files", `${fileCount} tracked`);
740
578
  }
741
579
 
742
- // Quick actions
743
- console.log(`\n${chalk.bold("🚀 Quick Actions:")}`);
580
+ out.pair("Sync", config?.sync_server || "wss://sync3.automerge.org");
581
+
744
582
  if (syncStatus.hasChanges) {
745
- console.log(
746
- ` ${chalk.cyan("pushwork diff")} - View pending changes`
747
- );
748
- console.log(` ${chalk.cyan("pushwork sync")} - Apply changes`);
583
+ out.pair("Changes", `${syncStatus.changeCount} pending`);
584
+ out.log("");
585
+ out.log("Run 'pushwork diff' to see changes");
749
586
  } else {
750
- console.log(
751
- ` ${chalk.cyan("pushwork sync")} - Check for remote changes`
752
- );
587
+ out.pair("Status", "up to date");
753
588
  }
754
- console.log(` ${chalk.cyan("pushwork log")} - View sync history`);
755
589
 
756
- // Cleanup repo resources
757
590
  await safeRepoShutdown(repo, "status");
758
591
  } catch (error) {
759
- console.error(chalk.red(`❌ Status check failed: ${error}`));
760
- throw error;
592
+ out.error(`Status check failed: ${error}`);
593
+ out.exit(1);
761
594
  }
762
595
  }
763
596
 
@@ -768,47 +601,30 @@ export async function log(
768
601
  targetPath = ".",
769
602
  options: LogOptions
770
603
  ): Promise<void> {
771
- try {
772
- // Setup shared context with network disabled for log check
773
- const {
774
- repo: logRepo,
775
- syncEngine: logSyncEngine,
776
- workingDir,
777
- } = await setupCommandContext(targetPath, undefined, undefined, false);
778
- const logStatus = await logSyncEngine.getStatus();
779
-
780
- if (logStatus.snapshot?.rootDirectoryUrl) {
781
- console.log(
782
- chalk.gray(`Root URL: ${logStatus.snapshot.rootDirectoryUrl}`)
783
- );
784
- console.log("");
785
- }
604
+ const out = new Output();
786
605
 
787
- // TODO: Implement history tracking and display
788
- // For now, show basic information
789
-
790
- console.log(chalk.bold("Sync History:"));
606
+ try {
607
+ const { repo: logRepo, workingDir } = await setupCommandContext(
608
+ targetPath,
609
+ undefined,
610
+ undefined,
611
+ false
612
+ );
791
613
 
792
- // Check for snapshot files
614
+ // TODO: Implement history tracking
793
615
  const snapshotPath = path.join(workingDir, ".pushwork", "snapshot.json");
794
616
  if (await pathExists(snapshotPath)) {
795
617
  const stats = await fs.stat(snapshotPath);
796
-
797
- if (options.oneline) {
798
- console.log(`${stats.mtime.toISOString()} - Last sync`);
799
- } else {
800
- console.log(`Last sync: ${chalk.green(stats.mtime.toISOString())}`);
801
- console.log(`Snapshot size: ${stats.size} bytes`);
802
- }
618
+ out.info("HISTORY", "Sync history (stub)");
619
+ out.pair("Last sync", stats.mtime.toISOString());
803
620
  } else {
804
- console.log(chalk.yellow("No sync history found"));
621
+ out.info("No sync history found");
805
622
  }
806
623
 
807
- // Cleanup repo resources
808
624
  await safeRepoShutdown(logRepo, "log");
809
625
  } catch (error) {
810
- console.error(chalk.red(`Log failed: ${error}`));
811
- throw error;
626
+ out.error(`Log failed: ${error}`);
627
+ out.exit(1);
812
628
  }
813
629
  }
814
630
 
@@ -820,22 +636,18 @@ export async function checkout(
820
636
  targetPath = ".",
821
637
  options: CheckoutOptions
822
638
  ): Promise<void> {
639
+ const out = new Output();
640
+
823
641
  try {
824
- // Setup shared context
825
642
  const { workingDir } = await setupCommandContext(targetPath);
826
643
 
827
644
  // TODO: Implement checkout functionality
828
- // This would involve:
829
- // 1. Finding the sync with the given ID
830
- // 2. Restoring file states from that sync
831
- // 3. Updating the snapshot
832
-
833
- console.log(chalk.yellow(`Checkout functionality not yet implemented`));
834
- console.log(`Would restore to sync: ${syncId}`);
835
- console.log(`Target path: ${workingDir}`);
645
+ out.warn("NOT IMPLEMENTED", "Checkout not yet implemented");
646
+ out.pair("Sync ID", syncId);
647
+ out.pair("Path", workingDir);
836
648
  } catch (error) {
837
- console.error(chalk.red(`Checkout failed: ${error}`));
838
- throw error;
649
+ out.error(`Checkout failed: ${error}`);
650
+ out.exit(1);
839
651
  }
840
652
  }
841
653
 
@@ -847,22 +659,22 @@ export async function clone(
847
659
  targetPath: string,
848
660
  options: CloneOptions
849
661
  ): Promise<void> {
850
- const spinner = ora("Starting clone operation...").start();
662
+ // Validate sync server options
663
+ validateSyncServerOptions(options.syncServer, options.syncServerStorageId);
664
+
665
+ const out = new Output();
851
666
 
852
667
  try {
853
668
  const resolvedPath = path.resolve(targetPath);
854
669
 
855
- // Step 1: Directory setup
856
- spinner.text = "Setting up target directory...";
670
+ out.task(`Cloning ${rootUrl}`);
857
671
 
858
672
  // Check if directory exists and handle --force
859
673
  if (await pathExists(resolvedPath)) {
860
674
  const files = await fs.readdir(resolvedPath);
861
675
  if (files.length > 0 && !options.force) {
862
- spinner.fail(
863
- "Target directory is not empty. Use --force to overwrite."
864
- );
865
- return;
676
+ out.error("Target directory is not empty. Use --force to overwrite");
677
+ out.exit(1);
866
678
  }
867
679
  } else {
868
680
  await ensureDirectoryExists(resolvedPath);
@@ -872,26 +684,17 @@ export async function clone(
872
684
  const syncToolDir = path.join(resolvedPath, ".pushwork");
873
685
  if (await pathExists(syncToolDir)) {
874
686
  if (!options.force) {
875
- spinner.fail(
876
- "Directory already initialized for sync. Use --force to overwrite."
877
- );
878
- return;
687
+ out.error("Directory already initialized. Use --force to overwrite");
688
+ out.exit(1);
879
689
  }
880
- // Clean up existing sync directory
881
690
  await fs.rm(syncToolDir, { recursive: true, force: true });
882
691
  }
883
692
 
884
- console.log(chalk.gray(" Target directory prepared"));
885
-
886
- // Step 2: Create sync directories
887
- spinner.text = "Creating .pushwork directory...";
693
+ out.update("Creating sync directory");
888
694
  await ensureDirectoryExists(syncToolDir);
889
695
  await ensureDirectoryExists(path.join(syncToolDir, "automerge"));
890
696
 
891
- ProgressMessages.directoryStructureCreated();
892
-
893
- // Step 3: Configuration setup
894
- spinner.text = "Setting up configuration...";
697
+ out.update("Setting up configuration");
895
698
  const configManager = new ConfigManager(resolvedPath);
896
699
  const defaultSyncServer = options.syncServer || "wss://sync3.automerge.org";
897
700
  const defaultStorageId =
@@ -916,86 +719,64 @@ export async function clone(
916
719
  };
917
720
  await configManager.save(config);
918
721
 
919
- ProgressMessages.configSaved();
920
- ProgressMessages.syncServer(defaultSyncServer);
921
- ProgressMessages.storageId(defaultStorageId);
922
-
923
- // Step 4: Initialize Automerge repo and connect to root directory
924
- spinner.text = "Connecting to root directory document...";
722
+ out.update("Connecting to sync server");
925
723
  const repo = await createRepo(resolvedPath, {
926
724
  enableNetwork: true,
927
725
  syncServer: options.syncServer,
928
726
  syncServerStorageId: options.syncServerStorageId,
929
727
  });
930
728
 
931
- ProgressMessages.repoCreated();
932
- ProgressMessages.rootUrl(rootUrl);
933
-
934
- // Step 5: Initialize sync engine and pull existing structure
935
- spinner.text = "Downloading directory structure...";
729
+ out.update("Downloading files");
936
730
  const syncEngine = new SyncEngine(
937
731
  repo,
938
732
  resolvedPath,
939
733
  config.defaults.exclude_patterns,
940
- true, // Network sync enabled for clone
734
+ true,
941
735
  defaultStorageId
942
736
  );
943
737
 
944
- // Set the root directory URL to connect to the cloned repository
945
738
  await syncEngine.setRootDirectoryUrl(rootUrl as AutomergeUrl);
739
+ const result = await syncEngine.sync(false);
946
740
 
947
- // Sync to pull the existing directory structure and files
948
- const startTime = Date.now();
949
- await syncEngine.sync(false);
950
- const duration = Date.now() - startTime;
741
+ out.update("Writing to disk");
742
+ await safeRepoShutdown(repo, "clone");
951
743
 
952
- console.log(chalk.gray(` ✓ Directory sync completed in ${duration}ms`));
744
+ out.done();
953
745
 
954
- // Ensure all changes are flushed to disk
955
- spinner.text = "Flushing changes to disk...";
956
- await safeRepoShutdown(repo, "clone");
957
- ProgressMessages.changesWritten();
958
-
959
- spinner.succeed(`Cloned sync directory to ${chalk.green(resolvedPath)}`);
960
-
961
- console.log(`\n${chalk.bold("📂 Directory Cloned!")}`);
962
- console.log(` 📁 Directory: ${chalk.blue(resolvedPath)}`);
963
- console.log(` 🔗 Root URL: ${chalk.cyan(rootUrl)}`);
964
- console.log(` 🔗 Sync server: ${chalk.blue(defaultSyncServer)}`);
965
- console.log(
966
- `\n${chalk.green("Clone complete!")} Run ${chalk.cyan(
967
- "pushwork sync"
968
- )} to stay in sync.`
969
- );
746
+ out.pair("Path", resolvedPath);
747
+ out.pair("Files", `${result.filesChanged} downloaded`);
748
+ out.pair("Sync", defaultSyncServer);
749
+ out.success("CLONED", rootUrl);
970
750
  } catch (error) {
971
- spinner.fail(`Failed to clone: ${error}`);
972
- throw error;
751
+ out.error("FAILED", "Clone failed");
752
+ out.log(` ${error}`);
753
+ out.exit(1);
973
754
  }
755
+ process.exit();
974
756
  }
975
757
 
976
758
  /**
977
759
  * Get the root URL for the current pushwork repository
978
760
  */
979
- export async function url(targetPath = "."): Promise<void> {
761
+ export async function url(
762
+ targetPath: string = ".",
763
+ options: UrlOptions = {}
764
+ ): Promise<void> {
765
+ const out = new Output();
766
+
980
767
  try {
981
768
  const resolvedPath = path.resolve(targetPath);
982
-
983
- // Check if initialized
984
769
  const syncToolDir = path.join(resolvedPath, ".pushwork");
770
+
985
771
  if (!(await pathExists(syncToolDir))) {
986
- console.error(chalk.red("Directory not initialized for sync"));
987
- console.error(`Run ${chalk.cyan("pushwork init .")} to get started`);
988
- process.exit(1);
772
+ out.error("Directory not initialized for sync");
773
+ out.exit(1);
989
774
  }
990
775
 
991
- // Load the snapshot directly to get the URL without all the verbose output
992
776
  const snapshotPath = path.join(syncToolDir, "snapshot.json");
993
777
  if (!(await pathExists(snapshotPath))) {
994
- console.error(chalk.red("No snapshot found"));
995
- console.error(
996
- chalk.gray("The repository may not be properly initialized")
997
- );
998
- process.exit(1);
778
+ out.error("No snapshot found");
779
+ out.exit(1);
999
780
  }
1000
781
 
1001
782
  const snapshotData = await fs.readFile(snapshotPath, "utf-8");
@@ -1005,98 +786,72 @@ export async function url(targetPath = "."): Promise<void> {
1005
786
  // Output just the URL for easy use in scripts
1006
787
  console.log(snapshot.rootDirectoryUrl);
1007
788
  } else {
1008
- console.error(chalk.red("No root URL found in snapshot"));
1009
- console.error(
1010
- chalk.gray("The repository may not be properly initialized")
1011
- );
1012
- process.exit(1);
789
+ out.error("No root URL found in snapshot");
790
+ out.exit(1);
1013
791
  }
1014
792
  } catch (error) {
1015
- console.error(chalk.red(`Failed to get URL: ${error}`));
1016
- process.exit(1);
793
+ out.error(`Failed to get URL: ${error}`);
794
+ out.exit(1);
1017
795
  }
1018
796
  }
1019
797
 
1020
798
  export async function commit(
1021
799
  targetPath: string,
1022
- dryRun: boolean = false
800
+ options: CommitOptions = {}
1023
801
  ): Promise<void> {
1024
- const spinner = ora("Starting commit operation...").start();
1025
- let repo: Repo | undefined;
802
+ const out = new Output();
1026
803
 
1027
804
  try {
1028
- // Setup shared context with network disabled for local-only commit
1029
- spinner.text = "Setting up commit context...";
1030
- const context = await setupCommandContext(
805
+ out.task("Committing local changes");
806
+
807
+ const { repo, syncEngine } = await setupCommandContext(
1031
808
  targetPath,
1032
809
  undefined,
1033
810
  undefined,
1034
811
  false
1035
812
  );
1036
- repo = context.repo;
1037
- const syncEngine = context.syncEngine;
1038
- spinner.succeed("Connected to repository");
1039
-
1040
- // Run local commit only
1041
- spinner.text = "Committing local changes...";
1042
- const startTime = Date.now();
1043
- const result = await syncEngine.commitLocal(dryRun);
1044
- const duration = Date.now() - startTime;
1045
-
1046
- if (repo) {
1047
- await safeRepoShutdown(repo, "commit");
1048
- }
1049
- spinner.succeed(`Commit completed in ${duration}ms`);
1050
813
 
1051
- // Display results
1052
- console.log(chalk.green("\n✅ Commit Results:"));
1053
- console.log(` 📄 Files committed: ${result.filesChanged}`);
1054
- console.log(` 📁 Directories committed: ${result.directoriesChanged}`);
1055
- console.log(` ⏱️ Total time: ${duration}ms`);
814
+ const result = await syncEngine.commitLocal(options.dryRun || false);
815
+ await safeRepoShutdown(repo, "commit");
1056
816
 
1057
- if (result.warnings.length > 0) {
1058
- console.log(chalk.yellow("\n⚠️ Warnings:"));
1059
- result.warnings.forEach((warning: string) =>
1060
- console.log(chalk.yellow(` • ${warning}`))
1061
- );
1062
- }
817
+ out.done();
1063
818
 
1064
819
  if (result.errors.length > 0) {
1065
- console.log(chalk.red("\n❌ Errors:"));
1066
- result.errors.forEach((error) =>
1067
- console.log(
1068
- chalk.red(
1069
- ` • ${error.operation} at ${error.path}: ${error.error.message}`
1070
- )
1071
- )
1072
- );
1073
- process.exit(1);
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);
1074
825
  }
1075
826
 
1076
- console.log(
1077
- chalk.gray("\n💡 Run 'pushwork push' to upload to sync server")
1078
- );
1079
- } catch (error) {
1080
- if (repo) {
1081
- await safeRepoShutdown(repo, "commit-error");
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}`));
1082
835
  }
1083
- spinner.fail(`Commit failed: ${error}`);
1084
- console.error(chalk.red(`Error: ${error}`));
1085
- process.exit(1);
836
+ } catch (error) {
837
+ out.error(`Commit failed: ${error}`);
838
+ out.exit(1);
1086
839
  }
840
+ process.exit();
1087
841
  }
1088
842
 
1089
843
  /**
1090
844
  * Debug command to inspect internal document state
1091
845
  */
1092
846
  export async function debug(
1093
- targetPath = ".",
1094
- options: { verbose?: boolean } = {}
847
+ targetPath: string = ".",
848
+ options: DebugOptions = {}
1095
849
  ): Promise<void> {
850
+ const out = new Output();
851
+
1096
852
  try {
1097
- const spinner = ora("Loading debug information...").start();
853
+ out.task("Loading debug info");
1098
854
 
1099
- // Setup shared context with network disabled for debug check
1100
855
  const { repo, syncEngine, workingDir } = await setupCommandContext(
1101
856
  targetPath,
1102
857
  undefined,
@@ -1105,21 +860,12 @@ export async function debug(
1105
860
  );
1106
861
  const debugStatus = await syncEngine.getStatus();
1107
862
 
1108
- spinner.stop();
863
+ out.done("done");
1109
864
 
1110
- console.log(chalk.bold("🔍 Debug Information"));
1111
- console.log(`${"=".repeat(50)}`);
1112
-
1113
- // Directory information
1114
- console.log(`\n${chalk.bold("📁 Directory Information:")}`);
1115
- console.log(` 📂 Path: ${chalk.blue(workingDir)}`);
1116
- console.log(` 🔧 Config: ${path.join(workingDir, ".pushwork")}`);
865
+ out.info("DEBUG", workingDir);
1117
866
 
1118
867
  if (debugStatus.snapshot?.rootDirectoryUrl) {
1119
- console.log(`\n${chalk.bold("🗂️ Root Directory Document:")}`);
1120
- console.log(
1121
- ` 🔗 URL: ${chalk.cyan(debugStatus.snapshot.rootDirectoryUrl)}`
1122
- );
868
+ out.pair("URL", debugStatus.snapshot.rootDirectoryUrl);
1123
869
 
1124
870
  try {
1125
871
  const rootHandle = await repo.find<DirectoryDocument>(
@@ -1128,79 +874,156 @@ export async function debug(
1128
874
  const rootDoc = await rootHandle.doc();
1129
875
 
1130
876
  if (rootDoc) {
1131
- console.log(` 📊 Document Structure:`);
1132
- console.log(` 📄 Entries: ${rootDoc.docs.length}`);
1133
- console.log(` 🏷️ Type: ${rootDoc["@patchwork"].type}`);
1134
-
877
+ out.pair("Entries", rootDoc.docs.length.toString());
1135
878
  if (rootDoc.lastSyncAt) {
1136
879
  const lastSyncDate = new Date(rootDoc.lastSyncAt);
1137
- console.log(
1138
- ` 🕒 Last Sync At: ${chalk.green(lastSyncDate.toISOString())}`
1139
- );
1140
- console.log(
1141
- ` 🕒 Last Sync Timestamp: ${chalk.gray(rootDoc.lastSyncAt)}`
1142
- );
1143
- } else {
1144
- console.log(` 🕒 Last Sync At: ${chalk.yellow("Never set")}`);
880
+ out.pair("Last sync", lastSyncDate.toISOString());
1145
881
  }
1146
882
 
1147
883
  if (options.verbose) {
1148
- console.log(`\n 📋 Full Document Content:`);
1149
- console.log(JSON.stringify(rootDoc, null, 2));
1150
-
1151
- console.log(`\n 🏷️ Document Heads:`);
1152
- console.log(JSON.stringify(rootHandle.heads(), null, 2));
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));
1153
890
  }
1154
-
1155
- console.log(`\n 📁 Directory Entries:`);
1156
- rootDoc.docs.forEach((entry: any, index: number) => {
1157
- console.log(
1158
- ` ${index + 1}. ${entry.name} (${entry.type}) -> ${entry.url}`
1159
- );
1160
- });
1161
- } else {
1162
- console.log(` ❌ Unable to load root document`);
1163
891
  }
1164
892
  } catch (error) {
1165
- console.log(`Error loading root document: ${error}`);
893
+ out.warn(`Error loading root document: ${error}`);
1166
894
  }
1167
- } else {
1168
- console.log(`\n${chalk.bold("🗂️ Root Directory Document:")}`);
1169
- console.log(` ❌ No root directory URL set`);
1170
895
  }
1171
896
 
1172
- // Snapshot information
1173
897
  if (debugStatus.snapshot) {
1174
- console.log(`\n${chalk.bold("📸 Snapshot Information:")}`);
1175
- console.log(` 📄 Tracked files: ${debugStatus.snapshot.files.size}`);
1176
- console.log(
1177
- ` 📁 Tracked directories: ${debugStatus.snapshot.directories.size}`
1178
- );
1179
- console.log(
1180
- ` 🏷️ Timestamp: ${new Date(
1181
- debugStatus.snapshot.timestamp
1182
- ).toISOString()}`
1183
- );
1184
- console.log(` 📂 Root path: ${debugStatus.snapshot.rootPath}`);
898
+ out.pair("Files", debugStatus.snapshot.files.size.toString());
899
+ out.pair("Directories", debugStatus.snapshot.directories.size.toString());
1185
900
 
1186
901
  if (options.verbose) {
1187
- console.log(`\n 📋 All Tracked Files:`);
1188
- debugStatus.snapshot.files.forEach((entry, path) => {
1189
- console.log(` ${path} -> ${entry.url}`);
1190
- });
1191
-
1192
- console.log(`\n 📋 All Tracked Directories:`);
1193
- debugStatus.snapshot.directories.forEach((entry, path) => {
1194
- console.log(` ${path} -> ${entry.url}`);
902
+ out.log("");
903
+ out.log("All tracked files:");
904
+ debugStatus.snapshot.files.forEach((entry, filePath) => {
905
+ out.log(` ${filePath} -> ${entry.url}`);
1195
906
  });
1196
907
  }
1197
908
  }
1198
909
 
1199
- // Cleanup repo resources
1200
910
  await safeRepoShutdown(repo, "debug");
1201
911
  } catch (error) {
1202
- console.error(chalk.red(`Debug failed: ${error}`));
1203
- throw 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);
1204
1027
  }
1205
1028
  }
1206
1029