@fazetitans/fscopy 1.4.0 → 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/README.md +12 -1
- package/package.json +19 -15
- package/src/cli.ts +10 -2
- package/src/config/defaults.ts +1 -0
- package/src/config/parser.ts +90 -53
- package/src/config/validator.ts +51 -0
- package/src/constants.ts +39 -0
- package/src/firebase/index.ts +29 -18
- package/src/interactive.ts +6 -1
- package/src/orchestrator.ts +69 -57
- package/src/state/index.ts +42 -26
- package/src/transfer/clear.ts +104 -68
- package/src/transfer/count.ts +44 -35
- package/src/transfer/helpers.ts +36 -1
- package/src/transfer/index.ts +7 -1
- package/src/transfer/transfer.ts +141 -149
- package/src/transform/loader.ts +13 -1
- package/src/types.ts +3 -1
- package/src/utils/credentials.ts +7 -4
- package/src/utils/errors.ts +7 -2
- package/src/utils/index.ts +6 -1
- package/src/utils/output.ts +6 -0
- package/src/utils/progress.ts +10 -0
- package/src/webhook/index.ts +127 -44
package/src/webhook/index.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
123
|
+
async function attemptWebhookSend(
|
|
109
124
|
webhookUrl: string,
|
|
110
|
-
|
|
125
|
+
bodyJson: string,
|
|
111
126
|
output: Output
|
|
112
|
-
): Promise<
|
|
113
|
-
const
|
|
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:
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
+
|
|
167
|
+
return false;
|
|
166
168
|
}
|
|
167
169
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
}
|