git-watchtower 1.6.0 → 1.7.0

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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Async utilities for Git Watchtower
3
+ * Provides Mutex, timeout wrapper, and retry with exponential backoff
4
+ */
5
+
6
+ /**
7
+ * Simple mutex for preventing concurrent operations
8
+ * Use this to prevent race conditions in polling and server operations
9
+ */
10
+ class Mutex {
11
+ constructor() {
12
+ this.locked = false;
13
+ this.queue = [];
14
+ }
15
+
16
+ /**
17
+ * Acquire the lock. Returns a promise that resolves when lock is acquired.
18
+ * @returns {Promise<void>}
19
+ */
20
+ async acquire() {
21
+ return new Promise((resolve) => {
22
+ if (!this.locked) {
23
+ this.locked = true;
24
+ resolve();
25
+ } else {
26
+ this.queue.push(resolve);
27
+ }
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Release the lock. If there are waiting acquirers, the next one gets the lock.
33
+ */
34
+ release() {
35
+ if (this.queue.length > 0) {
36
+ const next = this.queue.shift();
37
+ next();
38
+ } else {
39
+ this.locked = false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Execute a function while holding the lock
45
+ * @template T
46
+ * @param {() => Promise<T>} fn - Async function to execute
47
+ * @returns {Promise<T>}
48
+ */
49
+ async withLock(fn) {
50
+ await this.acquire();
51
+ try {
52
+ return await fn();
53
+ } finally {
54
+ this.release();
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Check if the mutex is currently locked
60
+ * @returns {boolean}
61
+ */
62
+ isLocked() {
63
+ return this.locked;
64
+ }
65
+
66
+ /**
67
+ * Get the number of waiters in the queue
68
+ * @returns {number}
69
+ */
70
+ getQueueLength() {
71
+ return this.queue.length;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Wrap a promise with a timeout
77
+ * @template T
78
+ * @param {Promise<T>} promise - The promise to wrap
79
+ * @param {number} ms - Timeout in milliseconds
80
+ * @param {string} [message] - Custom timeout error message
81
+ * @returns {Promise<T>}
82
+ * @throws {Error} If the timeout is exceeded
83
+ */
84
+ function withTimeout(promise, ms, message = 'Operation timed out') {
85
+ let timeoutId;
86
+
87
+ const timeoutPromise = new Promise((_, reject) => {
88
+ timeoutId = setTimeout(() => {
89
+ reject(new Error(message));
90
+ }, ms);
91
+ });
92
+
93
+ return Promise.race([promise, timeoutPromise]).finally(() => {
94
+ clearTimeout(timeoutId);
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Retry an async function with exponential backoff
100
+ * @template T
101
+ * @param {() => Promise<T>} fn - Async function to retry
102
+ * @param {Object} [options] - Retry options
103
+ * @param {number} [options.maxAttempts=3] - Maximum number of attempts
104
+ * @param {number} [options.baseDelay=1000] - Base delay in ms (doubles each retry)
105
+ * @param {number} [options.maxDelay=30000] - Maximum delay between retries
106
+ * @param {(error: Error) => boolean} [options.shouldRetry] - Function to determine if error is retryable
107
+ * @returns {Promise<T>}
108
+ * @throws {Error} The last error if all retries fail
109
+ */
110
+ async function retry(fn, options = {}) {
111
+ const {
112
+ maxAttempts = 3,
113
+ baseDelay = 1000,
114
+ maxDelay = 30000,
115
+ shouldRetry = () => true,
116
+ } = options;
117
+
118
+ let lastError;
119
+
120
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
121
+ try {
122
+ return await fn();
123
+ } catch (error) {
124
+ lastError = error;
125
+
126
+ // Check if we should retry this error
127
+ if (!shouldRetry(error)) {
128
+ throw error;
129
+ }
130
+
131
+ // Don't wait after the last attempt
132
+ if (attempt < maxAttempts) {
133
+ const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
134
+ await sleep(delay);
135
+ }
136
+ }
137
+ }
138
+
139
+ throw lastError;
140
+ }
141
+
142
+ /**
143
+ * Sleep for a given number of milliseconds
144
+ * @param {number} ms - Milliseconds to sleep
145
+ * @returns {Promise<void>}
146
+ */
147
+ function sleep(ms) {
148
+ return new Promise((resolve) => setTimeout(resolve, ms));
149
+ }
150
+
151
+ /**
152
+ * Debounce a function - only execute after delay with no calls
153
+ * @template {(...args: any[]) => void} T
154
+ * @param {T} fn - Function to debounce
155
+ * @param {number} delay - Delay in milliseconds
156
+ * @returns {T & { cancel: () => void }}
157
+ */
158
+ function debounce(fn, delay) {
159
+ let timeoutId = null;
160
+
161
+ const debounced = (...args) => {
162
+ if (timeoutId) {
163
+ clearTimeout(timeoutId);
164
+ }
165
+ timeoutId = setTimeout(() => {
166
+ fn(...args);
167
+ timeoutId = null;
168
+ }, delay);
169
+ };
170
+
171
+ debounced.cancel = () => {
172
+ if (timeoutId) {
173
+ clearTimeout(timeoutId);
174
+ timeoutId = null;
175
+ }
176
+ };
177
+
178
+ // @ts-ignore - TypeScript can't verify generic function augmentation
179
+ return debounced;
180
+ }
181
+
182
+ /**
183
+ * Throttle a function - execute at most once per interval
184
+ * @template {(...args: any[]) => void} T
185
+ * @param {T} fn - Function to throttle
186
+ * @param {number} interval - Minimum interval between calls in milliseconds
187
+ * @returns {T}
188
+ */
189
+ function throttle(fn, interval) {
190
+ let lastCall = 0;
191
+ let timeoutId = null;
192
+
193
+ // @ts-ignore - TypeScript can't verify generic function return type
194
+ return (...args) => {
195
+ const now = Date.now();
196
+ const timeSinceLastCall = now - lastCall;
197
+
198
+ if (timeSinceLastCall >= interval) {
199
+ lastCall = now;
200
+ fn(...args);
201
+ } else if (!timeoutId) {
202
+ // Schedule a call for when the interval expires
203
+ timeoutId = setTimeout(() => {
204
+ lastCall = Date.now();
205
+ timeoutId = null;
206
+ fn(...args);
207
+ }, interval - timeSinceLastCall);
208
+ }
209
+ };
210
+ }
211
+
212
+ module.exports = {
213
+ Mutex,
214
+ withTimeout,
215
+ retry,
216
+ sleep,
217
+ debounce,
218
+ throttle,
219
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Cross-platform browser opening utility
3
+ * @module utils/browser
4
+ */
5
+
6
+ const { execFile } = require('child_process');
7
+
8
+ /**
9
+ * Open a URL in the user's default browser.
10
+ * Cross-platform: macOS (open), Windows (start), Linux (xdg-open).
11
+ * Uses execFile (no shell) to prevent command injection via crafted URLs.
12
+ * @param {string} url - The URL to open
13
+ * @param {function} [onError] - Optional error callback (receives Error)
14
+ */
15
+ function openInBrowser(url, onError) {
16
+ const platform = process.platform;
17
+ let command;
18
+ let args;
19
+
20
+ if (platform === 'darwin') {
21
+ command = 'open';
22
+ args = [url];
23
+ } else if (platform === 'win32') {
24
+ // On Windows, 'start' is a shell built-in, so we must use cmd.exe.
25
+ // The URL is passed as a separate argument, not interpolated into a string.
26
+ command = 'cmd.exe';
27
+ args = ['/c', 'start', '', url];
28
+ } else {
29
+ command = 'xdg-open';
30
+ args = [url];
31
+ }
32
+
33
+ execFile(command, args, (error) => {
34
+ if (error && onError) {
35
+ onError(error);
36
+ }
37
+ });
38
+ }
39
+
40
+ module.exports = { openInBrowser };