@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/README.md +12 -1
- package/package.json +19 -15
- package/src/cli.ts +14 -9
- package/src/config/defaults.ts +1 -0
- package/src/config/parser.ts +120 -49
- package/src/config/validator.ts +51 -0
- package/src/constants.ts +39 -0
- package/src/firebase/index.ts +29 -18
- package/src/interactive.ts +526 -36
- package/src/orchestrator.ts +100 -34
- package/src/state/index.ts +42 -26
- package/src/transfer/clear.ts +104 -68
- package/src/transfer/count.ts +54 -36
- package/src/transfer/helpers.ts +36 -1
- package/src/transfer/index.ts +7 -1
- package/src/transfer/transfer.ts +181 -167
- package/src/transform/loader.ts +13 -1
- package/src/types.ts +4 -2
- 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/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
|
|
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
|
}
|
package/src/utils/credentials.ts
CHANGED
|
@@ -28,10 +28,13 @@ export function ensureCredentials(): void {
|
|
|
28
28
|
const { exists, path: credPath } = checkCredentialsExist();
|
|
29
29
|
|
|
30
30
|
if (!exists) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
}
|
package/src/utils/errors.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
141
|
+
error: FirebaseError,
|
|
137
142
|
context: string,
|
|
138
143
|
logger?: { error: (msg: string, data?: Record<string, unknown>) => void }
|
|
139
144
|
): void {
|
package/src/utils/index.ts
CHANGED
|
@@ -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 {
|
|
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';
|
package/src/utils/output.ts
CHANGED
|
@@ -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);
|
package/src/utils/progress.ts
CHANGED
|
@@ -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
|
*/
|
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
|
}
|