bgit-cli 2.0.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,300 @@
1
+ /**
2
+ * bgit OAuth Callback Server
3
+ *
4
+ * Temporary local HTTP server for capturing HandCash OAuth callbacks.
5
+ * Runs on localhost:3000-3010 with 5-minute timeout.
6
+ *
7
+ * Features:
8
+ * - Automatic port conflict resolution (tries 3000-3010)
9
+ * - Timeout handling (5 minutes)
10
+ * - Graceful shutdown after token capture
11
+ * - User-friendly success/error pages
12
+ */
13
+
14
+ const express = require('express');
15
+ const http = require('http');
16
+ const {
17
+ OAUTH_PORT_START,
18
+ OAUTH_PORT_END,
19
+ OAUTH_TIMEOUT_MS,
20
+ OAUTH_HOST
21
+ } = require('./constants');
22
+
23
+ /**
24
+ * Start temporary OAuth callback server
25
+ *
26
+ * @param {number} startPort - First port to try (default: 3000)
27
+ * @param {number} endPort - Last port to try (default: 3010)
28
+ * @returns {Promise<{authToken: string, port: number, server: http.Server}>}
29
+ * @throws {Error} If all ports are in use or timeout occurs
30
+ */
31
+ async function startOAuthServer(startPort = OAUTH_PORT_START, endPort = OAUTH_PORT_END) {
32
+ const app = express();
33
+ let server;
34
+ let actualPort;
35
+ let resolveToken;
36
+ let rejectToken;
37
+
38
+ // Create promise for token capture
39
+ const tokenPromise = new Promise((resolve, reject) => {
40
+ resolveToken = resolve;
41
+ rejectToken = reject;
42
+
43
+ // Set timeout (5 minutes)
44
+ setTimeout(() => {
45
+ reject(new Error('OAuth timeout: No authorization received within 5 minutes'));
46
+ }, OAUTH_TIMEOUT_MS);
47
+ });
48
+
49
+ // Success callback route
50
+ app.get('/callback', (req, res) => {
51
+ const { authToken } = req.query;
52
+
53
+ if (authToken) {
54
+ res.send(`
55
+ <!DOCTYPE html>
56
+ <html>
57
+ <head>
58
+ <title>bgit Authentication Successful</title>
59
+ <style>
60
+ body {
61
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
62
+ display: flex;
63
+ justify-content: center;
64
+ align-items: center;
65
+ height: 100vh;
66
+ margin: 0;
67
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
68
+ }
69
+ .container {
70
+ background: white;
71
+ padding: 3rem;
72
+ border-radius: 10px;
73
+ box-shadow: 0 10px 40px rgba(0,0,0,0.2);
74
+ text-align: center;
75
+ max-width: 400px;
76
+ }
77
+ h1 {
78
+ color: #2d3748;
79
+ margin-bottom: 1rem;
80
+ }
81
+ .success-icon {
82
+ font-size: 4rem;
83
+ margin-bottom: 1rem;
84
+ }
85
+ p {
86
+ color: #4a5568;
87
+ line-height: 1.6;
88
+ }
89
+ .close-hint {
90
+ margin-top: 2rem;
91
+ font-size: 0.875rem;
92
+ color: #718096;
93
+ }
94
+ </style>
95
+ </head>
96
+ <body>
97
+ <div class="container">
98
+ <div class="success-icon">✅</div>
99
+ <h1>Authentication Successful!</h1>
100
+ <p>Your HandCash wallet has been connected to bgit.</p>
101
+ <p>You can now close this window and return to your terminal.</p>
102
+ <div class="close-hint">This window will close automatically in 5 seconds...</div>
103
+ </div>
104
+ <script>
105
+ setTimeout(() => window.close(), 5000);
106
+ </script>
107
+ </body>
108
+ </html>
109
+ `);
110
+
111
+ // Resolve with token
112
+ resolveToken({ authToken, port: actualPort, server });
113
+ } else {
114
+ res.status(400).send(`
115
+ <!DOCTYPE html>
116
+ <html>
117
+ <head>
118
+ <title>bgit Authentication Failed</title>
119
+ <style>
120
+ body {
121
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
122
+ display: flex;
123
+ justify-content: center;
124
+ align-items: center;
125
+ height: 100vh;
126
+ margin: 0;
127
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
128
+ }
129
+ .container {
130
+ background: white;
131
+ padding: 3rem;
132
+ border-radius: 10px;
133
+ box-shadow: 0 10px 40px rgba(0,0,0,0.2);
134
+ text-align: center;
135
+ max-width: 400px;
136
+ }
137
+ h1 {
138
+ color: #2d3748;
139
+ margin-bottom: 1rem;
140
+ }
141
+ .error-icon {
142
+ font-size: 4rem;
143
+ margin-bottom: 1rem;
144
+ }
145
+ p {
146
+ color: #4a5568;
147
+ line-height: 1.6;
148
+ }
149
+ </style>
150
+ </head>
151
+ <body>
152
+ <div class="container">
153
+ <div class="error-icon">❌</div>
154
+ <h1>Authentication Failed</h1>
155
+ <p>No authorization token received from HandCash.</p>
156
+ <p>Please try again by running: <code>bgit auth login</code></p>
157
+ </div>
158
+ </body>
159
+ </html>
160
+ `);
161
+
162
+ rejectToken(new Error('No auth token received in callback'));
163
+ }
164
+ });
165
+
166
+ // Error callback route
167
+ app.get('/error', (req, res) => {
168
+ const { message } = req.query;
169
+
170
+ res.status(400).send(`
171
+ <!DOCTYPE html>
172
+ <html>
173
+ <head>
174
+ <title>bgit Authentication Error</title>
175
+ <style>
176
+ body {
177
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
178
+ display: flex;
179
+ justify-content: center;
180
+ align-items: center;
181
+ height: 100vh;
182
+ margin: 0;
183
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
184
+ }
185
+ .container {
186
+ background: white;
187
+ padding: 3rem;
188
+ border-radius: 10px;
189
+ box-shadow: 0 10px 40px rgba(0,0,0,0.2);
190
+ text-align: center;
191
+ max-width: 400px;
192
+ }
193
+ h1 {
194
+ color: #2d3748;
195
+ margin-bottom: 1rem;
196
+ }
197
+ .error-icon {
198
+ font-size: 4rem;
199
+ margin-bottom: 1rem;
200
+ }
201
+ p {
202
+ color: #4a5568;
203
+ line-height: 1.6;
204
+ }
205
+ .error-message {
206
+ background: #fed7d7;
207
+ padding: 1rem;
208
+ border-radius: 5px;
209
+ margin-top: 1rem;
210
+ color: #c53030;
211
+ }
212
+ </style>
213
+ </head>
214
+ <body>
215
+ <div class="container">
216
+ <div class="error-icon">⚠️</div>
217
+ <h1>Authentication Error</h1>
218
+ <p>An error occurred during authentication:</p>
219
+ <div class="error-message">${message || 'Unknown error'}</div>
220
+ <p style="margin-top: 2rem;">Please try again by running: <code>bgit auth login</code></p>
221
+ </div>
222
+ </body>
223
+ </html>
224
+ `);
225
+
226
+ rejectToken(new Error(`OAuth error: ${message || 'Unknown error'}`));
227
+ });
228
+
229
+ // Health check route
230
+ app.get('/health', (req, res) => {
231
+ res.send('OK');
232
+ });
233
+
234
+ // Try to start server on available port
235
+ for (let port = startPort; port <= endPort; port++) {
236
+ try {
237
+ server = await new Promise((resolve, reject) => {
238
+ const srv = http.createServer(app);
239
+
240
+ srv.listen(port, OAUTH_HOST, () => {
241
+ actualPort = port;
242
+ resolve(srv);
243
+ });
244
+
245
+ srv.on('error', (err) => {
246
+ if (err.code === 'EADDRINUSE') {
247
+ resolve(null); // Try next port
248
+ } else {
249
+ reject(err);
250
+ }
251
+ });
252
+ });
253
+
254
+ if (server) {
255
+ console.log(`OAuth server started on http://${OAUTH_HOST}:${actualPort}`);
256
+ break; // Successfully started
257
+ }
258
+ } catch (error) {
259
+ throw new Error(`Failed to start OAuth server: ${error.message}`);
260
+ }
261
+ }
262
+
263
+ if (!server) {
264
+ throw new Error(`All ports (${startPort}-${endPort}) are in use. Please free up a port and try again.`);
265
+ }
266
+
267
+ // Wait for token or timeout
268
+ const result = await tokenPromise;
269
+
270
+ return result;
271
+ }
272
+
273
+ /**
274
+ * Stop OAuth server
275
+ *
276
+ * @param {http.Server} server - Server instance to stop
277
+ * @returns {Promise<void>}
278
+ */
279
+ function stopOAuthServer(server) {
280
+ return new Promise((resolve, reject) => {
281
+ if (!server) {
282
+ resolve();
283
+ return;
284
+ }
285
+
286
+ server.close((err) => {
287
+ if (err) {
288
+ reject(new Error(`Failed to stop OAuth server: ${err.message}`));
289
+ } else {
290
+ console.log('OAuth server stopped');
291
+ resolve();
292
+ }
293
+ });
294
+ });
295
+ }
296
+
297
+ module.exports = {
298
+ startOAuthServer,
299
+ stopOAuthServer
300
+ };
package/lib/payment.js ADDED
@@ -0,0 +1,287 @@
1
+ /**
2
+ * bgit Payment Gateway
3
+ *
4
+ * Handles BitcoinSV payments via HandCash for commit timestamps and pushes.
5
+ * Enhanced with retry logic, error handling, and user-friendly messages.
6
+ *
7
+ * Features:
8
+ * - Automatic retry with exponential backoff
9
+ * - Pre-flight balance check (optional)
10
+ * - Enhanced error messages
11
+ * - Transaction logging
12
+ * - Soft fail for git operations (git succeeds even if payment fails)
13
+ */
14
+
15
+ const { HandCashConnect } = require('@handcash/handcash-connect');
16
+ const chalk = require('chalk');
17
+ const {
18
+ HANDCASH_APP_ID,
19
+ HANDCASH_APP_SECRET,
20
+ TREASURY_HANDLE,
21
+ PAYMENT_RETRY_MAX_ATTEMPTS,
22
+ PAYMENT_RETRY_BASE_DELAY_MS,
23
+ PAYMENT_RETRY_MAX_DELAY_MS
24
+ } = require('./constants');
25
+
26
+ /**
27
+ * Sleep for specified milliseconds
28
+ * @param {number} ms - Milliseconds to sleep
29
+ * @returns {Promise<void>}
30
+ */
31
+ function sleep(ms) {
32
+ return new Promise(resolve => setTimeout(resolve, ms));
33
+ }
34
+
35
+ /**
36
+ * Calculate exponential backoff delay
37
+ * @param {number} attempt - Current attempt number (0-indexed)
38
+ * @returns {number} Delay in milliseconds
39
+ */
40
+ function getBackoffDelay(attempt) {
41
+ const delay = PAYMENT_RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
42
+ return Math.min(delay, PAYMENT_RETRY_MAX_DELAY_MS);
43
+ }
44
+
45
+ /**
46
+ * Execute payment with retry logic
47
+ *
48
+ * @param {number} amount - Amount in BSV
49
+ * @param {string} note - Payment description/note (e.g., "Commit: abc123")
50
+ * @param {string} authToken - HandCash auth token
51
+ * @param {Object} options - Additional options
52
+ * @param {boolean} options.checkBalance - Whether to check balance before payment (default: false)
53
+ * @param {number} options.maxRetries - Max retry attempts (default: 3)
54
+ * @returns {Promise<Object>} Payment result with transactionId
55
+ * @throws {Error} If payment fails after all retries
56
+ */
57
+ async function executePayment(amount, note, authToken, options = {}) {
58
+ const {
59
+ checkBalance = false,
60
+ maxRetries = PAYMENT_RETRY_MAX_ATTEMPTS
61
+ } = options;
62
+
63
+ if (!authToken) {
64
+ throw new Error('Auth token is required for payment');
65
+ }
66
+
67
+ if (!amount || amount <= 0) {
68
+ throw new Error('Payment amount must be greater than 0');
69
+ }
70
+
71
+ try {
72
+ const handCashConnect = new HandCashConnect({
73
+ appId: HANDCASH_APP_ID,
74
+ appSecret: HANDCASH_APP_SECRET
75
+ });
76
+
77
+ const account = handCashConnect.getAccountFromAuthToken(authToken);
78
+
79
+ // Optional: Pre-flight balance check
80
+ if (checkBalance) {
81
+ try {
82
+ const balance = await account.wallet.getSpendableBalance();
83
+
84
+ if (balance.bsv < amount) {
85
+ throw new Error(
86
+ `Insufficient balance. You have ${balance.bsv.toFixed(6)} BSV, but need ${amount} BSV.\n` +
87
+ `Add funds at: ${chalk.cyan('https://handcash.io')}`
88
+ );
89
+ }
90
+ } catch (balanceError) {
91
+ // Don't fail payment if balance check fails, just warn
92
+ console.warn(chalk.yellow(`Warning: Could not check balance - ${balanceError.message}`));
93
+ }
94
+ }
95
+
96
+ // Payment parameters
97
+ const paymentParameters = {
98
+ description: note || 'bgit payment',
99
+ payments: [
100
+ {
101
+ destination: TREASURY_HANDLE,
102
+ currencyCode: 'BSV',
103
+ sendAmount: amount
104
+ }
105
+ ]
106
+ };
107
+
108
+ // Retry logic with exponential backoff
109
+ let lastError;
110
+
111
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
112
+ try {
113
+ const result = await account.wallet.pay(paymentParameters);
114
+
115
+ // Payment successful
116
+ console.log(chalk.green(`✓ Payment sent! Transaction ID: ${result.transactionId}`));
117
+
118
+ if (note) {
119
+ console.log(chalk.gray(` Note: ${note}`));
120
+ }
121
+
122
+ return result;
123
+ } catch (error) {
124
+ lastError = error;
125
+
126
+ // Check if error is retryable
127
+ const isRetryable = isRetryableError(error);
128
+
129
+ if (!isRetryable || attempt === maxRetries - 1) {
130
+ // Don't retry, throw error
131
+ break;
132
+ }
133
+
134
+ // Retry with backoff
135
+ const delay = getBackoffDelay(attempt);
136
+ console.log(chalk.yellow(`⚠️ Payment attempt ${attempt + 1} failed, retrying in ${delay / 1000}s... (${attempt + 1}/${maxRetries})`));
137
+ await sleep(delay);
138
+ }
139
+ }
140
+
141
+ // All retries failed
142
+ throw enhancePaymentError(lastError);
143
+ } catch (error) {
144
+ throw error;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Check if error is retryable
150
+ * Network errors and timeouts are retryable
151
+ * Insufficient balance and invalid tokens are not
152
+ *
153
+ * @param {Error} error - Error to check
154
+ * @returns {boolean} true if error is retryable
155
+ */
156
+ function isRetryableError(error) {
157
+ const message = error.message.toLowerCase();
158
+
159
+ // Retryable errors
160
+ const retryableKeywords = [
161
+ 'network',
162
+ 'timeout',
163
+ 'econnrefused',
164
+ 'enotfound',
165
+ 'etimedout',
166
+ 'socket hang up'
167
+ ];
168
+
169
+ if (retryableKeywords.some(keyword => message.includes(keyword))) {
170
+ return true;
171
+ }
172
+
173
+ // Non-retryable errors
174
+ const nonRetryableKeywords = [
175
+ 'insufficient',
176
+ 'balance',
177
+ 'invalid token',
178
+ 'unauthorized',
179
+ 'forbidden'
180
+ ];
181
+
182
+ if (nonRetryableKeywords.some(keyword => message.includes(keyword))) {
183
+ return false;
184
+ }
185
+
186
+ // Default: retry unknown errors
187
+ return true;
188
+ }
189
+
190
+ /**
191
+ * Enhance payment error with helpful user message
192
+ *
193
+ * @param {Error} error - Original error
194
+ * @returns {Error} Enhanced error with user-friendly message
195
+ */
196
+ function enhancePaymentError(error) {
197
+ const message = error.message.toLowerCase();
198
+
199
+ // Insufficient balance
200
+ if (message.includes('insufficient') || message.includes('balance')) {
201
+ return new Error(
202
+ `Insufficient balance in your HandCash wallet.\n` +
203
+ `Add funds at: ${chalk.cyan('https://handcash.io')}\n` +
204
+ `Original error: ${error.message}`
205
+ );
206
+ }
207
+
208
+ // Invalid auth
209
+ if (message.includes('unauthorized') || message.includes('invalid token')) {
210
+ return new Error(
211
+ `Your authentication has expired.\n` +
212
+ `Please run: ${chalk.cyan('bgit auth login')}\n` +
213
+ `Original error: ${error.message}`
214
+ );
215
+ }
216
+
217
+ // Network errors
218
+ if (message.includes('network') || message.includes('timeout')) {
219
+ return new Error(
220
+ `Network error - please check your internet connection and try again.\n` +
221
+ `Original error: ${error.message}`
222
+ );
223
+ }
224
+
225
+ // API errors
226
+ if (message.includes('api') || message.includes('handcash')) {
227
+ return new Error(
228
+ `HandCash API error - the service may be temporarily unavailable.\n` +
229
+ `Please try again later.\n` +
230
+ `Original error: ${error.message}`
231
+ );
232
+ }
233
+
234
+ // Unknown error
235
+ return new Error(`Payment failed: ${error.message}`);
236
+ }
237
+
238
+ /**
239
+ * Format payment note for commit timestamp
240
+ *
241
+ * @param {string} commitHash - Git commit hash
242
+ * @returns {string} Formatted note
243
+ */
244
+ function formatCommitNote(commitHash) {
245
+ return `bgit commit: ${commitHash}`;
246
+ }
247
+
248
+ /**
249
+ * Format payment note for push
250
+ *
251
+ * @param {string} remote - Remote name (e.g., "origin")
252
+ * @param {string} branch - Branch name (e.g., "main")
253
+ * @returns {string} Formatted note
254
+ */
255
+ function formatPushNote(remote = 'origin', branch = 'main') {
256
+ return `bgit push: ${remote}/${branch}`;
257
+ }
258
+
259
+ /**
260
+ * Soft fail wrapper for payments
261
+ * If payment fails, warn user but don't fail the git operation
262
+ *
263
+ * @param {Function} paymentFn - Payment function to execute
264
+ * @param {string} operation - Operation name (e.g., "commit", "push")
265
+ * @returns {Promise<boolean>} true if payment succeeded, false otherwise
266
+ */
267
+ async function softFailPayment(paymentFn, operation) {
268
+ try {
269
+ await paymentFn();
270
+ return true;
271
+ } catch (error) {
272
+ console.warn(chalk.yellow(`\n⚠️ ${operation} succeeded, but on-chain timestamp failed:`));
273
+ console.warn(chalk.yellow(error.message));
274
+ console.log(chalk.gray(`Your ${operation} was saved locally.`));
275
+ console.log();
276
+ return false;
277
+ }
278
+ }
279
+
280
+ module.exports = {
281
+ executePayment,
282
+ formatCommitNote,
283
+ formatPushNote,
284
+ softFailPayment,
285
+ isRetryableError,
286
+ enhancePaymentError
287
+ };