pushwork 1.0.4 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +87 -328
  2. package/dist/.pushwork/automerge/3P/Dm3ekE2pmjGnWvDaG3vSR7ww98/snapshot/aa2349c94955ea561f698720142f9d884a6872d9f82dc332d578c216beb0df0e +0 -0
  3. package/dist/.pushwork/automerge/st/orage-adapter-id +1 -0
  4. package/dist/.pushwork/config.json +15 -0
  5. package/dist/.pushwork/snapshot.json +7 -0
  6. package/dist/cli.js +231 -170
  7. package/dist/cli.js.map +1 -1
  8. package/dist/commands.d.ts +51 -0
  9. package/dist/commands.d.ts.map +1 -0
  10. package/dist/commands.js +799 -0
  11. package/dist/commands.js.map +1 -0
  12. package/dist/core/change-detection.d.ts +6 -19
  13. package/dist/core/change-detection.d.ts.map +1 -1
  14. package/dist/core/change-detection.js +101 -80
  15. package/dist/core/change-detection.js.map +1 -1
  16. package/dist/{config/index.d.ts → core/config.d.ts} +13 -3
  17. package/dist/core/config.d.ts.map +1 -0
  18. package/dist/{config/index.js → core/config.js} +55 -73
  19. package/dist/core/config.js.map +1 -0
  20. package/dist/core/index.d.ts +1 -0
  21. package/dist/core/index.d.ts.map +1 -1
  22. package/dist/core/index.js +1 -1
  23. package/dist/core/index.js.map +1 -1
  24. package/dist/core/move-detection.d.ts +12 -50
  25. package/dist/core/move-detection.d.ts.map +1 -1
  26. package/dist/core/move-detection.js +58 -139
  27. package/dist/core/move-detection.js.map +1 -1
  28. package/dist/core/snapshot.d.ts +0 -4
  29. package/dist/core/snapshot.d.ts.map +1 -1
  30. package/dist/core/snapshot.js +2 -11
  31. package/dist/core/snapshot.js.map +1 -1
  32. package/dist/core/sync-engine.d.ts +5 -11
  33. package/dist/core/sync-engine.d.ts.map +1 -1
  34. package/dist/core/sync-engine.js +220 -362
  35. package/dist/core/sync-engine.js.map +1 -1
  36. package/dist/index.d.ts +0 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +0 -6
  39. package/dist/index.js.map +1 -1
  40. package/dist/types/config.d.ts +43 -67
  41. package/dist/types/config.d.ts.map +1 -1
  42. package/dist/types/config.js +6 -0
  43. package/dist/types/config.js.map +1 -1
  44. package/dist/types/documents.d.ts +15 -3
  45. package/dist/types/documents.d.ts.map +1 -1
  46. package/dist/types/documents.js.map +1 -1
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/types/index.js +0 -3
  49. package/dist/types/index.js.map +1 -1
  50. package/dist/types/snapshot.d.ts +3 -21
  51. package/dist/types/snapshot.d.ts.map +1 -1
  52. package/dist/types/snapshot.js +0 -14
  53. package/dist/types/snapshot.js.map +1 -1
  54. package/dist/utils/content.d.ts.map +1 -1
  55. package/dist/utils/content.js +2 -6
  56. package/dist/utils/content.js.map +1 -1
  57. package/dist/utils/directory.d.ts +10 -0
  58. package/dist/utils/directory.d.ts.map +1 -0
  59. package/dist/utils/directory.js +37 -0
  60. package/dist/utils/directory.js.map +1 -0
  61. package/dist/utils/fs.d.ts +15 -2
  62. package/dist/utils/fs.d.ts.map +1 -1
  63. package/dist/utils/fs.js +63 -53
  64. package/dist/utils/fs.js.map +1 -1
  65. package/dist/utils/index.d.ts +1 -1
  66. package/dist/utils/index.d.ts.map +1 -1
  67. package/dist/utils/index.js +1 -4
  68. package/dist/utils/index.js.map +1 -1
  69. package/dist/utils/mime-types.d.ts.map +1 -1
  70. package/dist/utils/mime-types.js +11 -4
  71. package/dist/utils/mime-types.js.map +1 -1
  72. package/dist/utils/network-sync.d.ts +0 -6
  73. package/dist/utils/network-sync.d.ts.map +1 -1
  74. package/dist/utils/network-sync.js +55 -99
  75. package/dist/utils/network-sync.js.map +1 -1
  76. package/dist/utils/output.d.ts +129 -0
  77. package/dist/utils/output.d.ts.map +1 -0
  78. package/dist/utils/output.js +375 -0
  79. package/dist/utils/output.js.map +1 -0
  80. package/dist/utils/repo-factory.d.ts +2 -6
  81. package/dist/utils/repo-factory.d.ts.map +1 -1
  82. package/dist/utils/repo-factory.js +8 -22
  83. package/dist/utils/repo-factory.js.map +1 -1
  84. package/dist/utils/string-similarity.d.ts +14 -0
  85. package/dist/utils/string-similarity.d.ts.map +1 -0
  86. package/dist/utils/string-similarity.js +43 -0
  87. package/dist/utils/string-similarity.js.map +1 -0
  88. package/dist/utils/trace.d.ts +19 -0
  89. package/dist/utils/trace.d.ts.map +1 -0
  90. package/dist/utils/trace.js +68 -0
  91. package/dist/utils/trace.js.map +1 -0
  92. package/package.json +17 -12
  93. package/src/cli.ts +326 -252
  94. package/src/commands.ts +988 -0
  95. package/src/core/change-detection.ts +199 -162
  96. package/src/{config/index.ts → core/config.ts} +65 -82
  97. package/src/core/index.ts +1 -1
  98. package/src/core/move-detection.ts +74 -180
  99. package/src/core/snapshot.ts +2 -12
  100. package/src/core/sync-engine.ts +248 -499
  101. package/src/index.ts +0 -10
  102. package/src/types/config.ts +50 -72
  103. package/src/types/documents.ts +16 -3
  104. package/src/types/index.ts +0 -5
  105. package/src/types/snapshot.ts +1 -23
  106. package/src/utils/content.ts +2 -6
  107. package/src/utils/directory.ts +50 -0
  108. package/src/utils/fs.ts +67 -56
  109. package/src/utils/index.ts +1 -6
  110. package/src/utils/mime-types.ts +12 -4
  111. package/src/utils/network-sync.ts +79 -137
  112. package/src/utils/output.ts +450 -0
  113. package/src/utils/repo-factory.ts +13 -31
  114. package/src/utils/string-similarity.ts +54 -0
  115. package/src/utils/trace.ts +70 -0
  116. package/test/integration/exclude-patterns.test.ts +6 -15
  117. package/test/integration/fuzzer.test.ts +308 -391
  118. package/test/integration/init-sync.test.ts +89 -0
  119. package/test/integration/sync-deletion.test.ts +2 -61
  120. package/test/integration/sync-flow.test.ts +4 -24
  121. package/test/jest.setup.ts +34 -0
  122. package/test/unit/deletion-behavior.test.ts +3 -14
  123. package/test/unit/enhanced-mime-detection.test.ts +0 -22
  124. package/test/unit/snapshot.test.ts +2 -29
  125. package/test/unit/sync-convergence.test.ts +3 -198
  126. package/test/unit/sync-timing.test.ts +0 -44
  127. package/test/unit/utils.test.ts +0 -2
  128. package/tsconfig.json +3 -3
  129. package/dist/browser/browser-sync-engine.d.ts +0 -64
  130. package/dist/browser/browser-sync-engine.d.ts.map +0 -1
  131. package/dist/browser/browser-sync-engine.js +0 -303
  132. package/dist/browser/browser-sync-engine.js.map +0 -1
  133. package/dist/browser/filesystem-adapter.d.ts +0 -84
  134. package/dist/browser/filesystem-adapter.d.ts.map +0 -1
  135. package/dist/browser/filesystem-adapter.js +0 -413
  136. package/dist/browser/filesystem-adapter.js.map +0 -1
  137. package/dist/browser/index.d.ts +0 -36
  138. package/dist/browser/index.d.ts.map +0 -1
  139. package/dist/browser/index.js +0 -90
  140. package/dist/browser/index.js.map +0 -1
  141. package/dist/browser/types.d.ts +0 -70
  142. package/dist/browser/types.d.ts.map +0 -1
  143. package/dist/browser/types.js +0 -6
  144. package/dist/browser/types.js.map +0 -1
  145. package/dist/cli/commands.d.ts +0 -77
  146. package/dist/cli/commands.d.ts.map +0 -1
  147. package/dist/cli/commands.js +0 -904
  148. package/dist/cli/commands.js.map +0 -1
  149. package/dist/cli/index.d.ts +0 -2
  150. package/dist/cli/index.d.ts.map +0 -1
  151. package/dist/cli/index.js +0 -19
  152. package/dist/cli/index.js.map +0 -1
  153. package/dist/config/index.d.ts.map +0 -1
  154. package/dist/config/index.js.map +0 -1
  155. package/dist/core/isomorphic-snapshot.d.ts +0 -58
  156. package/dist/core/isomorphic-snapshot.d.ts.map +0 -1
  157. package/dist/core/isomorphic-snapshot.js +0 -204
  158. package/dist/core/isomorphic-snapshot.js.map +0 -1
  159. package/dist/platform/browser-filesystem.d.ts +0 -26
  160. package/dist/platform/browser-filesystem.d.ts.map +0 -1
  161. package/dist/platform/browser-filesystem.js +0 -91
  162. package/dist/platform/browser-filesystem.js.map +0 -1
  163. package/dist/platform/filesystem.d.ts +0 -29
  164. package/dist/platform/filesystem.d.ts.map +0 -1
  165. package/dist/platform/filesystem.js +0 -65
  166. package/dist/platform/filesystem.js.map +0 -1
  167. package/dist/platform/node-filesystem.d.ts +0 -21
  168. package/dist/platform/node-filesystem.d.ts.map +0 -1
  169. package/dist/platform/node-filesystem.js +0 -93
  170. package/dist/platform/node-filesystem.js.map +0 -1
  171. package/dist/utils/content-similarity.d.ts +0 -53
  172. package/dist/utils/content-similarity.d.ts.map +0 -1
  173. package/dist/utils/content-similarity.js +0 -155
  174. package/dist/utils/content-similarity.js.map +0 -1
  175. package/dist/utils/fs-browser.d.ts +0 -57
  176. package/dist/utils/fs-browser.d.ts.map +0 -1
  177. package/dist/utils/fs-browser.js +0 -311
  178. package/dist/utils/fs-browser.js.map +0 -1
  179. package/dist/utils/fs-node.d.ts +0 -53
  180. package/dist/utils/fs-node.d.ts.map +0 -1
  181. package/dist/utils/fs-node.js +0 -220
  182. package/dist/utils/fs-node.js.map +0 -1
  183. package/dist/utils/isomorphic.d.ts +0 -29
  184. package/dist/utils/isomorphic.d.ts.map +0 -1
  185. package/dist/utils/isomorphic.js +0 -139
  186. package/dist/utils/isomorphic.js.map +0 -1
  187. package/dist/utils/pure.d.ts +0 -25
  188. package/dist/utils/pure.d.ts.map +0 -1
  189. package/dist/utils/pure.js +0 -112
  190. package/dist/utils/pure.js.map +0 -1
  191. package/src/cli/commands.ts +0 -1207
  192. package/src/cli/index.ts +0 -2
  193. package/src/utils/content-similarity.ts +0 -194
  194. package/test/README-TESTING-GAPS.md +0 -174
  195. package/test/unit/content-similarity.test.ts +0 -236
