@fazetitans/fscopy 1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +409 -0
  3. package/package.json +74 -0
  4. package/src/cli.ts +1936 -0
package/src/cli.ts ADDED
@@ -0,0 +1,1936 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import admin from 'firebase-admin';
4
+ import type { Firestore, DocumentReference, WriteBatch } from 'firebase-admin/firestore';
5
+ import yargs from 'yargs';
6
+ import { hideBin } from 'yargs/helpers';
7
+ import ini from 'ini';
8
+ import cliProgress from 'cli-progress';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import readline from 'node:readline';
12
+ import { input, confirm, checkbox } from '@inquirer/prompts';
13
+
14
+ // =============================================================================
15
+ // Types
16
+ // =============================================================================
17
+
18
+ interface WhereFilter {
19
+ field: string;
20
+ operator: FirebaseFirestore.WhereFilterOp;
21
+ value: string | number | boolean;
22
+ }
23
+
24
+ interface Config {
25
+ collections: string[];
26
+ includeSubcollections: boolean;
27
+ dryRun: boolean;
28
+ batchSize: number;
29
+ limit: number;
30
+ sourceProject: string | null;
31
+ destProject: string | null;
32
+ retries: number;
33
+ where: WhereFilter[];
34
+ exclude: string[];
35
+ merge: boolean;
36
+ parallel: number;
37
+ clear: boolean;
38
+ deleteMissing: boolean;
39
+ transform: string | null;
40
+ renameCollection: Record<string, string>;
41
+ idPrefix: string | null;
42
+ idSuffix: string | null;
43
+ webhook: string | null;
44
+ resume: boolean;
45
+ stateFile: string;
46
+ }
47
+
48
+ type TransformFunction = (
49
+ doc: Record<string, unknown>,
50
+ meta: { id: string; path: string }
51
+ ) => Record<string, unknown> | null;
52
+
53
+ interface Stats {
54
+ collectionsProcessed: number;
55
+ documentsTransferred: number;
56
+ documentsDeleted: number;
57
+ errors: number;
58
+ }
59
+
60
+ interface TransferState {
61
+ version: number;
62
+ sourceProject: string;
63
+ destProject: string;
64
+ collections: string[];
65
+ startedAt: string;
66
+ updatedAt: string;
67
+ completedDocs: Record<string, string[]>; // collectionPath -> array of doc IDs
68
+ stats: Stats;
69
+ }
70
+
71
+ interface LogEntry {
72
+ timestamp: string;
73
+ level: string;
74
+ message: string;
75
+ [key: string]: unknown;
76
+ }
77
+
78
+ interface RetryOptions {
79
+ retries?: number;
80
+ baseDelay?: number;
81
+ maxDelay?: number;
82
+ onRetry?: (attempt: number, max: number, error: Error, delay: number) => void;
83
+ }
84
+
85
+ interface CliArgs {
86
+ init?: string;
87
+ config?: string;
88
+ collections?: string[];
89
+ includeSubcollections?: boolean;
90
+ dryRun?: boolean;
91
+ batchSize?: number;
92
+ limit?: number;
93
+ sourceProject?: string;
94
+ destProject?: string;
95
+ yes: boolean;
96
+ log?: string;
97
+ retries: number;
98
+ quiet: boolean;
99
+ where?: string[];
100
+ exclude?: string[];
101
+ merge?: boolean;
102
+ parallel?: number;
103
+ clear?: boolean;
104
+ deleteMissing?: boolean;
105
+ interactive?: boolean;
106
+ transform?: string;
107
+ renameCollection?: string[];
108
+ idPrefix?: string;
109
+ idSuffix?: string;
110
+ webhook?: string;
111
+ resume?: boolean;
112
+ stateFile?: string;
113
+ }
114
+
115
+ // =============================================================================
116
+ // CLI Arguments
117
+ // =============================================================================
118
+
119
+ const argv = yargs(hideBin(process.argv))
120
+ .scriptName('fscopy')
121
+ .usage('$0 [options]')
122
+ .option('init', {
123
+ type: 'string',
124
+ description: 'Generate a config template file (.ini by default, .json if specified)',
125
+ nargs: 1,
126
+ default: undefined,
127
+ })
128
+ .option('config', {
129
+ alias: 'f',
130
+ type: 'string',
131
+ description: 'Path to config file (.ini or .json)',
132
+ })
133
+ .option('collections', {
134
+ alias: 'c',
135
+ type: 'array',
136
+ description: 'Collections to transfer (e.g., -c users orders)',
137
+ })
138
+ .option('include-subcollections', {
139
+ alias: 's',
140
+ type: 'boolean',
141
+ description: 'Include subcollections in transfer',
142
+ })
143
+ .option('dry-run', {
144
+ alias: 'd',
145
+ type: 'boolean',
146
+ description: 'Preview transfer without writing',
147
+ })
148
+ .option('batch-size', {
149
+ alias: 'b',
150
+ type: 'number',
151
+ description: 'Number of documents per batch write',
152
+ })
153
+ .option('limit', {
154
+ alias: 'l',
155
+ type: 'number',
156
+ description: 'Limit number of documents per collection (0 = no limit)',
157
+ })
158
+ .option('source-project', {
159
+ type: 'string',
160
+ description: 'Source Firebase project ID',
161
+ })
162
+ .option('dest-project', {
163
+ type: 'string',
164
+ description: 'Destination Firebase project ID',
165
+ })
166
+ .option('yes', {
167
+ alias: 'y',
168
+ type: 'boolean',
169
+ description: 'Skip confirmation prompt',
170
+ default: false,
171
+ })
172
+ .option('log', {
173
+ type: 'string',
174
+ description: 'Path to log file for transfer details',
175
+ })
176
+ .option('retries', {
177
+ type: 'number',
178
+ description: 'Number of retries on error (default: 3)',
179
+ default: 3,
180
+ })
181
+ .option('quiet', {
182
+ alias: 'q',
183
+ type: 'boolean',
184
+ description: 'Minimal output (no progress bar)',
185
+ default: false,
186
+ })
187
+ .option('where', {
188
+ alias: 'w',
189
+ type: 'array',
190
+ description: 'Filter documents (e.g., --where "status == active")',
191
+ })
192
+ .option('exclude', {
193
+ alias: 'x',
194
+ type: 'array',
195
+ description: 'Exclude subcollections by pattern (e.g., --exclude "logs" "temp/*")',
196
+ })
197
+ .option('merge', {
198
+ alias: 'm',
199
+ type: 'boolean',
200
+ description: 'Merge documents instead of overwriting',
201
+ default: false,
202
+ })
203
+ .option('parallel', {
204
+ alias: 'p',
205
+ type: 'number',
206
+ description: 'Number of parallel collection transfers (default: 1)',
207
+ default: 1,
208
+ })
209
+ .option('clear', {
210
+ type: 'boolean',
211
+ description: 'Clear destination collections before transfer (DESTRUCTIVE)',
212
+ default: false,
213
+ })
214
+ .option('delete-missing', {
215
+ type: 'boolean',
216
+ description: 'Delete destination docs not present in source (sync mode)',
217
+ default: false,
218
+ })
219
+ .option('interactive', {
220
+ alias: 'i',
221
+ type: 'boolean',
222
+ description: 'Interactive mode with prompts for project and collection selection',
223
+ default: false,
224
+ })
225
+ .option('transform', {
226
+ alias: 't',
227
+ type: 'string',
228
+ description: 'Path to JS/TS file exporting a transform(doc, meta) function',
229
+ })
230
+ .option('rename-collection', {
231
+ alias: 'r',
232
+ type: 'array',
233
+ description: 'Rename collection in destination (format: source:dest)',
234
+ })
235
+ .option('id-prefix', {
236
+ type: 'string',
237
+ description: 'Add prefix to document IDs in destination',
238
+ })
239
+ .option('id-suffix', {
240
+ type: 'string',
241
+ description: 'Add suffix to document IDs in destination',
242
+ })
243
+ .option('webhook', {
244
+ type: 'string',
245
+ description: 'Webhook URL for transfer notifications (Slack, Discord, or custom)',
246
+ })
247
+ .option('resume', {
248
+ type: 'boolean',
249
+ description: 'Resume an interrupted transfer from saved state',
250
+ default: false,
251
+ })
252
+ .option('state-file', {
253
+ type: 'string',
254
+ description: 'Path to state file for resume (default: .fscopy-state.json)',
255
+ default: '.fscopy-state.json',
256
+ })
257
+ .example('$0 --init config.ini', 'Generate INI config template (default)')
258
+ .example('$0 --init config.json', 'Generate JSON config template')
259
+ .example('$0 -f config.ini', 'Run transfer with config file')
260
+ .example('$0 -f config.ini -d false -y', 'Live transfer, skip confirmation')
261
+ .example('$0 -f config.ini --log transfer.log', 'Transfer with logging')
262
+ .example('$0 -f config.ini --where "active == true"', 'Filter documents')
263
+ .example('$0 -f config.ini --exclude "logs" --exclude "cache"', 'Exclude subcollections')
264
+ .example('$0 -f config.ini --merge', 'Merge instead of overwrite')
265
+ .example('$0 -f config.ini --parallel 3', 'Transfer 3 collections in parallel')
266
+ .example('$0 -f config.ini --clear', 'Clear destination before transfer')
267
+ .example('$0 -f config.ini --delete-missing', 'Sync mode: delete orphan docs in dest')
268
+ .example('$0 -i', 'Interactive mode with prompts')
269
+ .example('$0 -f config.ini -t ./transform.ts', 'Transform documents during transfer')
270
+ .example('$0 -f config.ini -r users:users_backup', 'Rename collection in destination')
271
+ .example('$0 -f config.ini --id-prefix backup_', 'Add prefix to document IDs')
272
+ .example('$0 -f config.ini --webhook https://hooks.slack.com/...', 'Send notification to Slack')
273
+ .example('$0 -f config.ini --resume', 'Resume an interrupted transfer')
274
+ .help()
275
+ .parseSync() as CliArgs;
276
+
277
+ // =============================================================================
278
+ // Constants & Templates
279
+ // =============================================================================
280
+
281
+ const defaults: Config = {
282
+ collections: [],
283
+ includeSubcollections: false,
284
+ dryRun: true,
285
+ batchSize: 500,
286
+ limit: 0,
287
+ sourceProject: null,
288
+ destProject: null,
289
+ retries: 3,
290
+ where: [],
291
+ exclude: [],
292
+ merge: false,
293
+ parallel: 1,
294
+ clear: false,
295
+ deleteMissing: false,
296
+ transform: null,
297
+ renameCollection: {},
298
+ idPrefix: null,
299
+ idSuffix: null,
300
+ webhook: null,
301
+ resume: false,
302
+ stateFile: '.fscopy-state.json',
303
+ };
304
+
305
+ const iniTemplate = `; fscopy configuration file
306
+
307
+ [projects]
308
+ source = my-source-project
309
+ dest = my-dest-project
310
+
311
+ [transfer]
312
+ ; Comma-separated list of collections
313
+ collections = collection1, collection2
314
+ includeSubcollections = false
315
+ dryRun = true
316
+ batchSize = 500
317
+ limit = 0
318
+
319
+ [options]
320
+ ; Filter documents: "field operator value" (operators: ==, !=, <, >, <=, >=)
321
+ ; where = status == active
322
+ ; Exclude subcollections by pattern (comma-separated, supports glob)
323
+ ; exclude = logs, temp/*, cache
324
+ ; Merge documents instead of overwriting
325
+ merge = false
326
+ ; Number of parallel collection transfers
327
+ parallel = 1
328
+ ; Clear destination collections before transfer (DESTRUCTIVE)
329
+ clear = false
330
+ ; Delete destination docs not present in source (sync mode)
331
+ deleteMissing = false
332
+ ; Transform documents during transfer (path to JS/TS file)
333
+ ; transform = ./transforms/anonymize.ts
334
+ ; Rename collections in destination (format: source:dest, comma-separated)
335
+ ; renameCollection = users:users_backup, orders:orders_2024
336
+ ; Add prefix or suffix to document IDs
337
+ ; idPrefix = backup_
338
+ ; idSuffix = _v2
339
+ ; Webhook URL for transfer notifications (Slack, Discord, or custom)
340
+ ; webhook = https://hooks.slack.com/services/...
341
+ `;
342
+
343
+ const jsonTemplate = {
344
+ sourceProject: 'my-source-project',
345
+ destProject: 'my-dest-project',
346
+ collections: ['collection1', 'collection2'],
347
+ includeSubcollections: false,
348
+ dryRun: true,
349
+ batchSize: 500,
350
+ limit: 0,
351
+ where: [],
352
+ exclude: [],
353
+ merge: false,
354
+ parallel: 1,
355
+ clear: false,
356
+ deleteMissing: false,
357
+ transform: null,
358
+ renameCollection: {},
359
+ idPrefix: null,
360
+ idSuffix: null,
361
+ webhook: null,
362
+ };
363
+
364
+ // =============================================================================
365
+ // Logger
366
+ // =============================================================================
367
+
368
+ class Logger {
369
+ private readonly logPath: string | undefined;
370
+ private readonly entries: LogEntry[] = [];
371
+ private readonly startTime: Date;
372
+
373
+ constructor(logPath?: string) {
374
+ this.logPath = logPath;
375
+ this.startTime = new Date();
376
+ }
377
+
378
+ log(level: string, message: string, data: Record<string, unknown> = {}): void {
379
+ const entry: LogEntry = {
380
+ timestamp: new Date().toISOString(),
381
+ level,
382
+ message,
383
+ ...data,
384
+ };
385
+ this.entries.push(entry);
386
+
387
+ if (this.logPath) {
388
+ const line =
389
+ `[${entry.timestamp}] [${level}] ${message}` +
390
+ (Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : '') +
391
+ '\n';
392
+ fs.appendFileSync(this.logPath, line);
393
+ }
394
+ }
395
+
396
+ info(message: string, data?: Record<string, unknown>): void {
397
+ this.log('INFO', message, data);
398
+ }
399
+
400
+ error(message: string, data?: Record<string, unknown>): void {
401
+ this.log('ERROR', message, data);
402
+ }
403
+
404
+ success(message: string, data?: Record<string, unknown>): void {
405
+ this.log('SUCCESS', message, data);
406
+ }
407
+
408
+ init(): void {
409
+ if (this.logPath) {
410
+ const header = `# fscopy transfer log\n# Started: ${this.startTime.toISOString()}\n\n`;
411
+ fs.writeFileSync(this.logPath, header);
412
+ }
413
+ }
414
+
415
+ summary(stats: Stats, duration: string): void {
416
+ if (this.logPath) {
417
+ let summary = `\n# Summary\n# Collections: ${stats.collectionsProcessed}\n`;
418
+ if (stats.documentsDeleted > 0) {
419
+ summary += `# Deleted: ${stats.documentsDeleted}\n`;
420
+ }
421
+ summary += `# Transferred: ${stats.documentsTransferred}\n# Errors: ${stats.errors}\n# Duration: ${duration}s\n`;
422
+ fs.appendFileSync(this.logPath, summary);
423
+ }
424
+ }
425
+ }
426
+
427
+ // =============================================================================
428
+ // Retry Logic
429
+ // =============================================================================
430
+
431
+ async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
432
+ const { retries = 3, baseDelay = 1000, maxDelay = 30000, onRetry } = options;
433
+
434
+ let lastError: Error | undefined;
435
+ for (let attempt = 0; attempt <= retries; attempt++) {
436
+ try {
437
+ return await fn();
438
+ } catch (error) {
439
+ lastError = error as Error;
440
+
441
+ if (attempt < retries) {
442
+ const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
443
+ if (onRetry) {
444
+ onRetry(attempt + 1, retries, lastError, delay);
445
+ }
446
+ await new Promise((resolve) => setTimeout(resolve, delay));
447
+ }
448
+ }
449
+ }
450
+
451
+ throw lastError;
452
+ }
453
+
454
+ // =============================================================================
455
+ // Config Parsing
456
+ // =============================================================================
457
+
458
+ function getFileFormat(filePath: string): 'json' | 'ini' {
459
+ const ext = path.extname(filePath).toLowerCase();
460
+ if (ext === '.json') return 'json';
461
+ return 'ini';
462
+ }
463
+
464
+ function parseBoolean(val: unknown): boolean {
465
+ if (typeof val === 'boolean') return val;
466
+ if (typeof val === 'string') {
467
+ return val.toLowerCase() === 'true';
468
+ }
469
+ return false;
470
+ }
471
+
472
+ function parseWhereFilter(filterStr: string): WhereFilter | null {
473
+ // Parse "field operator value" format
474
+ const operatorRegex = /(==|!=|<=|>=|<|>)/;
475
+ const match = new RegExp(operatorRegex).exec(filterStr);
476
+
477
+ if (!match) {
478
+ console.warn(`āš ļø Invalid where filter: "${filterStr}" (missing operator)`);
479
+ return null;
480
+ }
481
+
482
+ const operator = match[0] as FirebaseFirestore.WhereFilterOp;
483
+ const [fieldPart, valuePart] = filterStr.split(operatorRegex).filter((_, i) => i !== 1);
484
+
485
+ if (!fieldPart || !valuePart) {
486
+ console.warn(`āš ļø Invalid where filter: "${filterStr}" (missing field or value)`);
487
+ return null;
488
+ }
489
+
490
+ const field = fieldPart.trim();
491
+ const rawValue = valuePart.trim();
492
+
493
+ // Parse value type
494
+ let value: string | number | boolean;
495
+ if (rawValue === 'true') {
496
+ value = true;
497
+ } else if (rawValue === 'false') {
498
+ value = false;
499
+ } else if (rawValue === 'null') {
500
+ value = null as unknown as string; // Firestore supports null
501
+ } else if (!Number.isNaN(Number(rawValue)) && rawValue !== '') {
502
+ value = Number(rawValue);
503
+ } else {
504
+ // Remove quotes if present
505
+ value = rawValue.replaceAll(/(?:^["'])|(?:["']$)/g, '');
506
+ }
507
+
508
+ return { field, operator, value };
509
+ }
510
+
511
+ function parseWhereFilters(filters: string[] | undefined): WhereFilter[] {
512
+ if (!filters || filters.length === 0) return [];
513
+ return filters.map(parseWhereFilter).filter((f): f is WhereFilter => f !== null);
514
+ }
515
+
516
+ function parseStringList(value: string | undefined): string[] {
517
+ if (!value) return [];
518
+ return value
519
+ .split(',')
520
+ .map((s) => s.trim())
521
+ .filter((s) => s.length > 0);
522
+ }
523
+
524
+ function parseRenameMapping(mappings: string[] | string | undefined): Record<string, string> {
525
+ if (!mappings) return {};
526
+
527
+ const result: Record<string, string> = {};
528
+ const items = Array.isArray(mappings) ? mappings : parseStringList(mappings);
529
+
530
+ for (const item of items) {
531
+ const mapping = String(item).trim();
532
+ const colonIndex = mapping.indexOf(':');
533
+ if (colonIndex === -1) {
534
+ console.warn(`āš ļø Invalid rename mapping: "${mapping}" (missing ':')`);
535
+ continue;
536
+ }
537
+ const source = mapping.slice(0, colonIndex).trim();
538
+ const dest = mapping.slice(colonIndex + 1).trim();
539
+ if (!source || !dest) {
540
+ console.warn(`āš ļø Invalid rename mapping: "${mapping}" (empty source or dest)`);
541
+ continue;
542
+ }
543
+ result[source] = dest;
544
+ }
545
+
546
+ return result;
547
+ }
548
+
549
+ function matchesExcludePattern(path: string, patterns: string[]): boolean {
550
+ for (const pattern of patterns) {
551
+ if (pattern.includes('*')) {
552
+ // Convert glob pattern to regex
553
+ const regex = new RegExp('^' + pattern.replaceAll('*', '.*') + '$');
554
+ if (regex.test(path)) return true;
555
+ } else if (path === pattern || path.endsWith('/' + pattern)) {
556
+ // Exact match or ends with pattern
557
+ return true;
558
+ }
559
+ }
560
+ return false;
561
+ }
562
+
563
+ function parseIniConfig(content: string): Partial<Config> {
564
+ const parsed = ini.parse(content) as {
565
+ projects?: { source?: string; dest?: string };
566
+ transfer?: {
567
+ collections?: string;
568
+ includeSubcollections?: string | boolean;
569
+ dryRun?: string | boolean;
570
+ batchSize?: string;
571
+ limit?: string;
572
+ };
573
+ options?: {
574
+ where?: string;
575
+ exclude?: string;
576
+ merge?: string | boolean;
577
+ parallel?: string;
578
+ clear?: string | boolean;
579
+ deleteMissing?: string | boolean;
580
+ transform?: string;
581
+ renameCollection?: string;
582
+ idPrefix?: string;
583
+ idSuffix?: string;
584
+ webhook?: string;
585
+ };
586
+ };
587
+
588
+ let collections: string[] = [];
589
+ if (parsed.transfer?.collections) {
590
+ collections = parsed.transfer.collections
591
+ .split(',')
592
+ .map((c) => c.trim())
593
+ .filter((c) => c.length > 0);
594
+ }
595
+
596
+ // Parse where filters from INI (single filter per line or comma-separated)
597
+ const whereFilters = parsed.options?.where
598
+ ? parseWhereFilters(parseStringList(parsed.options.where))
599
+ : [];
600
+
601
+ return {
602
+ sourceProject: parsed.projects?.source ?? null,
603
+ destProject: parsed.projects?.dest ?? null,
604
+ collections,
605
+ includeSubcollections: parseBoolean(parsed.transfer?.includeSubcollections),
606
+ dryRun: parseBoolean(parsed.transfer?.dryRun ?? 'true'),
607
+ batchSize: Number.parseInt(parsed.transfer?.batchSize ?? '', 10) || 500,
608
+ limit: Number.parseInt(parsed.transfer?.limit ?? '', 10) || 0,
609
+ where: whereFilters,
610
+ exclude: parseStringList(parsed.options?.exclude),
611
+ merge: parseBoolean(parsed.options?.merge),
612
+ parallel: Number.parseInt(parsed.options?.parallel ?? '', 10) || 1,
613
+ clear: parseBoolean(parsed.options?.clear),
614
+ deleteMissing: parseBoolean(parsed.options?.deleteMissing),
615
+ transform: parsed.options?.transform ?? null,
616
+ renameCollection: parseRenameMapping(parsed.options?.renameCollection),
617
+ idPrefix: parsed.options?.idPrefix ?? null,
618
+ idSuffix: parsed.options?.idSuffix ?? null,
619
+ webhook: parsed.options?.webhook ?? null,
620
+ };
621
+ }
622
+
623
+ function parseJsonConfig(content: string): Partial<Config> {
624
+ const config = JSON.parse(content) as {
625
+ sourceProject?: string;
626
+ destProject?: string;
627
+ collections?: string[];
628
+ includeSubcollections?: boolean;
629
+ dryRun?: boolean;
630
+ batchSize?: number;
631
+ limit?: number;
632
+ where?: string[];
633
+ exclude?: string[];
634
+ merge?: boolean;
635
+ parallel?: number;
636
+ clear?: boolean;
637
+ deleteMissing?: boolean;
638
+ transform?: string;
639
+ renameCollection?: Record<string, string>;
640
+ idPrefix?: string;
641
+ idSuffix?: string;
642
+ webhook?: string;
643
+ };
644
+
645
+ return {
646
+ sourceProject: config.sourceProject ?? null,
647
+ destProject: config.destProject ?? null,
648
+ collections: config.collections,
649
+ includeSubcollections: config.includeSubcollections,
650
+ dryRun: config.dryRun,
651
+ batchSize: config.batchSize,
652
+ limit: config.limit,
653
+ where: parseWhereFilters(config.where),
654
+ exclude: config.exclude,
655
+ merge: config.merge,
656
+ parallel: config.parallel,
657
+ clear: config.clear,
658
+ deleteMissing: config.deleteMissing,
659
+ transform: config.transform ?? null,
660
+ renameCollection: config.renameCollection ?? {},
661
+ idPrefix: config.idPrefix ?? null,
662
+ idSuffix: config.idSuffix ?? null,
663
+ webhook: config.webhook ?? null,
664
+ };
665
+ }
666
+
667
+ function loadConfigFile(configPath?: string): Partial<Config> {
668
+ if (!configPath) return {};
669
+
670
+ const absolutePath = path.resolve(configPath);
671
+ if (!fs.existsSync(absolutePath)) {
672
+ throw new Error(`Config file not found: ${absolutePath}`);
673
+ }
674
+
675
+ const content = fs.readFileSync(absolutePath, 'utf-8');
676
+ const format = getFileFormat(absolutePath);
677
+
678
+ console.log(`šŸ“„ Loaded config from: ${absolutePath} (${format.toUpperCase()})\n`);
679
+
680
+ return format === 'json' ? parseJsonConfig(content) : parseIniConfig(content);
681
+ }
682
+
683
+ function mergeConfig(defaultConfig: Config, fileConfig: Partial<Config>, cliArgs: CliArgs): Config {
684
+ // Parse CLI where filters
685
+ const cliWhereFilters = parseWhereFilters(cliArgs.where);
686
+
687
+ // Parse CLI rename collection mappings
688
+ const cliRenameCollection = parseRenameMapping(cliArgs.renameCollection);
689
+
690
+ return {
691
+ collections: cliArgs.collections ?? fileConfig.collections ?? defaultConfig.collections,
692
+ includeSubcollections:
693
+ cliArgs.includeSubcollections ??
694
+ fileConfig.includeSubcollections ??
695
+ defaultConfig.includeSubcollections,
696
+ dryRun: cliArgs.dryRun ?? fileConfig.dryRun ?? defaultConfig.dryRun,
697
+ batchSize: cliArgs.batchSize ?? fileConfig.batchSize ?? defaultConfig.batchSize,
698
+ limit: cliArgs.limit ?? fileConfig.limit ?? defaultConfig.limit,
699
+ sourceProject:
700
+ cliArgs.sourceProject ?? fileConfig.sourceProject ?? defaultConfig.sourceProject,
701
+ destProject: cliArgs.destProject ?? fileConfig.destProject ?? defaultConfig.destProject,
702
+ retries: cliArgs.retries ?? defaultConfig.retries,
703
+ where:
704
+ cliWhereFilters.length > 0
705
+ ? cliWhereFilters
706
+ : (fileConfig.where ?? defaultConfig.where),
707
+ exclude: cliArgs.exclude ?? fileConfig.exclude ?? defaultConfig.exclude,
708
+ merge: cliArgs.merge ?? fileConfig.merge ?? defaultConfig.merge,
709
+ parallel: cliArgs.parallel ?? fileConfig.parallel ?? defaultConfig.parallel,
710
+ clear: cliArgs.clear ?? fileConfig.clear ?? defaultConfig.clear,
711
+ deleteMissing: cliArgs.deleteMissing ?? fileConfig.deleteMissing ?? defaultConfig.deleteMissing,
712
+ transform: cliArgs.transform ?? fileConfig.transform ?? defaultConfig.transform,
713
+ renameCollection:
714
+ Object.keys(cliRenameCollection).length > 0
715
+ ? cliRenameCollection
716
+ : (fileConfig.renameCollection ?? defaultConfig.renameCollection),
717
+ idPrefix: cliArgs.idPrefix ?? fileConfig.idPrefix ?? defaultConfig.idPrefix,
718
+ idSuffix: cliArgs.idSuffix ?? fileConfig.idSuffix ?? defaultConfig.idSuffix,
719
+ webhook: cliArgs.webhook ?? fileConfig.webhook ?? defaultConfig.webhook,
720
+ resume: cliArgs.resume ?? defaultConfig.resume,
721
+ stateFile: cliArgs.stateFile ?? defaultConfig.stateFile,
722
+ };
723
+ }
724
+
725
+ function validateConfig(config: Config): string[] {
726
+ const errors: string[] = [];
727
+
728
+ if (!config.sourceProject) {
729
+ errors.push('Source project is required (--source-project or in config file)');
730
+ }
731
+ if (!config.destProject) {
732
+ errors.push('Destination project is required (--dest-project or in config file)');
733
+ }
734
+ if (config.sourceProject && config.destProject && config.sourceProject === config.destProject) {
735
+ // Same project is allowed only if we're renaming collections or modifying IDs
736
+ const hasRenamedCollections = Object.keys(config.renameCollection).length > 0;
737
+ const hasIdModification = config.idPrefix !== null || config.idSuffix !== null;
738
+
739
+ if (!hasRenamedCollections && !hasIdModification) {
740
+ errors.push(
741
+ 'Source and destination projects are the same. ' +
742
+ 'Use --rename-collection or --id-prefix/--id-suffix to avoid overwriting data.'
743
+ );
744
+ }
745
+ }
746
+ if (!config.collections || config.collections.length === 0) {
747
+ errors.push('At least one collection is required (-c or --collections)');
748
+ }
749
+
750
+ return errors;
751
+ }
752
+
753
+ // =============================================================================
754
+ // Config File Generation
755
+ // =============================================================================
756
+
757
+ function generateConfigFile(outputPath: string): boolean {
758
+ const filePath = path.resolve(outputPath);
759
+ const format = getFileFormat(filePath);
760
+
761
+ if (fs.existsSync(filePath)) {
762
+ console.error(`āŒ File already exists: ${filePath}`);
763
+ console.error(' Use a different filename or delete the existing file.');
764
+ process.exitCode = 1;
765
+ return false;
766
+ }
767
+
768
+ const content = format === 'json' ? JSON.stringify(jsonTemplate, null, 4) : iniTemplate;
769
+
770
+ fs.writeFileSync(filePath, content, 'utf-8');
771
+
772
+ console.log(`āœ“ Config template created: ${filePath}`);
773
+ console.log('');
774
+ console.log('Edit the file to configure your transfer, then run:');
775
+ console.log(` fscopy -f ${outputPath}`);
776
+
777
+ return true;
778
+ }
779
+
780
+ // =============================================================================
781
+ // Transform Loading
782
+ // =============================================================================
783
+
784
+ async function loadTransformFunction(transformPath: string): Promise<TransformFunction> {
785
+ const absolutePath = path.resolve(transformPath);
786
+
787
+ if (!fs.existsSync(absolutePath)) {
788
+ throw new Error(`Transform file not found: ${absolutePath}`);
789
+ }
790
+
791
+ try {
792
+ const module = await import(absolutePath);
793
+
794
+ // Look for 'transform' export (default or named)
795
+ const transformFn = module.default?.transform ?? module.transform ?? module.default;
796
+
797
+ if (typeof transformFn !== 'function') {
798
+ throw new Error(
799
+ `Transform file must export a 'transform' function. Got: ${typeof transformFn}`
800
+ );
801
+ }
802
+
803
+ return transformFn as TransformFunction;
804
+ } catch (error) {
805
+ if ((error as Error).message.includes('Transform file')) {
806
+ throw error;
807
+ }
808
+ throw new Error(`Failed to load transform file: ${(error as Error).message}`);
809
+ }
810
+ }
811
+
812
+ // =============================================================================
813
+ // Display & Confirmation
814
+ // =============================================================================
815
+
816
+ function displayConfig(config: Config): void {
817
+ console.log('='.repeat(60));
818
+ console.log('šŸ”„ FSCOPY - CONFIGURATION');
819
+ console.log('='.repeat(60));
820
+ console.log('');
821
+ console.log(` šŸ“¤ Source project: ${config.sourceProject || '(not set)'}`);
822
+ console.log(` šŸ“„ Destination project: ${config.destProject || '(not set)'}`);
823
+ console.log('');
824
+ console.log(
825
+ ` šŸ“‹ Collections: ${config.collections.length > 0 ? config.collections.join(', ') : '(none)'}`
826
+ );
827
+ console.log(` šŸ“‚ Include subcollections: ${config.includeSubcollections}`);
828
+ console.log(` šŸ”¢ Document limit: ${config.limit === 0 ? 'No limit' : config.limit}`);
829
+ console.log(` šŸ“¦ Batch size: ${config.batchSize}`);
830
+ console.log(` šŸ”„ Retries on error: ${config.retries}`);
831
+
832
+ // New options
833
+ if (config.where.length > 0) {
834
+ const whereStr = config.where.map((w) => `${w.field} ${w.operator} ${w.value}`).join(', ');
835
+ console.log(` šŸ” Where filters: ${whereStr}`);
836
+ }
837
+ if (config.exclude.length > 0) {
838
+ console.log(` 🚫 Exclude patterns: ${config.exclude.join(', ')}`);
839
+ }
840
+ if (config.merge) {
841
+ console.log(` šŸ”€ Merge mode: enabled (merge instead of overwrite)`);
842
+ }
843
+ if (config.parallel > 1) {
844
+ console.log(` ⚔ Parallel transfers: ${config.parallel} collections`);
845
+ }
846
+ if (config.clear) {
847
+ console.log(` šŸ—‘ļø Clear destination: enabled (DESTRUCTIVE)`);
848
+ }
849
+ if (config.deleteMissing) {
850
+ console.log(` šŸ”„ Delete missing: enabled (sync mode)`);
851
+ }
852
+ if (config.transform) {
853
+ console.log(` šŸ”§ Transform: ${config.transform}`);
854
+ }
855
+ if (Object.keys(config.renameCollection).length > 0) {
856
+ const renameStr = Object.entries(config.renameCollection)
857
+ .map(([src, dest]) => `${src}→${dest}`)
858
+ .join(', ');
859
+ console.log(` šŸ“ Rename collections: ${renameStr}`);
860
+ }
861
+ if (config.idPrefix || config.idSuffix) {
862
+ const idMod = [
863
+ config.idPrefix ? `prefix: "${config.idPrefix}"` : null,
864
+ config.idSuffix ? `suffix: "${config.idSuffix}"` : null,
865
+ ]
866
+ .filter(Boolean)
867
+ .join(', ');
868
+ console.log(` šŸ·ļø ID modification: ${idMod}`);
869
+ }
870
+
871
+ console.log('');
872
+
873
+ if (config.dryRun) {
874
+ console.log(' šŸ” Mode: DRY RUN (no data will be written)');
875
+ } else {
876
+ console.log(' ⚔ Mode: LIVE (data WILL be transferred)');
877
+ }
878
+
879
+ console.log('');
880
+ console.log('='.repeat(60));
881
+ }
882
+
883
+ async function askConfirmation(config: Config): Promise<boolean> {
884
+ const rl = readline.createInterface({
885
+ input: process.stdin,
886
+ output: process.stdout,
887
+ });
888
+
889
+ return new Promise((resolve) => {
890
+ const modeText = config.dryRun ? 'DRY RUN' : 'āš ļø LIVE TRANSFER';
891
+ rl.question(`\nProceed with ${modeText}? (y/N): `, (answer) => {
892
+ rl.close();
893
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
894
+ });
895
+ });
896
+ }
897
+
898
+ // =============================================================================
899
+ // Interactive Mode
900
+ // =============================================================================
901
+
902
+ async function runInteractiveMode(config: Config): Promise<Config> {
903
+ console.log('\n' + '='.repeat(60));
904
+ console.log('šŸ”„ FSCOPY - INTERACTIVE MODE');
905
+ console.log('='.repeat(60) + '\n');
906
+
907
+ // Prompt for source project if not set
908
+ let sourceProject = config.sourceProject;
909
+ if (!sourceProject) {
910
+ sourceProject = await input({
911
+ message: 'Source Firebase project ID:',
912
+ validate: (value) => value.length > 0 || 'Project ID is required',
913
+ });
914
+ } else {
915
+ console.log(`šŸ“¤ Source project: ${sourceProject}`);
916
+ }
917
+
918
+ // Prompt for destination project if not set
919
+ let destProject = config.destProject;
920
+ if (!destProject) {
921
+ destProject = await input({
922
+ message: 'Destination Firebase project ID:',
923
+ validate: (value) => {
924
+ if (value.length === 0) return 'Project ID is required';
925
+ if (value === sourceProject) return 'Must be different from source';
926
+ return true;
927
+ },
928
+ });
929
+ } else {
930
+ console.log(`šŸ“„ Destination project: ${destProject}`);
931
+ }
932
+
933
+ // Initialize source Firebase to list collections
934
+ console.log('\nšŸ“Š Connecting to source project...');
935
+ const tempSourceApp = admin.initializeApp(
936
+ {
937
+ credential: admin.credential.applicationDefault(),
938
+ projectId: sourceProject,
939
+ },
940
+ 'interactive-source'
941
+ );
942
+ const sourceDb = tempSourceApp.firestore();
943
+
944
+ // List all root collections
945
+ const rootCollections = await sourceDb.listCollections();
946
+ const collectionIds = rootCollections.map((col) => col.id);
947
+
948
+ if (collectionIds.length === 0) {
949
+ console.log('\nāš ļø No collections found in source project');
950
+ await tempSourceApp.delete();
951
+ process.exit(0);
952
+ }
953
+
954
+ // Count documents in each collection for preview
955
+ console.log('\nšŸ“‹ Available collections:');
956
+ const collectionInfo: { id: string; count: number }[] = [];
957
+ for (const id of collectionIds) {
958
+ const snapshot = await sourceDb.collection(id).count().get();
959
+ const count = snapshot.data().count;
960
+ collectionInfo.push({ id, count });
961
+ console.log(` - ${id} (${count} documents)`);
962
+ }
963
+
964
+ // Let user select collections
965
+ console.log('');
966
+ const selectedCollections = await checkbox({
967
+ message: 'Select collections to transfer:',
968
+ choices: collectionInfo.map((col) => ({
969
+ name: `${col.id} (${col.count} docs)`,
970
+ value: col.id,
971
+ checked: config.collections.includes(col.id),
972
+ })),
973
+ validate: (value) => value.length > 0 || 'Select at least one collection',
974
+ });
975
+
976
+ // Ask about options
977
+ console.log('');
978
+ const includeSubcollections = await confirm({
979
+ message: 'Include subcollections?',
980
+ default: config.includeSubcollections,
981
+ });
982
+
983
+ const dryRun = await confirm({
984
+ message: 'Dry run mode (preview without writing)?',
985
+ default: config.dryRun,
986
+ });
987
+
988
+ const merge = await confirm({
989
+ message: 'Merge mode (update instead of overwrite)?',
990
+ default: config.merge,
991
+ });
992
+
993
+ // Clean up temporary app
994
+ await tempSourceApp.delete();
995
+
996
+ // Return updated config
997
+ return {
998
+ ...config,
999
+ sourceProject,
1000
+ destProject,
1001
+ collections: selectedCollections,
1002
+ includeSubcollections,
1003
+ dryRun,
1004
+ merge,
1005
+ };
1006
+ }
1007
+
1008
+ // =============================================================================
1009
+ // Firebase
1010
+ // =============================================================================
1011
+
1012
+ let sourceApp: admin.app.App | null = null;
1013
+ let destApp: admin.app.App | null = null;
1014
+
1015
+ function initializeFirebase(config: Config): { sourceDb: Firestore; destDb: Firestore } {
1016
+ sourceApp = admin.initializeApp(
1017
+ {
1018
+ credential: admin.credential.applicationDefault(),
1019
+ projectId: config.sourceProject!,
1020
+ },
1021
+ 'source'
1022
+ );
1023
+
1024
+ destApp = admin.initializeApp(
1025
+ {
1026
+ credential: admin.credential.applicationDefault(),
1027
+ projectId: config.destProject!,
1028
+ },
1029
+ 'dest'
1030
+ );
1031
+
1032
+ return {
1033
+ sourceDb: sourceApp.firestore(),
1034
+ destDb: destApp.firestore(),
1035
+ };
1036
+ }
1037
+
1038
+ async function cleanupFirebase(): Promise<void> {
1039
+ if (sourceApp) await sourceApp.delete();
1040
+ if (destApp) await destApp.delete();
1041
+ }
1042
+
1043
+ async function getSubcollections(docRef: DocumentReference): Promise<string[]> {
1044
+ const collections = await docRef.listCollections();
1045
+ return collections.map((col) => col.id);
1046
+ }
1047
+
1048
+ function getDestCollectionPath(
1049
+ sourcePath: string,
1050
+ renameMapping: Record<string, string>
1051
+ ): string {
1052
+ // Get the root collection name from the source path
1053
+ const rootCollection = sourcePath.split('/')[0];
1054
+
1055
+ // Check if this root collection should be renamed
1056
+ if (renameMapping[rootCollection]) {
1057
+ // Replace the root collection name with the destination name
1058
+ return renameMapping[rootCollection] + sourcePath.slice(rootCollection.length);
1059
+ }
1060
+
1061
+ return sourcePath;
1062
+ }
1063
+
1064
+ function getDestDocId(
1065
+ sourceId: string,
1066
+ prefix: string | null,
1067
+ suffix: string | null
1068
+ ): string {
1069
+ let destId = sourceId;
1070
+ if (prefix) {
1071
+ destId = prefix + destId;
1072
+ }
1073
+ if (suffix) {
1074
+ destId = destId + suffix;
1075
+ }
1076
+ return destId;
1077
+ }
1078
+
1079
+ // =============================================================================
1080
+ // Webhook
1081
+ // =============================================================================
1082
+
1083
+ interface WebhookPayload {
1084
+ source: string;
1085
+ destination: string;
1086
+ collections: string[];
1087
+ stats: Stats;
1088
+ duration: number;
1089
+ dryRun: boolean;
1090
+ success: boolean;
1091
+ error?: string;
1092
+ }
1093
+
1094
+ function detectWebhookType(url: string): 'slack' | 'discord' | 'custom' {
1095
+ if (url.includes('hooks.slack.com')) {
1096
+ return 'slack';
1097
+ }
1098
+ if (url.includes('discord.com/api/webhooks')) {
1099
+ return 'discord';
1100
+ }
1101
+ return 'custom';
1102
+ }
1103
+
1104
+ function formatSlackPayload(payload: WebhookPayload): Record<string, unknown> {
1105
+ const status = payload.success ? ':white_check_mark: Success' : ':x: Failed';
1106
+ const mode = payload.dryRun ? ' (DRY RUN)' : '';
1107
+
1108
+ const fields = [
1109
+ { title: 'Source', value: payload.source, short: true },
1110
+ { title: 'Destination', value: payload.destination, short: true },
1111
+ { title: 'Collections', value: payload.collections.join(', '), short: false },
1112
+ { title: 'Transferred', value: String(payload.stats.documentsTransferred), short: true },
1113
+ { title: 'Deleted', value: String(payload.stats.documentsDeleted), short: true },
1114
+ { title: 'Errors', value: String(payload.stats.errors), short: true },
1115
+ { title: 'Duration', value: `${payload.duration}s`, short: true },
1116
+ ];
1117
+
1118
+ if (payload.error) {
1119
+ fields.push({ title: 'Error', value: payload.error, short: false });
1120
+ }
1121
+
1122
+ return {
1123
+ attachments: [
1124
+ {
1125
+ color: payload.success ? '#36a64f' : '#ff0000',
1126
+ title: `fscopy Transfer${mode}`,
1127
+ text: status,
1128
+ fields,
1129
+ footer: 'fscopy',
1130
+ ts: Math.floor(Date.now() / 1000),
1131
+ },
1132
+ ],
1133
+ };
1134
+ }
1135
+
1136
+ function formatDiscordPayload(payload: WebhookPayload): Record<string, unknown> {
1137
+ const status = payload.success ? 'āœ… Success' : 'āŒ Failed';
1138
+ const mode = payload.dryRun ? ' (DRY RUN)' : '';
1139
+ const color = payload.success ? 0x36a64f : 0xff0000;
1140
+
1141
+ const fields = [
1142
+ { name: 'Source', value: payload.source, inline: true },
1143
+ { name: 'Destination', value: payload.destination, inline: true },
1144
+ { name: 'Collections', value: payload.collections.join(', '), inline: false },
1145
+ { name: 'Transferred', value: String(payload.stats.documentsTransferred), inline: true },
1146
+ { name: 'Deleted', value: String(payload.stats.documentsDeleted), inline: true },
1147
+ { name: 'Errors', value: String(payload.stats.errors), inline: true },
1148
+ { name: 'Duration', value: `${payload.duration}s`, inline: true },
1149
+ ];
1150
+
1151
+ if (payload.error) {
1152
+ fields.push({ name: 'Error', value: payload.error, inline: false });
1153
+ }
1154
+
1155
+ return {
1156
+ embeds: [
1157
+ {
1158
+ title: `fscopy Transfer${mode}`,
1159
+ description: status,
1160
+ color,
1161
+ fields,
1162
+ footer: { text: 'fscopy' },
1163
+ timestamp: new Date().toISOString(),
1164
+ },
1165
+ ],
1166
+ };
1167
+ }
1168
+
1169
+ async function sendWebhook(
1170
+ webhookUrl: string,
1171
+ payload: WebhookPayload,
1172
+ logger: Logger
1173
+ ): Promise<void> {
1174
+ const webhookType = detectWebhookType(webhookUrl);
1175
+
1176
+ let body: Record<string, unknown>;
1177
+ switch (webhookType) {
1178
+ case 'slack':
1179
+ body = formatSlackPayload(payload);
1180
+ break;
1181
+ case 'discord':
1182
+ body = formatDiscordPayload(payload);
1183
+ break;
1184
+ default:
1185
+ body = payload as unknown as Record<string, unknown>;
1186
+ }
1187
+
1188
+ try {
1189
+ const response = await fetch(webhookUrl, {
1190
+ method: 'POST',
1191
+ headers: { 'Content-Type': 'application/json' },
1192
+ body: JSON.stringify(body),
1193
+ });
1194
+
1195
+ if (!response.ok) {
1196
+ const errorText = await response.text();
1197
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
1198
+ }
1199
+
1200
+ logger.info(`Webhook sent successfully (${webhookType})`, { url: webhookUrl });
1201
+ console.log(`šŸ“¤ Webhook notification sent (${webhookType})`);
1202
+ } catch (error) {
1203
+ const message = error instanceof Error ? error.message : String(error);
1204
+ logger.error(`Failed to send webhook: ${message}`, { url: webhookUrl });
1205
+ console.error(`āš ļø Failed to send webhook: ${message}`);
1206
+ }
1207
+ }
1208
+
1209
+ // =============================================================================
1210
+ // State Management (Resume Support)
1211
+ // =============================================================================
1212
+
1213
+ const STATE_VERSION = 1;
1214
+
1215
+ function loadTransferState(stateFile: string): TransferState | null {
1216
+ try {
1217
+ if (!fs.existsSync(stateFile)) {
1218
+ return null;
1219
+ }
1220
+ const content = fs.readFileSync(stateFile, 'utf-8');
1221
+ const state = JSON.parse(content) as TransferState;
1222
+
1223
+ if (state.version !== STATE_VERSION) {
1224
+ console.warn(`āš ļø State file version mismatch (expected ${STATE_VERSION}, got ${state.version})`);
1225
+ return null;
1226
+ }
1227
+
1228
+ return state;
1229
+ } catch (error) {
1230
+ console.error(`āš ļø Failed to load state file: ${(error as Error).message}`);
1231
+ return null;
1232
+ }
1233
+ }
1234
+
1235
+ function saveTransferState(stateFile: string, state: TransferState): void {
1236
+ state.updatedAt = new Date().toISOString();
1237
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
1238
+ }
1239
+
1240
+ function deleteTransferState(stateFile: string): void {
1241
+ try {
1242
+ if (fs.existsSync(stateFile)) {
1243
+ fs.unlinkSync(stateFile);
1244
+ }
1245
+ } catch {
1246
+ // Ignore errors when deleting state file
1247
+ }
1248
+ }
1249
+
1250
+ function createInitialState(config: Config): TransferState {
1251
+ return {
1252
+ version: STATE_VERSION,
1253
+ sourceProject: config.sourceProject!,
1254
+ destProject: config.destProject!,
1255
+ collections: config.collections,
1256
+ startedAt: new Date().toISOString(),
1257
+ updatedAt: new Date().toISOString(),
1258
+ completedDocs: {},
1259
+ stats: {
1260
+ collectionsProcessed: 0,
1261
+ documentsTransferred: 0,
1262
+ documentsDeleted: 0,
1263
+ errors: 0,
1264
+ },
1265
+ };
1266
+ }
1267
+
1268
+ function validateStateForResume(state: TransferState, config: Config): string[] {
1269
+ const errors: string[] = [];
1270
+
1271
+ if (state.sourceProject !== config.sourceProject) {
1272
+ errors.push(`Source project mismatch: state has "${state.sourceProject}", config has "${config.sourceProject}"`);
1273
+ }
1274
+ if (state.destProject !== config.destProject) {
1275
+ errors.push(`Destination project mismatch: state has "${state.destProject}", config has "${config.destProject}"`);
1276
+ }
1277
+
1278
+ // Check if collections are compatible (state collections should be subset of config)
1279
+ const configCollections = new Set(config.collections);
1280
+ for (const col of state.collections) {
1281
+ if (!configCollections.has(col)) {
1282
+ errors.push(`State contains collection "${col}" not in current config`);
1283
+ }
1284
+ }
1285
+
1286
+ return errors;
1287
+ }
1288
+
1289
+ function isDocCompleted(state: TransferState, collectionPath: string, docId: string): boolean {
1290
+ const completedInCollection = state.completedDocs[collectionPath];
1291
+ return completedInCollection ? completedInCollection.includes(docId) : false;
1292
+ }
1293
+
1294
+ function markDocCompleted(state: TransferState, collectionPath: string, docId: string): void {
1295
+ if (!state.completedDocs[collectionPath]) {
1296
+ state.completedDocs[collectionPath] = [];
1297
+ }
1298
+ state.completedDocs[collectionPath].push(docId);
1299
+ }
1300
+
1301
+ // =============================================================================
1302
+ // Transfer Logic
1303
+ // =============================================================================
1304
+
1305
+ async function clearCollection(
1306
+ db: Firestore,
1307
+ collectionPath: string,
1308
+ config: Config,
1309
+ logger: Logger,
1310
+ includeSubcollections: boolean
1311
+ ): Promise<number> {
1312
+ let deletedCount = 0;
1313
+ const collectionRef = db.collection(collectionPath);
1314
+ const snapshot = await collectionRef.get();
1315
+
1316
+ if (snapshot.empty) {
1317
+ return 0;
1318
+ }
1319
+
1320
+ // Delete subcollections first if enabled
1321
+ if (includeSubcollections) {
1322
+ for (const doc of snapshot.docs) {
1323
+ const subcollections = await getSubcollections(doc.ref);
1324
+ for (const subId of subcollections) {
1325
+ // Check exclude patterns
1326
+ if (matchesExcludePattern(subId, config.exclude)) {
1327
+ continue;
1328
+ }
1329
+ const subPath = `${collectionPath}/${doc.id}/${subId}`;
1330
+ deletedCount += await clearCollection(db, subPath, config, logger, true);
1331
+ }
1332
+ }
1333
+ }
1334
+
1335
+ // Delete documents in batches
1336
+ const docs = snapshot.docs;
1337
+ for (let i = 0; i < docs.length; i += config.batchSize) {
1338
+ const batch = docs.slice(i, i + config.batchSize);
1339
+ const writeBatch = db.batch();
1340
+
1341
+ for (const doc of batch) {
1342
+ writeBatch.delete(doc.ref);
1343
+ deletedCount++;
1344
+ }
1345
+
1346
+ if (!config.dryRun) {
1347
+ await withRetry(() => writeBatch.commit(), {
1348
+ retries: config.retries,
1349
+ onRetry: (attempt, max, err, delay) => {
1350
+ logger.error(`Retry delete ${attempt}/${max} for ${collectionPath}`, {
1351
+ error: err.message,
1352
+ delay,
1353
+ });
1354
+ },
1355
+ });
1356
+ }
1357
+
1358
+ logger.info(`Deleted ${batch.length} documents from ${collectionPath}`);
1359
+ }
1360
+
1361
+ return deletedCount;
1362
+ }
1363
+
1364
+ async function deleteOrphanDocuments(
1365
+ sourceDb: Firestore,
1366
+ destDb: Firestore,
1367
+ sourceCollectionPath: string,
1368
+ config: Config,
1369
+ logger: Logger
1370
+ ): Promise<number> {
1371
+ let deletedCount = 0;
1372
+
1373
+ // Get the destination path (may be renamed)
1374
+ const destCollectionPath = getDestCollectionPath(sourceCollectionPath, config.renameCollection);
1375
+
1376
+ // Get all document IDs from source
1377
+ const sourceSnapshot = await sourceDb.collection(sourceCollectionPath).get();
1378
+ const sourceIds = new Set(sourceSnapshot.docs.map((doc) => doc.id));
1379
+
1380
+ // Get all document IDs from destination
1381
+ const destSnapshot = await destDb.collection(destCollectionPath).get();
1382
+
1383
+ // Find orphan documents (in dest but not in source)
1384
+ const orphanDocs = destSnapshot.docs.filter((doc) => !sourceIds.has(doc.id));
1385
+
1386
+ if (orphanDocs.length === 0) {
1387
+ return 0;
1388
+ }
1389
+
1390
+ logger.info(`Found ${orphanDocs.length} orphan documents in ${destCollectionPath}`);
1391
+
1392
+ // Delete orphan documents in batches
1393
+ for (let i = 0; i < orphanDocs.length; i += config.batchSize) {
1394
+ const batch = orphanDocs.slice(i, i + config.batchSize);
1395
+ const writeBatch = destDb.batch();
1396
+
1397
+ for (const doc of batch) {
1398
+ // If subcollections are included, recursively delete orphans in subcollections first
1399
+ if (config.includeSubcollections) {
1400
+ const subcollections = await getSubcollections(doc.ref);
1401
+ for (const subId of subcollections) {
1402
+ if (matchesExcludePattern(subId, config.exclude)) {
1403
+ continue;
1404
+ }
1405
+ const subPath = `${destCollectionPath}/${doc.id}/${subId}`;
1406
+ // For orphan parent docs, clear all subcollection data
1407
+ deletedCount += await clearCollection(destDb, subPath, config, logger, true);
1408
+ }
1409
+ }
1410
+
1411
+ writeBatch.delete(doc.ref);
1412
+ deletedCount++;
1413
+ }
1414
+
1415
+ if (!config.dryRun) {
1416
+ await withRetry(() => writeBatch.commit(), {
1417
+ retries: config.retries,
1418
+ onRetry: (attempt, max, err, delay) => {
1419
+ logger.error(`Retry delete orphans ${attempt}/${max} for ${destCollectionPath}`, {
1420
+ error: err.message,
1421
+ delay,
1422
+ });
1423
+ },
1424
+ });
1425
+ }
1426
+
1427
+ logger.info(`Deleted ${batch.length} orphan documents from ${destCollectionPath}`);
1428
+ }
1429
+
1430
+ // Also check subcollections of existing documents for orphans
1431
+ if (config.includeSubcollections) {
1432
+ for (const sourceDoc of sourceSnapshot.docs) {
1433
+ const sourceSubcollections = await getSubcollections(sourceDoc.ref);
1434
+ for (const subId of sourceSubcollections) {
1435
+ if (matchesExcludePattern(subId, config.exclude)) {
1436
+ continue;
1437
+ }
1438
+ const subPath = `${sourceCollectionPath}/${sourceDoc.id}/${subId}`;
1439
+ deletedCount += await deleteOrphanDocuments(sourceDb, destDb, subPath, config, logger);
1440
+ }
1441
+ }
1442
+ }
1443
+
1444
+ return deletedCount;
1445
+ }
1446
+
1447
+ async function countDocuments(
1448
+ sourceDb: Firestore,
1449
+ collectionPath: string,
1450
+ config: Config,
1451
+ depth: number = 0
1452
+ ): Promise<number> {
1453
+ let count = 0;
1454
+
1455
+ // Build query with where filters (only at root level)
1456
+ let query: FirebaseFirestore.Query = sourceDb.collection(collectionPath);
1457
+ if (depth === 0 && config.where.length > 0) {
1458
+ for (const filter of config.where) {
1459
+ query = query.where(filter.field, filter.operator, filter.value);
1460
+ }
1461
+ }
1462
+
1463
+ const snapshot = await query.get();
1464
+ count += snapshot.size;
1465
+
1466
+ if (config.includeSubcollections) {
1467
+ for (const doc of snapshot.docs) {
1468
+ const subcollections = await getSubcollections(doc.ref);
1469
+ for (const subId of subcollections) {
1470
+ const subPath = `${collectionPath}/${doc.id}/${subId}`;
1471
+
1472
+ // Check exclude patterns
1473
+ if (matchesExcludePattern(subId, config.exclude)) {
1474
+ continue;
1475
+ }
1476
+
1477
+ count += await countDocuments(sourceDb, subPath, config, depth + 1);
1478
+ }
1479
+ }
1480
+ }
1481
+
1482
+ return count;
1483
+ }
1484
+
1485
+ interface TransferContext {
1486
+ sourceDb: Firestore;
1487
+ destDb: Firestore;
1488
+ config: Config;
1489
+ stats: Stats;
1490
+ logger: Logger;
1491
+ progressBar: cliProgress.SingleBar | null;
1492
+ transformFn: TransformFunction | null;
1493
+ state: TransferState | null;
1494
+ }
1495
+
1496
+ async function transferCollection(
1497
+ ctx: TransferContext,
1498
+ collectionPath: string,
1499
+ depth: number = 0
1500
+ ): Promise<void> {
1501
+ const { sourceDb, destDb, config, stats, logger, progressBar, transformFn, state } = ctx;
1502
+
1503
+ // Get the destination path (may be renamed)
1504
+ const destCollectionPath = getDestCollectionPath(collectionPath, config.renameCollection);
1505
+
1506
+ const sourceCollectionRef = sourceDb.collection(collectionPath);
1507
+ let query: FirebaseFirestore.Query = sourceCollectionRef;
1508
+
1509
+ // Apply where filters (only at root level)
1510
+ if (depth === 0 && config.where.length > 0) {
1511
+ for (const filter of config.where) {
1512
+ query = query.where(filter.field, filter.operator, filter.value);
1513
+ }
1514
+ }
1515
+
1516
+ if (config.limit > 0 && depth === 0) {
1517
+ query = query.limit(config.limit);
1518
+ }
1519
+
1520
+ const snapshot = await withRetry(() => query.get(), {
1521
+ retries: config.retries,
1522
+ onRetry: (attempt, max, err, delay) => {
1523
+ logger.error(`Retry ${attempt}/${max} for ${collectionPath}`, {
1524
+ error: err.message,
1525
+ delay,
1526
+ });
1527
+ },
1528
+ });
1529
+
1530
+ if (snapshot.empty) {
1531
+ return;
1532
+ }
1533
+
1534
+ stats.collectionsProcessed++;
1535
+ logger.info(`Processing collection: ${collectionPath}`, { documents: snapshot.size });
1536
+
1537
+ const docs = snapshot.docs;
1538
+ const batchDocIds: string[] = []; // Track docs in current batch for state saving
1539
+
1540
+ for (let i = 0; i < docs.length; i += config.batchSize) {
1541
+ const batch = docs.slice(i, i + config.batchSize);
1542
+ const destBatch: WriteBatch = destDb.batch();
1543
+ batchDocIds.length = 0; // Clear for new batch
1544
+
1545
+ for (const doc of batch) {
1546
+ // Skip if already completed (resume mode)
1547
+ if (state && isDocCompleted(state, collectionPath, doc.id)) {
1548
+ if (progressBar) {
1549
+ progressBar.increment();
1550
+ }
1551
+ stats.documentsTransferred++;
1552
+ continue;
1553
+ }
1554
+
1555
+ // Get destination document ID (with optional prefix/suffix)
1556
+ const destDocId = getDestDocId(doc.id, config.idPrefix, config.idSuffix);
1557
+ const destDocRef = destDb.collection(destCollectionPath).doc(destDocId);
1558
+
1559
+ // Apply transform if provided
1560
+ let docData = doc.data() as Record<string, unknown>;
1561
+ if (transformFn) {
1562
+ const transformed = transformFn(docData, {
1563
+ id: doc.id,
1564
+ path: `${collectionPath}/${doc.id}`,
1565
+ });
1566
+ if (transformed === null) {
1567
+ // Skip this document if transform returns null
1568
+ logger.info('Skipped document (transform returned null)', {
1569
+ collection: collectionPath,
1570
+ docId: doc.id,
1571
+ });
1572
+ if (progressBar) {
1573
+ progressBar.increment();
1574
+ }
1575
+ // Mark as completed even if skipped
1576
+ batchDocIds.push(doc.id);
1577
+ continue;
1578
+ }
1579
+ docData = transformed;
1580
+ }
1581
+
1582
+ if (!config.dryRun) {
1583
+ // Use merge option if enabled
1584
+ if (config.merge) {
1585
+ destBatch.set(destDocRef, docData, { merge: true });
1586
+ } else {
1587
+ destBatch.set(destDocRef, docData);
1588
+ }
1589
+ }
1590
+
1591
+ batchDocIds.push(doc.id);
1592
+ stats.documentsTransferred++;
1593
+ if (progressBar) {
1594
+ progressBar.increment();
1595
+ }
1596
+
1597
+ logger.info('Transferred document', {
1598
+ source: collectionPath,
1599
+ dest: destCollectionPath,
1600
+ sourceDocId: doc.id,
1601
+ destDocId: destDocId,
1602
+ });
1603
+
1604
+ if (config.includeSubcollections) {
1605
+ const subcollections = await getSubcollections(doc.ref);
1606
+
1607
+ for (const subcollectionId of subcollections) {
1608
+ // Check exclude patterns
1609
+ if (matchesExcludePattern(subcollectionId, config.exclude)) {
1610
+ logger.info(`Skipping excluded subcollection: ${subcollectionId}`);
1611
+ continue;
1612
+ }
1613
+
1614
+ const subcollectionPath = `${collectionPath}/${doc.id}/${subcollectionId}`;
1615
+
1616
+ await transferCollection(
1617
+ { ...ctx, config: { ...config, limit: 0, where: [] } },
1618
+ subcollectionPath,
1619
+ depth + 1
1620
+ );
1621
+ }
1622
+ }
1623
+ }
1624
+
1625
+ if (!config.dryRun && batch.length > 0) {
1626
+ await withRetry(() => destBatch.commit(), {
1627
+ retries: config.retries,
1628
+ onRetry: (attempt, max, err, delay) => {
1629
+ logger.error(`Retry commit ${attempt}/${max}`, { error: err.message, delay });
1630
+ },
1631
+ });
1632
+
1633
+ // Save state after successful batch commit (for resume support)
1634
+ if (state && batchDocIds.length > 0) {
1635
+ for (const docId of batchDocIds) {
1636
+ markDocCompleted(state, collectionPath, docId);
1637
+ }
1638
+ state.stats = { ...stats };
1639
+ saveTransferState(config.stateFile, state);
1640
+ }
1641
+ }
1642
+ }
1643
+ }
1644
+
1645
+ // =============================================================================
1646
+ // Parallel Processing Helper
1647
+ // =============================================================================
1648
+
1649
+ async function processInParallel<T, R>(
1650
+ items: T[],
1651
+ concurrency: number,
1652
+ processor: (item: T) => Promise<R>
1653
+ ): Promise<R[]> {
1654
+ const results: R[] = [];
1655
+ const executing: Promise<void>[] = [];
1656
+
1657
+ for (const item of items) {
1658
+ const promise = processor(item).then((result) => {
1659
+ results.push(result);
1660
+ });
1661
+
1662
+ executing.push(promise);
1663
+
1664
+ if (executing.length >= concurrency) {
1665
+ await Promise.race(executing);
1666
+ // Remove completed promises
1667
+ for (let i = executing.length - 1; i >= 0; i--) {
1668
+ const p = executing[i];
1669
+ // Check if promise is settled by racing with resolved promise
1670
+ const isSettled = await Promise.race([
1671
+ p.then(() => true).catch(() => true),
1672
+ Promise.resolve(false),
1673
+ ]);
1674
+ if (isSettled) {
1675
+ executing.splice(i, 1);
1676
+ }
1677
+ }
1678
+ }
1679
+ }
1680
+
1681
+ await Promise.all(executing);
1682
+ return results;
1683
+ }
1684
+
1685
+ // =============================================================================
1686
+ // Main
1687
+ // =============================================================================
1688
+
1689
+ // Handle --init command
1690
+ if (argv.init !== undefined) {
1691
+ const filename = argv.init || 'fscopy.ini';
1692
+ generateConfigFile(filename);
1693
+ process.exit(0);
1694
+ }
1695
+
1696
+ // Main transfer flow
1697
+ let config: Config = defaults;
1698
+ let logger: Logger | null = null;
1699
+ let stats: Stats = { collectionsProcessed: 0, documentsTransferred: 0, documentsDeleted: 0, errors: 0 };
1700
+ let startTime = Date.now();
1701
+
1702
+ try {
1703
+ const fileConfig = loadConfigFile(argv.config);
1704
+ config = mergeConfig(defaults, fileConfig, argv);
1705
+
1706
+ // Run interactive mode if enabled
1707
+ if (argv.interactive) {
1708
+ config = await runInteractiveMode(config);
1709
+ }
1710
+
1711
+ displayConfig(config);
1712
+
1713
+ const errors = validateConfig(config);
1714
+ if (errors.length > 0) {
1715
+ console.log('\nāŒ Configuration errors:');
1716
+ errors.forEach((err) => console.log(` - ${err}`));
1717
+ process.exit(1);
1718
+ }
1719
+
1720
+ // Skip confirmation in interactive mode (already confirmed by selection)
1721
+ if (!argv.yes && !argv.interactive) {
1722
+ const confirmed = await askConfirmation(config);
1723
+ if (!confirmed) {
1724
+ console.log('\n🚫 Transfer cancelled by user\n');
1725
+ process.exit(0);
1726
+ }
1727
+ }
1728
+
1729
+ logger = new Logger(argv.log);
1730
+ logger.init();
1731
+ logger.info('Transfer started', { config: config as unknown as Record<string, unknown> });
1732
+
1733
+ // Handle resume mode
1734
+ let transferState: TransferState | null = null;
1735
+ if (config.resume) {
1736
+ const existingState = loadTransferState(config.stateFile);
1737
+ if (!existingState) {
1738
+ console.error(`\nāŒ No state file found at ${config.stateFile}`);
1739
+ console.error(' Cannot resume without a saved state. Run without --resume to start fresh.');
1740
+ process.exit(1);
1741
+ }
1742
+
1743
+ const stateErrors = validateStateForResume(existingState, config);
1744
+ if (stateErrors.length > 0) {
1745
+ console.error('\nāŒ Cannot resume: state file incompatible with current config:');
1746
+ stateErrors.forEach((err) => console.error(` - ${err}`));
1747
+ process.exit(1);
1748
+ }
1749
+
1750
+ transferState = existingState;
1751
+ const completedCount = Object.values(transferState.completedDocs).reduce((sum, ids) => sum + ids.length, 0);
1752
+ console.log(`\nšŸ”„ Resuming transfer from ${config.stateFile}`);
1753
+ console.log(` Started: ${transferState.startedAt}`);
1754
+ console.log(` Previously completed: ${completedCount} documents`);
1755
+ stats = { ...transferState.stats };
1756
+ } else if (!config.dryRun) {
1757
+ // Create new state for tracking (only in non-dry-run mode)
1758
+ transferState = createInitialState(config);
1759
+ saveTransferState(config.stateFile, transferState);
1760
+ console.log(`\nšŸ’¾ State will be saved to ${config.stateFile} (use --resume to continue if interrupted)`);
1761
+ }
1762
+
1763
+ // Load transform function if specified
1764
+ let transformFn: TransformFunction | null = null;
1765
+ if (config.transform) {
1766
+ console.log(`\nšŸ”§ Loading transform: ${config.transform}`);
1767
+ transformFn = await loadTransformFunction(config.transform);
1768
+ console.log(' Transform loaded successfully');
1769
+ }
1770
+
1771
+ console.log('\n');
1772
+ startTime = Date.now();
1773
+
1774
+ const { sourceDb, destDb } = initializeFirebase(config);
1775
+
1776
+ if (!config.resume) {
1777
+ stats = {
1778
+ collectionsProcessed: 0,
1779
+ documentsTransferred: 0,
1780
+ documentsDeleted: 0,
1781
+ errors: 0,
1782
+ };
1783
+ }
1784
+
1785
+ // Count total documents for progress bar
1786
+ let totalDocs = 0;
1787
+ let progressBar: cliProgress.SingleBar | null = null;
1788
+
1789
+ if (!argv.quiet) {
1790
+ console.log('šŸ“Š Counting documents...');
1791
+ for (const collection of config.collections) {
1792
+ totalDocs += await countDocuments(sourceDb, collection, config);
1793
+ }
1794
+ console.log(` Found ${totalDocs} documents to transfer\n`);
1795
+
1796
+ if (totalDocs > 0) {
1797
+ progressBar = new cliProgress.SingleBar({
1798
+ format: 'šŸ“¦ Progress |{bar}| {percentage}% | {value}/{total} docs | ETA: {eta}s',
1799
+ barCompleteChar: 'ā–ˆ',
1800
+ barIncompleteChar: 'ā–‘',
1801
+ hideCursor: true,
1802
+ });
1803
+ progressBar.start(totalDocs, 0);
1804
+ }
1805
+ }
1806
+
1807
+ // Clear destination collections if enabled
1808
+ if (config.clear) {
1809
+ console.log('šŸ—‘ļø Clearing destination collections...');
1810
+ for (const collection of config.collections) {
1811
+ const destCollection = getDestCollectionPath(collection, config.renameCollection);
1812
+ const deleted = await clearCollection(
1813
+ destDb,
1814
+ destCollection,
1815
+ config,
1816
+ logger,
1817
+ config.includeSubcollections
1818
+ );
1819
+ stats.documentsDeleted += deleted;
1820
+ }
1821
+ console.log(` Deleted ${stats.documentsDeleted} documents\n`);
1822
+ }
1823
+
1824
+ const ctx: TransferContext = { sourceDb, destDb, config, stats, logger, progressBar, transformFn, state: transferState };
1825
+
1826
+ // Transfer collections (with optional parallelism)
1827
+ if (config.parallel > 1) {
1828
+ await processInParallel(config.collections, config.parallel, (collection) =>
1829
+ transferCollection(ctx, collection)
1830
+ );
1831
+ } else {
1832
+ for (const collection of config.collections) {
1833
+ await transferCollection(ctx, collection);
1834
+ }
1835
+ }
1836
+
1837
+ if (progressBar) {
1838
+ progressBar.stop();
1839
+ }
1840
+
1841
+ // Delete orphan documents if enabled (sync mode)
1842
+ if (config.deleteMissing) {
1843
+ console.log('\nšŸ”„ Deleting orphan documents (sync mode)...');
1844
+ for (const collection of config.collections) {
1845
+ const deleted = await deleteOrphanDocuments(
1846
+ sourceDb,
1847
+ destDb,
1848
+ collection,
1849
+ config,
1850
+ logger
1851
+ );
1852
+ stats.documentsDeleted += deleted;
1853
+ }
1854
+ if (stats.documentsDeleted > 0) {
1855
+ console.log(` Deleted ${stats.documentsDeleted} orphan documents`);
1856
+ } else {
1857
+ console.log(' No orphan documents found');
1858
+ }
1859
+ }
1860
+
1861
+ const duration = ((Date.now() - startTime) / 1000).toFixed(2);
1862
+
1863
+ logger.success('Transfer completed', {
1864
+ stats: stats as unknown as Record<string, unknown>,
1865
+ duration,
1866
+ });
1867
+ logger.summary(stats, duration);
1868
+
1869
+ console.log('\n' + '='.repeat(60));
1870
+ console.log('šŸ“Š TRANSFER SUMMARY');
1871
+ console.log('='.repeat(60));
1872
+ console.log(`Collections processed: ${stats.collectionsProcessed}`);
1873
+ if (stats.documentsDeleted > 0) {
1874
+ console.log(`Documents deleted: ${stats.documentsDeleted}`);
1875
+ }
1876
+ console.log(`Documents transferred: ${stats.documentsTransferred}`);
1877
+ console.log(`Errors: ${stats.errors}`);
1878
+ console.log(`Duration: ${duration}s`);
1879
+
1880
+ if (argv.log) {
1881
+ console.log(`Log file: ${argv.log}`);
1882
+ }
1883
+
1884
+ if (config.dryRun) {
1885
+ console.log('\n⚠ DRY RUN: No data was actually written');
1886
+ console.log(' Run with --dry-run=false to perform the transfer');
1887
+ } else {
1888
+ console.log('\nāœ“ Transfer completed successfully');
1889
+ // Delete state file on successful completion
1890
+ deleteTransferState(config.stateFile);
1891
+ }
1892
+ console.log('='.repeat(60) + '\n');
1893
+
1894
+ // Send webhook notification if configured
1895
+ if (config.webhook) {
1896
+ await sendWebhook(
1897
+ config.webhook,
1898
+ {
1899
+ source: config.sourceProject!,
1900
+ destination: config.destProject!,
1901
+ collections: config.collections,
1902
+ stats,
1903
+ duration: Number.parseFloat(duration),
1904
+ dryRun: config.dryRun,
1905
+ success: true,
1906
+ },
1907
+ logger
1908
+ );
1909
+ }
1910
+
1911
+ await cleanupFirebase();
1912
+ } catch (error) {
1913
+ const errorMessage = (error as Error).message;
1914
+ console.error('\nāŒ Error during transfer:', errorMessage);
1915
+
1916
+ // Send webhook notification on error if configured
1917
+ if (config.webhook && logger) {
1918
+ await sendWebhook(
1919
+ config.webhook,
1920
+ {
1921
+ source: config.sourceProject ?? 'unknown',
1922
+ destination: config.destProject ?? 'unknown',
1923
+ collections: config.collections,
1924
+ stats,
1925
+ duration: Number.parseFloat(((Date.now() - startTime) / 1000).toFixed(2)),
1926
+ dryRun: config.dryRun,
1927
+ success: false,
1928
+ error: errorMessage,
1929
+ },
1930
+ logger
1931
+ );
1932
+ }
1933
+
1934
+ await cleanupFirebase();
1935
+ process.exit(1);
1936
+ }