@fazetitans/fscopy 1.3.1 → 1.5.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.
package/src/types.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  export interface WhereFilter {
6
6
  field: string;
7
7
  operator: FirebaseFirestore.WhereFilterOp;
8
- value: string | number | boolean;
8
+ value: string | number | boolean | null;
9
9
  }
10
10
 
11
11
  export interface Config {
@@ -38,6 +38,7 @@ export interface Config {
38
38
  detectConflicts: boolean;
39
39
  maxDepth: number;
40
40
  verifyIntegrity: boolean;
41
+ allowHttpWebhook: boolean;
41
42
  }
42
43
 
43
44
  // Config after validation - required fields are guaranteed non-null
@@ -97,7 +98,7 @@ export interface CliArgs {
97
98
  yes: boolean;
98
99
  log?: string;
99
100
  maxLogSize?: string;
100
- retries: number;
101
+ retries?: number;
101
102
  quiet: boolean;
102
103
  where?: string[];
103
104
  exclude?: string[];
@@ -121,5 +122,6 @@ export interface CliArgs {
121
122
  detectConflicts?: boolean;
122
123
  maxDepth?: number;
123
124
  verifyIntegrity?: boolean;
125
+ allowHttpWebhook?: boolean;
124
126
  validateOnly?: boolean;
125
127
  }
@@ -28,10 +28,13 @@ export function ensureCredentials(): void {
28
28
  const { exists, path: credPath } = checkCredentialsExist();
29
29
 
30
30
  if (!exists) {
31
- console.error('\n❌ Google Cloud credentials not found.');
32
- console.error(` Expected at: ${credPath}\n`);
33
- console.error(' Run this command to authenticate:');
34
- console.error(' gcloud auth application-default login\n');
31
+ const msg = [
32
+ '\n❌ Google Cloud credentials not found.',
33
+ ` Expected at: ${credPath}\n`,
34
+ ' Run this command to authenticate:',
35
+ ' gcloud auth application-default login\n',
36
+ ].join('\n');
37
+ process.stderr.write(msg);
35
38
  process.exit(1);
36
39
  }
37
40
  }
@@ -1,3 +1,8 @@
1
+ /** Error with optional Firebase/gRPC error code */
2
+ export interface FirebaseError extends Error {
3
+ code?: string;
4
+ }
5
+
1
6
  export interface FirebaseErrorInfo {
2
7
  message: string;
3
8
  suggestion?: string;
@@ -95,7 +100,7 @@ const errorMap: Record<string, FirebaseErrorInfo> = {
95
100
  },
96
101
  };
97
102
 
98
- export function formatFirebaseError(error: Error & { code?: string }): FirebaseErrorInfo {
103
+ export function formatFirebaseError(error: FirebaseError): FirebaseErrorInfo {
99
104
  // Check by error code first
100
105
  if (error.code) {
101
106
  const mapped = errorMap[error.code];
@@ -133,7 +138,7 @@ export function formatFirebaseError(error: Error & { code?: string }): FirebaseE
133
138
  }
134
139
 
135
140
  export function logFirebaseError(
136
- error: Error & { code?: string },
141
+ error: FirebaseError,
137
142
  context: string,
138
143
  logger?: { error: (msg: string, data?: Record<string, unknown>) => void }
139
144
  ): void {
@@ -3,6 +3,11 @@ export { Output, type OutputOptions } from './output.js';
3
3
  export { ProgressBarWrapper, type ProgressBarOptions } from './progress.js';
4
4
  export { checkCredentialsExist, ensureCredentials } from './credentials.js';
5
5
  export { matchesExcludePattern } from './patterns.js';
6
- export { formatFirebaseError, logFirebaseError, type FirebaseErrorInfo } from './errors.js';
6
+ export {
7
+ formatFirebaseError,
8
+ logFirebaseError,
9
+ type FirebaseError,
10
+ type FirebaseErrorInfo,
11
+ } from './errors.js';
7
12
  export { RateLimiter } from './rate-limiter.js';
8
13
  export { estimateDocumentSize, formatBytes, FIRESTORE_MAX_DOC_SIZE } from './doc-size.js';
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { SEPARATOR_LENGTH } from '../constants.js';
3
4
  import type { Stats, LogEntry } from '../types.js';
4
5
  import { rotateFileIfNeeded } from './file-rotation.js';
@@ -62,6 +63,11 @@ export class Output {
62
63
 
63
64
  init(): void {
64
65
  if (this.options.logFile) {
66
+ // Ensure parent directory exists
67
+ const dir = path.dirname(this.options.logFile);
68
+ if (!fs.existsSync(dir)) {
69
+ fs.mkdirSync(dir, { recursive: true });
70
+ }
65
71
  this.rotateLogIfNeeded();
66
72
  const header = `# fscopy transfer log\n# Started: ${this.startTime.toISOString()}\n\n`;
67
73
  fs.writeFileSync(this.options.logFile, header);
@@ -135,6 +135,16 @@ export class ProgressBarWrapper {
135
135
  }
136
136
  }
137
137
 
138
+ /**
139
+ * Increase the progress bar total by the given amount.
140
+ * Used for dynamically discovered work (e.g., subcollections).
141
+ */
142
+ addToTotal(count: number): void {
143
+ if (this.bar && count > 0) {
144
+ this.bar.setTotal(this.bar.getTotal() + count);
145
+ }
146
+ }
147
+
138
148
  /**
139
149
  * Check if the progress bar is active.
140
150
  */
@@ -1,5 +1,11 @@
1
1
  import type { Stats } from '../types.js';
2
2
  import type { Output } from '../utils/output.js';
3
+ import {
4
+ WEBHOOK_TIMEOUT_MS,
5
+ WEBHOOK_MAX_PAYLOAD_BYTES,
6
+ WEBHOOK_MAX_RETRIES,
7
+ WEBHOOK_RETRY_DELAY_MS,
8
+ } from '../constants.js';
3
9
 
4
10
  export interface WebhookPayload {
5
11
  source: string;
@@ -22,12 +28,21 @@ export function detectWebhookType(url: string): 'slack' | 'discord' | 'custom' {
22
28
  return 'custom';
23
29
  }
24
30
 
25
- export function validateWebhookUrl(url: string): { valid: boolean; warning?: string } {
31
+ export function validateWebhookUrl(
32
+ url: string,
33
+ allowHttp: boolean = false
34
+ ): { valid: boolean; warning?: string } {
26
35
  try {
27
36
  const parsed = new URL(url);
28
37
  const isLocalhost = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
29
38
 
30
39
  if (parsed.protocol !== 'https:' && !isLocalhost) {
40
+ if (!allowHttp) {
41
+ return {
42
+ valid: false,
43
+ warning: `Webhook URL uses HTTP instead of HTTPS. Use --allow-http-webhook to allow unencrypted webhooks.`,
44
+ };
45
+ }
31
46
  return {
32
47
  valid: true,
33
48
  warning: `Webhook URL uses HTTP instead of HTTPS. Data will be sent unencrypted.`,
@@ -105,33 +120,19 @@ export function formatDiscordPayload(payload: WebhookPayload): Record<string, un
105
120
  };
106
121
  }
107
122
 
108
- export async function sendWebhook(
123
+ async function attemptWebhookSend(
109
124
  webhookUrl: string,
110
- payload: WebhookPayload,
125
+ bodyJson: string,
111
126
  output: Output
112
- ): Promise<void> {
113
- const webhookType = detectWebhookType(webhookUrl);
114
-
115
- let body: Record<string, unknown>;
116
- switch (webhookType) {
117
- case 'slack':
118
- body = formatSlackPayload(payload);
119
- break;
120
- case 'discord':
121
- body = formatDiscordPayload(payload);
122
- break;
123
- default:
124
- body = payload as unknown as Record<string, unknown>;
125
- }
127
+ ): Promise<boolean> {
128
+ const controller = new AbortController();
129
+ const timeout = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS);
126
130
 
127
131
  try {
128
- const controller = new AbortController();
129
- const timeout = setTimeout(() => controller.abort(), 30000);
130
-
131
132
  const response = await fetch(webhookUrl, {
132
133
  method: 'POST',
133
134
  headers: { 'Content-Type': 'application/json' },
134
- body: JSON.stringify(body),
135
+ body: bodyJson,
135
136
  signal: controller.signal,
136
137
  });
137
138
 
@@ -142,7 +143,6 @@ export async function sendWebhook(
142
143
  const statusCode = response.status;
143
144
 
144
145
  if (statusCode >= 400 && statusCode < 500) {
145
- // Client error - likely bad URL or payload format
146
146
  output.logError(`Webhook client error (${statusCode})`, {
147
147
  url: webhookUrl,
148
148
  status: statusCode,
@@ -151,36 +151,119 @@ export async function sendWebhook(
151
151
  output.warn(
152
152
  `⚠️ Webhook failed (HTTP ${statusCode}): Check webhook URL or payload format`
153
153
  );
154
- } else if (statusCode >= 500) {
155
- // Server error - retry might help
154
+ // Client errors are not retryable
155
+ return false;
156
+ }
157
+
158
+ if (statusCode >= 500) {
156
159
  output.logError(`Webhook server error (${statusCode})`, {
157
160
  url: webhookUrl,
158
161
  status: statusCode,
159
162
  error: errorText,
160
163
  });
161
- output.warn(
162
- `⚠️ Webhook server error (HTTP ${statusCode}): The webhook service may be temporarily unavailable`
163
- );
164
+ throw new Error(`Server error (HTTP ${statusCode})`);
164
165
  }
165
- return;
166
+
167
+ return false;
166
168
  }
167
169
 
168
- output.logInfo(`Webhook sent successfully (${webhookType})`, { url: webhookUrl });
169
- output.info(`📤 Webhook notification sent (${webhookType})`);
170
- } catch (error) {
171
- const err = error as Error;
172
-
173
- if (err.name === 'AbortError') {
174
- output.logError('Webhook timeout after 30s', { url: webhookUrl });
175
- output.warn('⚠️ Webhook request timed out after 30 seconds');
176
- } else if (err.message.includes('ECONNREFUSED') || err.message.includes('ENOTFOUND')) {
177
- output.logError(`Webhook connection failed: ${err.message}`, { url: webhookUrl });
178
- output.warn(
179
- `⚠️ Webhook connection failed: Unable to reach ${new URL(webhookUrl).hostname}`
180
- );
181
- } else {
182
- output.logError(`Failed to send webhook: ${err.message}`, { url: webhookUrl });
183
- output.warn(`⚠️ Failed to send webhook: ${err.message}`);
170
+ return true;
171
+ } finally {
172
+ clearTimeout(timeout);
173
+ }
174
+ }
175
+
176
+ export async function sendWebhook(
177
+ webhookUrl: string,
178
+ payload: WebhookPayload,
179
+ output: Output
180
+ ): Promise<boolean> {
181
+ const webhookType = detectWebhookType(webhookUrl);
182
+
183
+ let body: Record<string, unknown>;
184
+ switch (webhookType) {
185
+ case 'slack':
186
+ body = formatSlackPayload(payload);
187
+ break;
188
+ case 'discord':
189
+ body = formatDiscordPayload(payload);
190
+ break;
191
+ default:
192
+ body = { ...payload };
193
+ }
194
+
195
+ const bodyJson = JSON.stringify(body);
196
+
197
+ // Validate payload size
198
+ const payloadSize = new TextEncoder().encode(bodyJson).length;
199
+ if (payloadSize > WEBHOOK_MAX_PAYLOAD_BYTES) {
200
+ output.logError('Webhook payload too large', {
201
+ url: webhookUrl,
202
+ size: payloadSize,
203
+ limit: WEBHOOK_MAX_PAYLOAD_BYTES,
204
+ });
205
+ output.warn(
206
+ `⚠️ Webhook payload too large (${Math.round(payloadSize / 1024)}KB > ${Math.round(WEBHOOK_MAX_PAYLOAD_BYTES / 1024)}KB limit)`
207
+ );
208
+ return false;
209
+ }
210
+
211
+ // Retry loop for server errors and network failures
212
+ for (let attempt = 0; attempt <= WEBHOOK_MAX_RETRIES; attempt++) {
213
+ try {
214
+ const success = await attemptWebhookSend(webhookUrl, bodyJson, output);
215
+ if (success) {
216
+ output.logInfo(`Webhook sent successfully (${webhookType})`, { url: webhookUrl });
217
+ output.info(`📤 Webhook notification sent (${webhookType})`);
218
+ return true;
219
+ }
220
+ // Client error (4xx) - don't retry
221
+ return false;
222
+ } catch (error) {
223
+ const err = error as Error;
224
+ const isLastAttempt = attempt === WEBHOOK_MAX_RETRIES;
225
+
226
+ if (err.name === 'AbortError') {
227
+ output.logError(`Webhook timeout after ${WEBHOOK_TIMEOUT_MS / 1000}s`, {
228
+ url: webhookUrl,
229
+ attempt: attempt + 1,
230
+ });
231
+ if (isLastAttempt) {
232
+ output.warn(
233
+ `⚠️ Webhook request timed out after ${WEBHOOK_TIMEOUT_MS / 1000} seconds`
234
+ );
235
+ return false;
236
+ }
237
+ } else if (err.message.includes('ECONNREFUSED') || err.message.includes('ENOTFOUND')) {
238
+ output.logError(`Webhook connection failed: ${err.message}`, {
239
+ url: webhookUrl,
240
+ attempt: attempt + 1,
241
+ });
242
+ if (isLastAttempt) {
243
+ output.warn(
244
+ `⚠️ Webhook connection failed: Unable to reach ${new URL(webhookUrl).hostname}`
245
+ );
246
+ return false;
247
+ }
248
+ } else {
249
+ output.logError(`Failed to send webhook: ${err.message}`, {
250
+ url: webhookUrl,
251
+ attempt: attempt + 1,
252
+ });
253
+ if (isLastAttempt) {
254
+ output.warn(`⚠️ Failed to send webhook: ${err.message}`);
255
+ return false;
256
+ }
257
+ }
258
+
259
+ // Wait before retrying
260
+ const delay = WEBHOOK_RETRY_DELAY_MS * Math.pow(2, attempt);
261
+ await new Promise((resolve) => setTimeout(resolve, delay));
262
+ output.logInfo(`Retrying webhook (attempt ${attempt + 2}/${WEBHOOK_MAX_RETRIES + 1})`, {
263
+ url: webhookUrl,
264
+ });
184
265
  }
185
266
  }
267
+
268
+ return false;
186
269
  }