@@ -1,169 +1,111 @@
1
1
  import { DocHandle, StorageId } from "@automerge/automerge-repo";
2
2
  import * as A from "@automerge/automerge";
3
+ import { out } from "./output";
3
4
 
4
5
  /**
5
6
  * Wait for documents to sync to the remote server
6
- * Based on patchwork-cli implementation with timeout for debugging
7
7
  */
8
8
  export async function waitForSync(
9
9
  handlesToWaitOn: DocHandle<unknown>[],
10
10
  syncServerStorageId?: StorageId,
11
- timeoutMs: number = 60000 // 60 second timeout for debugging
11
+ timeoutMs: number = 60000
12
12
  ): Promise<void> {
13
13
  const startTime = Date.now();
14
14
 
15
15
  if (!syncServerStorageId) {
16
- console.warn(
17
- "No sync server storage ID provided. Skipping network sync wait."
18
- );
16
+ // No sync server storage ID - skip network sync
19
17
  return;
20
18
  }
21
19
 
22
20
  if (handlesToWaitOn.length === 0) {
23
- console.log("🔄 No documents to sync");
21
+ // No documents to sync
24
22
  return;
25
23
  }
26
24
 
27
- // Debug logging only in verbose mode (can be controlled via env var later)
28
- const verbose = false;
29
-
30
- if (verbose) {
31
- console.log(
32
- `🔄 Waiting for ${handlesToWaitOn.length} documents to sync...`
33
- );
34
- console.log(`📡 Using sync server storage ID: ${syncServerStorageId}`);
35
-
36
- handlesToWaitOn.forEach((handle, i) => {
37
- const localHeads = handle.heads();
38
- const syncInfo = handle.getSyncInfo(syncServerStorageId);
39
- const remoteHeads = syncInfo?.lastHeads;
40
- console.log(` 📄 Document ${i + 1}: ${handle.url}`);
41
- console.log(` 🏠 Local heads: ${JSON.stringify(localHeads)}`);
42
- console.log(` 🌐 Remote heads: ${JSON.stringify(remoteHeads)}`);
43
- console.log(
44
- ` ✅ Already synced: ${A.equals(localHeads, remoteHeads)}`
45
- );
46
- });
47
- }
25
+ let alreadySynced = 0;
26
+
27
+ const promises = handlesToWaitOn.map((handle) => {
28
+ // Check if already synced
29
+ const heads = handle.heads();
30
+ const syncInfo = handle.getSyncInfo(syncServerStorageId);
31
+ const remoteHeads = syncInfo?.lastHeads;
32
+ const wasAlreadySynced = A.equals(heads, remoteHeads);
33
+
34
+ if (wasAlreadySynced) {
35
+ alreadySynced++;
36
+ return Promise.resolve();
37
+ }
48
38
 
49
- const promises = handlesToWaitOn.map(
50
- (handle, index) =>
51
- new Promise<void>((resolve, reject) => {
52
- let pollInterval: NodeJS.Timeout;
53
-
54
- const timeout = setTimeout(() => {
55
- clearInterval(pollInterval);
56
- const localHeads = handle.heads();
57
- const syncInfo = handle.getSyncInfo(syncServerStorageId);
58
- const remoteHeads = syncInfo?.lastHeads;
59
- console.log(`⏰ TIMEOUT for document ${index + 1}: ${handle.url}`);
60
- console.log(` Final local heads: ${JSON.stringify(localHeads)}`);
61
- console.log(` Final remote heads: ${JSON.stringify(remoteHeads)}`);
62
- reject(
63
- new Error(
64
- `Sync timeout after ${timeoutMs}ms for document ${handle.url}`
65
- )
66
- );
67
- }, timeoutMs);
68
-
69
- const checkSync = () => {
70
- const newHeads = handle.heads();
71
- const syncInfo = handle.getSyncInfo(syncServerStorageId);
72
- const remoteHeads = syncInfo?.lastHeads;
73
-
74
- if (verbose) {
75
- console.log(`🔍 Checking sync for ${handle.url}:`);
76
- console.log(` Local heads: ${JSON.stringify(newHeads)}`);
77
- console.log(` Remote heads: ${JSON.stringify(remoteHeads)}`);
78
- console.log(` Heads equal: ${A.equals(newHeads, remoteHeads)}`);
79
- }
80
-
81
- // If the remote heads are already up to date, we can resolve immediately
82
- if (A.equals(newHeads, remoteHeads)) {
83
- if (verbose) {
84
- console.log(`✅ Document ${index + 1} synced: ${handle.url}`);
85
- }
86
- clearTimeout(timeout);
87
- clearInterval(pollInterval);
88
- resolve();
89
- return true;
90
- }
91
- return false;
92
- };
93
-
94
- // Check if already synced
95
- if (checkSync()) {
96
- return;
39
+ // Wait for convergence
40
+ return new Promise<void>((resolve, reject) => {
41
+ // TODO: can we delete this polling?
42
+ let pollInterval: NodeJS.Timeout;
43
+
44
+ const cleanup = () => {
45
+ clearTimeout(timeout);
46
+ clearInterval(pollInterval);
47
+ handle.off("remote-heads", onRemoteHeads);
48
+ };
49
+
50
+ const onConverged = () => {
51
+ cleanup();
52
+ resolve();
53
+ };
54
+
55
+ const timeout = setTimeout(() => {
56
+ cleanup();
57
+ reject(
58
+ new Error(
59
+ `Sync timeout after ${timeoutMs}ms for document ${handle.url}`
60
+ )
61
+ );
62
+ }, timeoutMs);
63
+
64
+ const isConverged = () => {
65
+ const localHeads = handle.heads();
66
+ const info = handle.getSyncInfo(syncServerStorageId);
67
+ return A.equals(localHeads, info?.lastHeads);
68
+ };
69
+
70
+ const onRemoteHeads = ({
71
+ storageId,
72
+ }: {
73
+ storageId: StorageId;
74
+ heads: any;
75
+ }) => {
76
+ if (storageId === syncServerStorageId && isConverged()) {
77
+ onConverged();
97
78
  }
79
+ };
98
80
 
99
- // Periodically re-check if heads have converged (polling fallback)
100
- pollInterval = setInterval(() => {
101
- if (checkSync()) {
102
- clearInterval(pollInterval);
103
- }
104
- }, 100); // Check every 100ms for faster response
105
-
106
- // Also wait for remote-heads event (faster when events work)
107
- const onRemoteHeads = ({
108
- storageId,
109
- heads,
110
- }: {
111
- storageId: StorageId;
112
- heads: any;
113
- }) => {
114
- if (verbose) {
115
- console.log(`📡 Received remote heads event for ${handle.url}:`);
116
- console.log(` Event storage ID: ${storageId}`);
117
- console.log(` Expected storage ID: ${syncServerStorageId}`);
118
- console.log(` Event heads: ${JSON.stringify(heads)}`);
119
- console.log(
120
- ` Current local heads: ${JSON.stringify(handle.heads())}`
121
- );
122
- }
123
-
124
- if (
125
- storageId === syncServerStorageId &&
126
- A.equals(handle.heads(), heads)
127
- ) {
128
- if (verbose) {
129
- console.log(
130
- `✅ Document ${index + 1} synced via event: ${handle.url}`
131
- );
132
- }
133
- clearTimeout(timeout);
134
- clearInterval(pollInterval);
135
- handle.off("remote-heads", onRemoteHeads);
136
- resolve();
137
- } else if (verbose) {
138
- console.log(`❌ Heads/storage mismatch for ${handle.url}`);
139
- }
140
- };
141
-
142
- if (verbose) {
143
- console.log(`👂 Listening for remote-heads events on ${handle.url}`);
81
+ const poll = () => {
82
+ if (isConverged()) {
83
+ onConverged();
84
+ return true;
144
85
  }
145
- handle.on("remote-heads", onRemoteHeads);
146
- })
147
- );
86
+ return false;
87
+ };
88
+
89
+ // Initial check
90
+ if (poll()) {
91
+ return;
92
+ }
93
+
94
+ // Start polling and event listening
95
+ pollInterval = setInterval(() => {
96
+ poll();
97
+ }, 100);
98
+
99
+ handle.on("remote-heads", onRemoteHeads);
100
+ });
101
+ });
148
102
 
149
103
  try {
150
104
  await Promise.all(promises);
151
- const elapsed = Date.now() - startTime;
152
- if (verbose) {
153
- console.log(`✅ All documents synced to network (took ${elapsed}ms)`);
154
- }
155
105
  } catch (error) {
156
106
  const elapsed = Date.now() - startTime;
157
- console.error(`❌ Sync wait failed after ${elapsed}ms: ${error}`);
107
+ out.errorBlock("FAILED", `after ${elapsed}ms`);
108
+ out.crash(error);
158
109
  throw error;
159
110
  }
160
111
  }
161
-
162
- /**
163
- * Get the storage ID for the sync server
164
- * Using the same ID as patchwork-cli for consistency
165
- */
166
- export function getSyncServerStorageId(customStorageId?: string): StorageId {
167
- return (customStorageId ||
168
- "3760df37-a4c6-4f66-9ecd-732039a9385d") as StorageId;
169
- }
@@ -0,0 +1,450 @@
1
+ import chalk from "chalk";
2
+ import ora, { Ora } from "ora";
3
+
4
+ /**
5
+ * Clean terminal output manager (Singleton)
6
+ * - Progress stays on one line (spinner updates in place)
7
+ * - No emojis
8
+ * - Background colors for section headers
9
+ * - Minimal output
10
+ * - Supports scrolling task lines (max-lines)
11
+ */
12
+ export class Output {
13
+ private static instance: Output | null = null;
14
+ private spinner: Ora | null = null;
15
+ private taskStartTime: number | null = null;
16
+ private taskOriginalMessage: string | null = null; // Original task message for done()
17
+ private taskCurrentMessage: string | null = null; // Current display message (can be updated)
18
+ private taskLines: string[] = []; // Lines written during active task
19
+ private taskMaxLines: number = 0; // 0 = unlimited
20
+
21
+ private constructor() {}
22
+
23
+ /**
24
+ * Get the singleton instance
25
+ */
26
+ static getInstance(): Output {
27
+ if (!Output.instance) {
28
+ Output.instance = new Output();
29
+ }
30
+ return Output.instance;
31
+ }
32
+
33
+ /**
34
+ * Reset the singleton (useful for testing)
35
+ */
36
+ static reset(): void {
37
+ if (Output.instance?.spinner) {
38
+ Output.instance.spinner.stop();
39
+ Output.instance.spinner.clear();
40
+ }
41
+ Output.instance = null;
42
+ }
43
+
44
+ /**
45
+ * Start a task with spinner - updates in place
46
+ * Completes any previous task before starting the new one
47
+ * @param message - The task message
48
+ * @param maxLines - Maximum number of task lines to show (0 = unlimited, lines scroll)
49
+ */
50
+ task(message: string, maxLines: number = 0): void {
51
+ // Complete any existing task first
52
+ if (this.spinner) {
53
+ this.done();
54
+ }
55
+
56
+ this.taskStartTime = Date.now();
57
+ this.taskOriginalMessage = message;
58
+ this.taskCurrentMessage = message;
59
+ this.taskMaxLines = maxLines;
60
+ this.taskLines = [];
61
+ this.spinner = ora(message).start();
62
+ }
63
+
64
+ /**
65
+ * Update spinner text (stays on same line)
66
+ */
67
+ update(message: string): void {
68
+ if (this.spinner) {
69
+ this.taskCurrentMessage = message;
70
+ this.#updateTaskDisplay();
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Add a line to the active task (appears below spinner, scrolls if max-lines set)
76
+ * Lines are dimmed and temporary - they disappear when task completes unless kept
77
+ * If no task is active, displays as a regular log message
78
+ */
79
+ taskLine(message: string, keepOnComplete: boolean = false): void {
80
+ if (!this.spinner) {
81
+ // No active task, just log normally as regular output
82
+ this.info(message);
83
+ return;
84
+ }
85
+
86
+ // Add to task lines buffer with keep flag
87
+ this.taskLines.push(keepOnComplete ? `[keep]${message}` : message);
88
+
89
+ // If max lines set, trim from the start (scroll)
90
+ if (this.taskMaxLines > 0 && this.taskLines.length > this.taskMaxLines) {
91
+ this.taskLines = this.taskLines.slice(-this.taskMaxLines);
92
+ }
93
+
94
+ this.#updateTaskDisplay();
95
+ }
96
+
97
+ /**
98
+ * Clear all task lines (useful when you want to reset the scrolling window)
99
+ */
100
+ clearTaskLines(): void {
101
+ this.taskLines = [];
102
+ this.#updateTaskDisplay();
103
+ }
104
+
105
+ /**
106
+ * Update the task display (spinner + task lines)
107
+ * Uses ora's multiline text support to keep spinner at top with lines below
108
+ */
109
+ #updateTaskDisplay(): void {
110
+ if (!this.spinner) return;
111
+
112
+ const currentText =
113
+ this.taskCurrentMessage || this.spinner.text.split("\n")[0] || "";
114
+
115
+ // If no task lines, show just the spinner message
116
+ if (this.taskLines.length === 0) {
117
+ this.spinner.text = currentText;
118
+ return;
119
+ }
120
+
121
+ // Build multiline text: spinner message + task lines below
122
+ const taskLinesText = this.taskLines
123
+ .map((line) => {
124
+ const cleanLine = line.startsWith("[keep]") ? line.slice(6) : line;
125
+ return chalk.dim(` ${cleanLine}`);
126
+ })
127
+ .join("\n");
128
+
129
+ // Set spinner text to include task lines (ora handles multiline rendering)
130
+ this.spinner.text = `${currentText}\n${taskLinesText}`;
131
+ }
132
+
133
+ /**
134
+ * Complete task with optional duration display
135
+ * Defaults to showing the original task message with duration
136
+ * Task lines marked with keepOnComplete will be preserved, others are cleared
137
+ */
138
+ done(message?: string, showTime: boolean = true): void {
139
+ if (!this.spinner) return;
140
+
141
+ let text = message || this.taskOriginalMessage || "done";
142
+ if (showTime && this.taskStartTime) {
143
+ const durationMs = Date.now() - this.taskStartTime;
144
+ const durationText = (() => {
145
+ switch (true) {
146
+ case durationMs < 1000:
147
+ return `${durationMs}ms`;
148
+ case durationMs < 2000:
149
+ return `${(durationMs / 1000).toFixed(2)}s`;
150
+ default:
151
+ return `${(durationMs / 1000).toFixed(1)}s`;
152
+ }
153
+ })();
154
+ text += chalk.dim(` (${durationText})`);
155
+ }
156
+
157
+ // Clear multiline text and set to just completion message
158
+ this.spinner.text = text;
159
+ this.spinner.succeed();
160
+ this.spinner = null;
161
+
162
+ // Print kept task lines after completion
163
+ const keptLines = this.taskLines.filter((line) =>
164
+ line.startsWith("[keep]")
165
+ );
166
+ for (const line of keptLines) {
167
+ console.log(chalk.dim(` ${line.slice(6)}`));
168
+ }
169
+
170
+ this.taskStartTime = null;
171
+ this.taskOriginalMessage = null;
172
+ this.taskCurrentMessage = null;
173
+ this.taskLines = [];
174
+ this.taskMaxLines = 0;
175
+ }
176
+
177
+ /**
178
+ * Show an object as a table of key-value pairs
179
+ * Filters out undefined values and applies optional transforms
180
+ * Automatically calculates key padding from max key length
181
+ */
182
+ obj(
183
+ obj: Record<string, any>,
184
+ keyTransform?: (key: string) => string,
185
+ valueTransform?: (value: any, key: string) => string
186
+ ): void {
187
+ this.#stopTask();
188
+
189
+ // Filter out undefined values and apply key transform
190
+ const entries: Array<[string, string, any]> = [];
191
+ for (const [key, value] of Object.entries(obj)) {
192
+ if (value === undefined) continue;
193
+ const displayKey = keyTransform ? keyTransform(key) : key;
194
+ entries.push([key, displayKey, value]);
195
+ }
196
+
197
+ // Calculate max key length for padding
198
+ const maxKeyLength = Math.max(
199
+ ...entries.map(([, displayKey]) => displayKey.length)
200
+ );
201
+
202
+ // Print each entry
203
+ for (const [key, displayKey, value] of entries) {
204
+ const displayValue = valueTransform
205
+ ? valueTransform(value, key)
206
+ : String(value);
207
+ const keyFormatted = chalk.dim(displayKey.padEnd(maxKeyLength + 2));
208
+ console.log(`${keyFormatted}${displayValue}`);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Display array as bulleted list
214
+ * Each item shown with dim bullet and white text
215
+ */
216
+ arr(items: any[]): void {
217
+ this.#stopTask();
218
+
219
+ for (const item of items) {
220
+ const bullet = chalk.dim("• ");
221
+ console.log(`${bullet}${String(item)}`);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Show plain message with optional color
227
+ */
228
+ log(
229
+ message: string,
230
+ color?:
231
+ | "red"
232
+ | "green"
233
+ | "yellow"
234
+ | "blue"
235
+ | "cyan"
236
+ | "magenta"
237
+ | "gray"
238
+ | "dim"
239
+ ): void {
240
+ this.#stopTask();
241
+
242
+ if (color) {
243
+ const colorFn = color === "dim" ? chalk.dim : chalk[color];
244
+ console.log(colorFn(message));
245
+ } else {
246
+ console.log(message);
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Show success message (green text)
252
+ */
253
+ success(message: string): void {
254
+ this.#stopTask();
255
+ console.log(chalk.green(message));
256
+ }
257
+
258
+ /**
259
+ * Show success block (green background label + optional message)
260
+ */
261
+ successBlock(label: string, message: string = ""): void {
262
+ this.#stopTask();
263
+ console.log(
264
+ `\n${chalk.bgGreen.black(` ${label} `)}${message && ` ${message}`}`
265
+ );
266
+ }
267
+
268
+ /**
269
+ * Show success message (green text)
270
+ */
271
+ spicy(message: string): void {
272
+ this.#stopTask();
273
+ console.log(chalk.cyan(message));
274
+ }
275
+
276
+ /**
277
+ * Show success block (green background label + optional message)
278
+ */
279
+ spicyBlock(label: string, message: string = ""): void {
280
+ this.#stopTask();
281
+ console.log(
282
+ `\n${chalk.bgCyan.black(` ${label} `)}${message && ` ${message}`}`
283
+ );
284
+ }
285
+
286
+ /**
287
+ * Show message with rainbow gradient
288
+ */
289
+ rainbow(message: string): void {
290
+ this.#stopTask();
291
+
292
+ // Rainbow colors in order
293
+ const colors = [
294
+ chalk.red,
295
+ chalk.rgb(255, 165, 0), // orange
296
+ chalk.yellow,
297
+ chalk.green,
298
+ chalk.cyan,
299
+ chalk.blue,
300
+ chalk.magenta,
301
+ ];
302
+
303
+ const chars = message.split("");
304
+ const colorCount = colors.length;
305
+
306
+ // Spread colors across the string
307
+ const rainbow = chars
308
+ .map((char, i) => {
309
+ // Calculate which color to use based on position
310
+ const colorIndex = Math.floor((i / chars.length) * colorCount);
311
+ const color = colors[Math.min(colorIndex, colorCount - 1)];
312
+ return color(char);
313
+ })
314
+ .join("");
315
+
316
+ console.log(rainbow);
317
+ }
318
+
319
+ /**
320
+ * Show info message (dim text)
321
+ */
322
+ info(message: string): void {
323
+ this.#stopTask();
324
+ console.log(chalk.dim(message));
325
+ }
326
+
327
+ /**
328
+ * Show info block (grey background label + optional message)
329
+ */
330
+ infoBlock(label: string, message: string = ""): void {
331
+ this.#stopTask();
332
+ console.log(
333
+ `\n${chalk.bgGrey.white(` ${label} `)}${message && ` ${message}`}`
334
+ );
335
+ }
336
+
337
+ /**
338
+ * Show error message (red text) - fails spinner if running
339
+ */
340
+ error(message: string | Error | unknown): void {
341
+ if (this.spinner) {
342
+ this.spinner.fail("failed");
343
+ this.spinner = null;
344
+ this.taskStartTime = null;
345
+ this.taskOriginalMessage = null;
346
+ this.taskCurrentMessage = null;
347
+ }
348
+ console.log(
349
+ chalk.red(
350
+ message instanceof Error
351
+ ? message.message
352
+ : message instanceof Object
353
+ ? JSON.stringify(message)
354
+ : String(message)
355
+ )
356
+ );
357
+ }
358
+
359
+ /**
360
+ * Show error block (red background label + optional message) - fails spinner if running
361
+ */
362
+ errorBlock(label: string, message: string = ""): void {
363
+ if (this.spinner) {
364
+ this.spinner.fail("failed");
365
+ this.spinner = null;
366
+ this.taskStartTime = null;
367
+ this.taskOriginalMessage = null;
368
+ this.taskCurrentMessage = null;
369
+ }
370
+ console.log(
371
+ `\n${chalk.bgRed.white(` ${label} `)}${message && ` ${message}`}`
372
+ );
373
+ }
374
+
375
+ /**
376
+ * Show warning message (yellow text)
377
+ */
378
+ warn(message: string): void {
379
+ this.#stopTask();
380
+ console.log(chalk.yellow(message));
381
+ }
382
+
383
+ /**
384
+ * Show warning block (yellow background label + optional message)
385
+ */
386
+ warnBlock(label: string, message: string = ""): void {
387
+ this.#stopTask();
388
+ console.log(
389
+ `\n${chalk.bgYellow.black(` ${label} `)}${message && ` ${message}`}`
390
+ );
391
+ }
392
+
393
+ /**
394
+ * Show detailed error information and exit the program
395
+ * Use this when an unexpected/unrecoverable error occurs
396
+ * Shows error message and stack trace, then exits
397
+ */
398
+ crash(error: unknown, exitCode: number = 1): never {
399
+ this.#stopTask();
400
+
401
+ if (error instanceof Error) {
402
+ // Error type and message
403
+ console.log(chalk.red(`${error.name}: ${error.message}`));
404
+
405
+ // Stack trace
406
+ if (error.stack) {
407
+ console.log("");
408
+ console.log(chalk.dim("Stack trace:"));
409
+ const stackLines = error.stack.split("\n").slice(1); // Skip first line (error message)
410
+ stackLines.forEach((line) =>
411
+ console.log(chalk.dim(` ${line.trim()}`))
412
+ );
413
+ }
414
+ } else {
415
+ console.log(chalk.red(String(error)));
416
+ }
417
+
418
+ process.exit(exitCode);
419
+ }
420
+
421
+ /**
422
+ * Exit with code
423
+ */
424
+ exit(code?: number): never {
425
+ this.#stopTask();
426
+ process.exit(code || 0);
427
+ }
428
+
429
+ /**
430
+ * Stop spinner without showing result
431
+ */
432
+ #stopTask(): void {
433
+ if (this.spinner) {
434
+ this.spinner.stop();
435
+ this.spinner.clear();
436
+ this.spinner = null;
437
+ }
438
+ this.taskStartTime = null;
439
+ this.taskOriginalMessage = null;
440
+ this.taskCurrentMessage = null;
441
+ this.taskLines = [];
442
+ this.taskMaxLines = 0;
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Global singleton output instance
448
+ * Import and use this anywhere in your code
449
+ */
450
+ export const out = Output.getInstance();