@fazetitans/fscopy 1.3.1 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fazetitans/fscopy",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Fast CLI tool to copy Firestore collections between Firebase projects with filtering, parallel transfers, and subcollection support",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -89,7 +89,6 @@ const argv = yargs(hideBin(process.argv))
89
89
  .option('retries', {
90
90
  type: 'number',
91
91
  description: 'Number of retries on error (default: 3)',
92
- default: 3,
93
92
  })
94
93
  .option('quiet', {
95
94
  alias: 'q',
@@ -166,17 +165,14 @@ const argv = yargs(hideBin(process.argv))
166
165
  .option('verify', {
167
166
  type: 'boolean',
168
167
  description: 'Verify document counts after transfer',
169
- default: false,
170
168
  })
171
169
  .option('rate-limit', {
172
170
  type: 'number',
173
171
  description: 'Limit transfer rate (documents per second, 0 = unlimited)',
174
- default: 0,
175
172
  })
176
173
  .option('skip-oversized', {
177
174
  type: 'boolean',
178
175
  description: 'Skip documents exceeding 1MB instead of failing',
179
- default: false,
180
176
  })
181
177
  .option('json', {
182
178
  type: 'boolean',
@@ -191,17 +187,14 @@ const argv = yargs(hideBin(process.argv))
191
187
  .option('detect-conflicts', {
192
188
  type: 'boolean',
193
189
  description: 'Detect if destination docs were modified during transfer',
194
- default: false,
195
190
  })
196
191
  .option('max-depth', {
197
192
  type: 'number',
198
193
  description: 'Max subcollection depth (0 = unlimited)',
199
- default: 0,
200
194
  })
201
195
  .option('verify-integrity', {
202
196
  type: 'boolean',
203
197
  description: 'Verify document integrity with hash after transfer',
204
- default: false,
205
198
  })
206
199
  .option('validate-only', {
207
200
  type: 'boolean',
@@ -250,7 +243,11 @@ async function main(): Promise<void> {
250
243
 
251
244
  // Run interactive mode if enabled
252
245
  if (argv.interactive) {
253
- config = await runInteractiveMode(config);
246
+ const result = await runInteractiveMode(config);
247
+ config = result.config;
248
+ if (result.action === 'save') {
249
+ process.exit(0);
250
+ }
254
251
  }
255
252
 
256
253
  displayConfig(config);
@@ -66,6 +66,12 @@ export function parseStringList(value: string | undefined): string[] {
66
66
  .filter((s) => s.length > 0);
67
67
  }
68
68
 
69
+ export function parseIntOption(value: string | undefined): number | undefined {
70
+ if (value === undefined || value === '') return undefined;
71
+ const parsed = Number.parseInt(value, 10);
72
+ return Number.isNaN(parsed) ? undefined : parsed;
73
+ }
74
+
69
75
  export function parseRenameMapping(
70
76
  mappings: string[] | string | undefined
71
77
  ): Record<string, string> {
@@ -115,6 +121,13 @@ export function parseIniConfig(content: string): Partial<Config> {
115
121
  idPrefix?: string;
116
122
  idSuffix?: string;
117
123
  webhook?: string;
124
+ retries?: string;
125
+ rateLimit?: string;
126
+ skipOversized?: string | boolean;
127
+ detectConflicts?: string | boolean;
128
+ maxDepth?: string;
129
+ verify?: string | boolean;
130
+ verifyIntegrity?: string | boolean;
118
131
  };
119
132
  };
120
133
 
@@ -149,6 +162,13 @@ export function parseIniConfig(content: string): Partial<Config> {
149
162
  idPrefix: parsed.options?.idPrefix ?? null,
150
163
  idSuffix: parsed.options?.idSuffix ?? null,
151
164
  webhook: parsed.options?.webhook ?? null,
165
+ retries: parseIntOption(parsed.options?.retries),
166
+ rateLimit: parseIntOption(parsed.options?.rateLimit),
167
+ skipOversized: parsed.options?.skipOversized !== undefined ? parseBoolean(parsed.options.skipOversized) : undefined,
168
+ detectConflicts: parsed.options?.detectConflicts !== undefined ? parseBoolean(parsed.options.detectConflicts) : undefined,
169
+ maxDepth: parseIntOption(parsed.options?.maxDepth),
170
+ verify: parsed.options?.verify !== undefined ? parseBoolean(parsed.options.verify) : undefined,
171
+ verifyIntegrity: parsed.options?.verifyIntegrity !== undefined ? parseBoolean(parsed.options.verifyIntegrity) : undefined,
152
172
  };
153
173
  }
154
174
 
@@ -172,6 +192,13 @@ export function parseJsonConfig(content: string): Partial<Config> {
172
192
  idPrefix?: string;
173
193
  idSuffix?: string;
174
194
  webhook?: string;
195
+ retries?: number;
196
+ rateLimit?: number;
197
+ skipOversized?: boolean;
198
+ detectConflicts?: boolean;
199
+ maxDepth?: number;
200
+ verify?: boolean;
201
+ verifyIntegrity?: boolean;
175
202
  };
176
203
 
177
204
  return {
@@ -193,6 +220,13 @@ export function parseJsonConfig(content: string): Partial<Config> {
193
220
  idPrefix: config.idPrefix ?? null,
194
221
  idSuffix: config.idSuffix ?? null,
195
222
  webhook: config.webhook ?? null,
223
+ retries: config.retries,
224
+ rateLimit: config.rateLimit,
225
+ skipOversized: config.skipOversized,
226
+ detectConflicts: config.detectConflicts,
227
+ maxDepth: config.maxDepth,
228
+ verify: config.verify,
229
+ verifyIntegrity: config.verifyIntegrity,
196
230
  };
197
231
  }
198
232
 
@@ -232,7 +266,6 @@ export function mergeConfig(
232
266
  sourceProject:
233
267
  cliArgs.sourceProject ?? fileConfig.sourceProject ?? defaultConfig.sourceProject,
234
268
  destProject: cliArgs.destProject ?? fileConfig.destProject ?? defaultConfig.destProject,
235
- retries: cliArgs.retries ?? defaultConfig.retries,
236
269
  where:
237
270
  cliWhereFilters.length > 0
238
271
  ? cliWhereFilters
@@ -251,15 +284,16 @@ export function mergeConfig(
251
284
  idPrefix: cliArgs.idPrefix ?? fileConfig.idPrefix ?? defaultConfig.idPrefix,
252
285
  idSuffix: cliArgs.idSuffix ?? fileConfig.idSuffix ?? defaultConfig.idSuffix,
253
286
  webhook: cliArgs.webhook ?? fileConfig.webhook ?? defaultConfig.webhook,
287
+ retries: cliArgs.retries ?? fileConfig.retries ?? defaultConfig.retries,
254
288
  resume: cliArgs.resume ?? defaultConfig.resume,
255
289
  stateFile: cliArgs.stateFile ?? defaultConfig.stateFile,
256
- verify: cliArgs.verify ?? defaultConfig.verify,
257
- rateLimit: cliArgs.rateLimit ?? defaultConfig.rateLimit,
258
- skipOversized: cliArgs.skipOversized ?? defaultConfig.skipOversized,
290
+ verify: cliArgs.verify ?? fileConfig.verify ?? defaultConfig.verify,
291
+ rateLimit: cliArgs.rateLimit ?? fileConfig.rateLimit ?? defaultConfig.rateLimit,
292
+ skipOversized: cliArgs.skipOversized ?? fileConfig.skipOversized ?? defaultConfig.skipOversized,
259
293
  json: cliArgs.json ?? defaultConfig.json,
260
294
  transformSamples: cliArgs.transformSamples ?? defaultConfig.transformSamples,
261
- detectConflicts: cliArgs.detectConflicts ?? defaultConfig.detectConflicts,
262
- maxDepth: cliArgs.maxDepth ?? defaultConfig.maxDepth,
263
- verifyIntegrity: cliArgs.verifyIntegrity ?? defaultConfig.verifyIntegrity,
295
+ detectConflicts: cliArgs.detectConflicts ?? fileConfig.detectConflicts ?? defaultConfig.detectConflicts,
296
+ maxDepth: cliArgs.maxDepth ?? fileConfig.maxDepth ?? defaultConfig.maxDepth,
297
+ verifyIntegrity: cliArgs.verifyIntegrity ?? fileConfig.verifyIntegrity ?? defaultConfig.verifyIntegrity,
264
298
  };
265
299
  }
@@ -1,8 +1,31 @@
1
1
  import admin from 'firebase-admin';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
2
4
  import type { Firestore } from 'firebase-admin/firestore';
3
- import { input, checkbox, confirm } from '@inquirer/prompts';
5
+ import { input, checkbox, confirm, select, number } from '@inquirer/prompts';
4
6
  import { SEPARATOR_LENGTH } from './constants.js';
5
- import type { Config } from './types.js';
7
+ import type { Config, WhereFilter } from './types.js';
8
+ import { parseWhereFilter, parseRenameMapping, parseStringList } from './config/parser.js';
9
+
10
+ // =============================================================================
11
+ // Types
12
+ // =============================================================================
13
+
14
+ export interface InteractiveResult {
15
+ config: Config;
16
+ action: 'execute' | 'save';
17
+ }
18
+
19
+ type AdvancedOption =
20
+ | 'exclude' | 'where' | 'parallel' | 'batchSize' | 'limit'
21
+ | 'maxDepth' | 'rateLimit' | 'clear' | 'deleteMissing' | 'transform'
22
+ | 'renameCollection' | 'idPrefix' | 'idSuffix' | 'webhook'
23
+ | 'skipOversized' | 'detectConflicts' | 'verify' | 'verifyIntegrity'
24
+ | 'retries';
25
+
26
+ // =============================================================================
27
+ // Project prompts
28
+ // =============================================================================
6
29
 
7
30
  async function promptForProject(
8
31
  currentValue: string | null | undefined,
@@ -23,7 +46,7 @@ async function promptForIdModification(
23
46
  currentPrefix: string | null,
24
47
  currentSuffix: string | null
25
48
  ): Promise<{ idPrefix: string | null; idSuffix: string | null }> {
26
- console.log('\n⚠️ Source and destination are the same project.');
49
+ console.log('\nSource and destination are the same project.');
27
50
  console.log(' You need to rename collections or modify document IDs to avoid overwriting.\n');
28
51
 
29
52
  const modifyIds = await confirm({
@@ -54,30 +77,24 @@ async function promptForIdModification(
54
77
  return { idPrefix: currentPrefix, idSuffix };
55
78
  }
56
79
 
57
- console.log('\n❌ Cannot proceed: source and destination are the same without ID modification.');
80
+ console.log('\nCannot proceed: source and destination are the same without ID modification.');
58
81
  console.log(' This would overwrite your data. Use --rename-collection, --id-prefix, or --id-suffix.\n');
59
82
  process.exit(1);
60
83
  }
61
84
 
62
- export async function runInteractiveMode(config: Config): Promise<Config> {
63
- console.log('\n' + '='.repeat(SEPARATOR_LENGTH));
64
- console.log('🔄 FSCOPY - INTERACTIVE MODE');
65
- console.log('='.repeat(SEPARATOR_LENGTH) + '\n');
66
-
67
- const sourceProject = await promptForProject(config.sourceProject, 'Source Firebase project ID', '📤');
68
- const destProject = await promptForProject(config.destProject, 'Destination Firebase project ID', '📥');
69
-
70
- let idPrefix = config.idPrefix;
71
- let idSuffix = config.idSuffix;
85
+ // =============================================================================
86
+ // Collection discovery
87
+ // =============================================================================
72
88
 
73
- if (sourceProject === destProject) {
74
- const mods = await promptForIdModification(idPrefix, idSuffix);
75
- idPrefix = mods.idPrefix;
76
- idSuffix = mods.idSuffix;
77
- }
89
+ interface CollectionInfo {
90
+ id: string;
91
+ count: number;
92
+ }
78
93
 
79
- // Initialize source Firebase to list collections
80
- console.log('\n📊 Connecting to source project...');
94
+ async function discoverCollections(
95
+ sourceProject: string
96
+ ): Promise<{ app: admin.app.App; db: Firestore; collections: CollectionInfo[] }> {
97
+ console.log('\nConnecting to source project...');
81
98
 
82
99
  let tempSourceApp: admin.app.App;
83
100
  let sourceDb: Firestore;
@@ -92,12 +109,10 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
92
109
  'interactive-source'
93
110
  );
94
111
  sourceDb = tempSourceApp.firestore();
95
-
96
- // List collections (also tests connectivity)
97
112
  rootCollections = await sourceDb.listCollections();
98
113
  } catch (error) {
99
114
  const err = error as Error & { code?: string };
100
- console.error('\n❌ Cannot connect to Firebase project:', err.message);
115
+ console.error('\nCannot connect to Firebase project:', err.message);
101
116
 
102
117
  if (err.message.includes('default credentials') || err.message.includes('credential')) {
103
118
  console.error('\n Run this command to authenticate:');
@@ -114,14 +129,13 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
114
129
  const collectionIds = rootCollections.map((col) => col.id);
115
130
 
116
131
  if (collectionIds.length === 0) {
117
- console.log('\n⚠️ No collections found in source project');
132
+ console.log('\nNo collections found in source project');
118
133
  await tempSourceApp.delete();
119
134
  process.exit(0);
120
135
  }
121
136
 
122
- // Count documents in each collection for preview
123
- console.log('\n📋 Available collections:');
124
- const collectionInfo: { id: string; count: number }[] = [];
137
+ console.log('\nAvailable collections:');
138
+ const collectionInfo: CollectionInfo[] = [];
125
139
  for (const id of collectionIds) {
126
140
  const snapshot = await sourceDb.collection(id).count().get();
127
141
  const count = snapshot.data().count;
@@ -129,7 +143,462 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
129
143
  console.log(` - ${id} (${count} documents)`);
130
144
  }
131
145
 
132
- // Let user select collections
146
+ return { app: tempSourceApp, db: sourceDb, collections: collectionInfo };
147
+ }
148
+
149
+ // =============================================================================
150
+ // Advanced options
151
+ // =============================================================================
152
+
153
+ const advancedOptionChoices: Array<{ name: string; value: AdvancedOption }> = [
154
+ { name: 'Exclude subcollection patterns', value: 'exclude' },
155
+ { name: 'Where filters (filter source documents)', value: 'where' },
156
+ { name: 'Parallel transfers', value: 'parallel' },
157
+ { name: 'Batch size', value: 'batchSize' },
158
+ { name: 'Document limit per collection', value: 'limit' },
159
+ { name: 'Max subcollection depth', value: 'maxDepth' },
160
+ { name: 'Rate limit (docs/sec)', value: 'rateLimit' },
161
+ { name: 'Clear destination before transfer', value: 'clear' },
162
+ { name: 'Delete missing docs in destination (sync mode)', value: 'deleteMissing' },
163
+ { name: 'Transform file (JS/TS)', value: 'transform' },
164
+ { name: 'Rename collections in destination', value: 'renameCollection' },
165
+ { name: 'ID prefix', value: 'idPrefix' },
166
+ { name: 'ID suffix', value: 'idSuffix' },
167
+ { name: 'Webhook URL (Slack, Discord, custom)', value: 'webhook' },
168
+ { name: 'Skip oversized documents (>1MB)', value: 'skipOversized' },
169
+ { name: 'Detect conflicts during transfer', value: 'detectConflicts' },
170
+ { name: 'Verify counts after transfer', value: 'verify' },
171
+ { name: 'Verify integrity (hash comparison)', value: 'verifyIntegrity' },
172
+ { name: 'Retries on error', value: 'retries' },
173
+ ];
174
+
175
+ async function promptAdvancedOptions(config: Config): Promise<Partial<Config>> {
176
+ const wantAdvanced = await confirm({
177
+ message: 'Configure additional options?',
178
+ default: false,
179
+ });
180
+
181
+ if (!wantAdvanced) return {};
182
+
183
+ console.log('');
184
+ const selected = new Set(
185
+ await checkbox<AdvancedOption>({
186
+ message: 'Select options to configure:',
187
+ choices: advancedOptionChoices,
188
+ })
189
+ );
190
+
191
+ if (selected.size === 0) return {};
192
+
193
+ console.log('');
194
+ const updates: Partial<Config> = {};
195
+
196
+ if (selected.has('exclude')) {
197
+ const val = await input({
198
+ message: 'Exclude patterns (comma-separated, e.g. "logs, cache*, temp"):',
199
+ default: config.exclude.length > 0 ? config.exclude.join(', ') : undefined,
200
+ });
201
+ updates.exclude = parseStringList(val);
202
+ }
203
+
204
+ if (selected.has('where')) {
205
+ const filters: WhereFilter[] = [];
206
+ let addMore = true;
207
+ while (addMore) {
208
+ const filterStr = await input({
209
+ message: `Where filter${filters.length > 0 ? ' (leave empty to stop)' : ''} (e.g. "status == active"):`,
210
+ });
211
+ if (!filterStr.trim()) break;
212
+ const parsed = parseWhereFilter(filterStr);
213
+ if (parsed) {
214
+ filters.push(parsed);
215
+ console.log(` Added: ${parsed.field} ${parsed.operator} ${parsed.value}`);
216
+ }
217
+ if (filters.length > 0) {
218
+ addMore = await confirm({ message: 'Add another filter?', default: false });
219
+ }
220
+ }
221
+ if (filters.length > 0) {
222
+ updates.where = filters;
223
+ }
224
+ }
225
+
226
+ if (selected.has('parallel')) {
227
+ const val = await number({
228
+ message: 'Number of parallel collection transfers:',
229
+ default: config.parallel,
230
+ min: 1,
231
+ max: 20,
232
+ step: 1,
233
+ });
234
+ if (val !== undefined) updates.parallel = val;
235
+ }
236
+
237
+ if (selected.has('batchSize')) {
238
+ const val = await number({
239
+ message: 'Batch size (documents per write):',
240
+ default: config.batchSize,
241
+ min: 1,
242
+ max: 500,
243
+ step: 1,
244
+ });
245
+ if (val !== undefined) updates.batchSize = val;
246
+ }
247
+
248
+ if (selected.has('limit')) {
249
+ const val = await number({
250
+ message: 'Document limit per collection (0 = no limit):',
251
+ default: config.limit,
252
+ min: 0,
253
+ step: 1,
254
+ });
255
+ if (val !== undefined) updates.limit = val;
256
+ }
257
+
258
+ if (selected.has('maxDepth')) {
259
+ const val = await number({
260
+ message: 'Max subcollection depth (0 = unlimited):',
261
+ default: config.maxDepth,
262
+ min: 0,
263
+ step: 1,
264
+ });
265
+ if (val !== undefined) updates.maxDepth = val;
266
+ }
267
+
268
+ if (selected.has('rateLimit')) {
269
+ const val = await number({
270
+ message: 'Rate limit in docs/sec (0 = unlimited):',
271
+ default: config.rateLimit,
272
+ min: 0,
273
+ step: 1,
274
+ });
275
+ if (val !== undefined) updates.rateLimit = val;
276
+ }
277
+
278
+ if (selected.has('clear')) {
279
+ updates.clear = await confirm({
280
+ message: 'Clear destination collections before transfer? (DESTRUCTIVE)',
281
+ default: config.clear,
282
+ });
283
+ }
284
+
285
+ if (selected.has('deleteMissing')) {
286
+ updates.deleteMissing = await confirm({
287
+ message: 'Delete docs in destination not present in source? (sync mode)',
288
+ default: config.deleteMissing,
289
+ });
290
+ }
291
+
292
+ if (selected.has('transform')) {
293
+ const val = await input({
294
+ message: 'Path to transform file (JS/TS):',
295
+ default: config.transform ?? undefined,
296
+ validate: (value) => {
297
+ if (!value.trim()) return 'Path is required';
298
+ return true;
299
+ },
300
+ });
301
+ updates.transform = val.trim();
302
+ }
303
+
304
+ if (selected.has('renameCollection')) {
305
+ const val = await input({
306
+ message: 'Rename mappings (e.g. "users:users_backup, orders:orders_v2"):',
307
+ default: Object.entries(config.renameCollection).map(([s, d]) => `${s}:${d}`).join(', ') || undefined,
308
+ });
309
+ updates.renameCollection = parseRenameMapping(parseStringList(val));
310
+ }
311
+
312
+ if (selected.has('idPrefix')) {
313
+ const val = await input({
314
+ message: 'Document ID prefix:',
315
+ default: config.idPrefix ?? undefined,
316
+ });
317
+ updates.idPrefix = val.trim() || null;
318
+ }
319
+
320
+ if (selected.has('idSuffix')) {
321
+ const val = await input({
322
+ message: 'Document ID suffix:',
323
+ default: config.idSuffix ?? undefined,
324
+ });
325
+ updates.idSuffix = val.trim() || null;
326
+ }
327
+
328
+ if (selected.has('webhook')) {
329
+ const val = await input({
330
+ message: 'Webhook URL:',
331
+ default: config.webhook ?? undefined,
332
+ });
333
+ updates.webhook = val.trim() || null;
334
+ }
335
+
336
+ if (selected.has('skipOversized')) {
337
+ updates.skipOversized = await confirm({
338
+ message: 'Skip documents exceeding 1MB instead of failing?',
339
+ default: config.skipOversized,
340
+ });
341
+ }
342
+
343
+ if (selected.has('detectConflicts')) {
344
+ updates.detectConflicts = await confirm({
345
+ message: 'Detect destination modifications during transfer?',
346
+ default: config.detectConflicts,
347
+ });
348
+ }
349
+
350
+ if (selected.has('verify')) {
351
+ updates.verify = await confirm({
352
+ message: 'Verify document counts after transfer?',
353
+ default: config.verify,
354
+ });
355
+ }
356
+
357
+ if (selected.has('verifyIntegrity')) {
358
+ updates.verifyIntegrity = await confirm({
359
+ message: 'Verify document integrity with hash after transfer?',
360
+ default: config.verifyIntegrity,
361
+ });
362
+ }
363
+
364
+ if (selected.has('retries')) {
365
+ const val = await number({
366
+ message: 'Number of retries on error:',
367
+ default: config.retries,
368
+ min: 0,
369
+ max: 10,
370
+ step: 1,
371
+ });
372
+ if (val !== undefined) updates.retries = val;
373
+ }
374
+
375
+ return updates;
376
+ }
377
+
378
+ // =============================================================================
379
+ // Final action
380
+ // =============================================================================
381
+
382
+ async function promptFinalAction(): Promise<'execute' | 'save-ini' | 'save-json'> {
383
+ console.log('');
384
+ return select({
385
+ message: 'What would you like to do?',
386
+ choices: [
387
+ { name: 'Execute transfer', value: 'execute' as const },
388
+ { name: 'Save as INI config file', value: 'save-ini' as const },
389
+ { name: 'Save as JSON config file', value: 'save-json' as const },
390
+ ],
391
+ });
392
+ }
393
+
394
+ // =============================================================================
395
+ // Config serialization
396
+ // =============================================================================
397
+
398
+ function serializeWhereFilters(filters: WhereFilter[]): string[] {
399
+ return filters.map((f) => `${f.field} ${f.operator} ${f.value}`);
400
+ }
401
+
402
+ function serializeRenameMapping(mapping: Record<string, string>): string {
403
+ return Object.entries(mapping)
404
+ .map(([src, dest]) => `${src}:${dest}`)
405
+ .join(', ');
406
+ }
407
+
408
+ function configToJson(config: Config): string {
409
+ const output: Record<string, unknown> = {
410
+ sourceProject: config.sourceProject,
411
+ destProject: config.destProject,
412
+ collections: config.collections,
413
+ includeSubcollections: config.includeSubcollections,
414
+ dryRun: config.dryRun,
415
+ batchSize: config.batchSize,
416
+ limit: config.limit,
417
+ where: serializeWhereFilters(config.where),
418
+ exclude: config.exclude,
419
+ merge: config.merge,
420
+ parallel: config.parallel,
421
+ clear: config.clear,
422
+ deleteMissing: config.deleteMissing,
423
+ };
424
+
425
+ // Include optional fields only if set
426
+ if (config.transform) output.transform = config.transform;
427
+ if (Object.keys(config.renameCollection).length > 0) output.renameCollection = config.renameCollection;
428
+ if (config.idPrefix) output.idPrefix = config.idPrefix;
429
+ if (config.idSuffix) output.idSuffix = config.idSuffix;
430
+ if (config.webhook) output.webhook = config.webhook;
431
+ if (config.rateLimit > 0) output.rateLimit = config.rateLimit;
432
+ if (config.skipOversized) output.skipOversized = config.skipOversized;
433
+ if (config.detectConflicts) output.detectConflicts = config.detectConflicts;
434
+ if (config.maxDepth > 0) output.maxDepth = config.maxDepth;
435
+ if (config.verify) output.verify = config.verify;
436
+ if (config.verifyIntegrity) output.verifyIntegrity = config.verifyIntegrity;
437
+ if (config.retries !== 3) output.retries = config.retries;
438
+
439
+ return JSON.stringify(output, null, 4);
440
+ }
441
+
442
+ function iniLine(key: string, value: string | number | boolean): string {
443
+ return `${key} = ${value}\n`;
444
+ }
445
+
446
+ function iniComment(key: string, value: string | number | boolean): string {
447
+ return `; ${key} = ${value}\n`;
448
+ }
449
+
450
+ function configToIni(config: Config): string {
451
+ let ini = '; fscopy configuration file\n';
452
+ ini += '; Generated by interactive mode\n\n';
453
+
454
+ // [projects]
455
+ ini += '[projects]\n';
456
+ ini += iniLine('source', config.sourceProject ?? '');
457
+ ini += iniLine('dest', config.destProject ?? '');
458
+ ini += '\n';
459
+
460
+ // [transfer]
461
+ ini += '[transfer]\n';
462
+ ini += iniLine('collections', config.collections.join(', '));
463
+ ini += iniLine('includeSubcollections', config.includeSubcollections);
464
+ ini += iniLine('dryRun', config.dryRun);
465
+ ini += iniLine('batchSize', config.batchSize);
466
+ ini += iniLine('limit', config.limit);
467
+ ini += '\n';
468
+
469
+ // [options]
470
+ ini += '[options]\n';
471
+
472
+ if (config.where.length > 0) {
473
+ ini += iniLine('where', serializeWhereFilters(config.where).join(', '));
474
+ } else {
475
+ ini += iniComment('where', 'status == active');
476
+ }
477
+
478
+ if (config.exclude.length > 0) {
479
+ ini += iniLine('exclude', config.exclude.join(', '));
480
+ } else {
481
+ ini += iniComment('exclude', 'logs, temp/*, cache');
482
+ }
483
+
484
+ ini += iniLine('merge', config.merge);
485
+ ini += iniLine('parallel', config.parallel);
486
+ ini += iniLine('clear', config.clear);
487
+ ini += iniLine('deleteMissing', config.deleteMissing);
488
+
489
+ if (config.transform) {
490
+ ini += iniLine('transform', config.transform);
491
+ } else {
492
+ ini += iniComment('transform', './transforms/anonymize.ts');
493
+ }
494
+
495
+ if (Object.keys(config.renameCollection).length > 0) {
496
+ ini += iniLine('renameCollection', serializeRenameMapping(config.renameCollection));
497
+ } else {
498
+ ini += iniComment('renameCollection', 'users:users_backup, orders:orders_2024');
499
+ }
500
+
501
+ if (config.idPrefix) {
502
+ ini += iniLine('idPrefix', config.idPrefix);
503
+ } else {
504
+ ini += iniComment('idPrefix', 'backup_');
505
+ }
506
+
507
+ if (config.idSuffix) {
508
+ ini += iniLine('idSuffix', config.idSuffix);
509
+ } else {
510
+ ini += iniComment('idSuffix', '_v2');
511
+ }
512
+
513
+ if (config.webhook) {
514
+ ini += iniLine('webhook', config.webhook);
515
+ } else {
516
+ ini += iniComment('webhook', 'https://hooks.slack.com/services/...');
517
+ }
518
+
519
+ if (config.retries !== 3) {
520
+ ini += iniLine('retries', config.retries);
521
+ } else {
522
+ ini += iniComment('retries', 3);
523
+ }
524
+
525
+ if (config.rateLimit > 0) {
526
+ ini += iniLine('rateLimit', config.rateLimit);
527
+ } else {
528
+ ini += iniComment('rateLimit', 0);
529
+ }
530
+
531
+ ini += iniLine('skipOversized', config.skipOversized);
532
+ ini += iniLine('detectConflicts', config.detectConflicts);
533
+
534
+ if (config.maxDepth > 0) {
535
+ ini += iniLine('maxDepth', config.maxDepth);
536
+ } else {
537
+ ini += iniComment('maxDepth', 0);
538
+ }
539
+
540
+ ini += iniLine('verify', config.verify);
541
+ ini += iniLine('verifyIntegrity', config.verifyIntegrity);
542
+
543
+ return ini;
544
+ }
545
+
546
+ async function saveConfig(config: Config, format: 'ini' | 'json'): Promise<string> {
547
+ const defaultName = format === 'json' ? 'fscopy-config.json' : 'fscopy-config.ini';
548
+
549
+ const filePath = await input({
550
+ message: `Save path:`,
551
+ default: defaultName,
552
+ });
553
+
554
+ const resolvedPath = path.resolve(filePath);
555
+
556
+ if (fs.existsSync(resolvedPath)) {
557
+ const overwrite = await confirm({
558
+ message: `File "${filePath}" already exists. Overwrite?`,
559
+ default: false,
560
+ });
561
+ if (!overwrite) {
562
+ console.log('\nSave cancelled.\n');
563
+ process.exit(0);
564
+ }
565
+ }
566
+
567
+ const content = format === 'json' ? configToJson(config) : configToIni(config);
568
+ fs.writeFileSync(resolvedPath, content, 'utf-8');
569
+
570
+ console.log(`\nConfig saved: ${resolvedPath}`);
571
+ console.log(`\n Run with: fscopy -f ${filePath}\n`);
572
+
573
+ return resolvedPath;
574
+ }
575
+
576
+ // =============================================================================
577
+ // Main interactive flow
578
+ // =============================================================================
579
+
580
+ export async function runInteractiveMode(config: Config): Promise<InteractiveResult> {
581
+ console.log('\n' + '='.repeat(SEPARATOR_LENGTH));
582
+ console.log('FSCOPY - INTERACTIVE MODE');
583
+ console.log('='.repeat(SEPARATOR_LENGTH) + '\n');
584
+
585
+ // 1. Projects
586
+ const sourceProject = await promptForProject(config.sourceProject, 'Source Firebase project ID', '>>');
587
+ const destProject = await promptForProject(config.destProject, 'Destination Firebase project ID', '>>');
588
+
589
+ let idPrefix = config.idPrefix;
590
+ let idSuffix = config.idSuffix;
591
+
592
+ if (sourceProject === destProject) {
593
+ const mods = await promptForIdModification(idPrefix, idSuffix);
594
+ idPrefix = mods.idPrefix;
595
+ idSuffix = mods.idSuffix;
596
+ }
597
+
598
+ // 2. Discover collections
599
+ const { app: tempSourceApp, collections: collectionInfo } = await discoverCollections(sourceProject);
600
+
601
+ // 3. Select collections
133
602
  console.log('');
134
603
  const selectedCollections = await checkbox({
135
604
  message: 'Select collections to transfer:',
@@ -141,7 +610,7 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
141
610
  validate: (value) => value.length > 0 || 'Select at least one collection',
142
611
  });
143
612
 
144
- // Ask about options
613
+ // 4. Basic options
145
614
  console.log('');
146
615
  const includeSubcollections = await confirm({
147
616
  message: 'Include subcollections?',
@@ -158,11 +627,8 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
158
627
  default: config.merge,
159
628
  });
160
629
 
161
- // Clean up temporary app
162
- await tempSourceApp.delete();
163
-
164
- // Return updated config
165
- return {
630
+ // Build config so far
631
+ let finalConfig: Config = {
166
632
  ...config,
167
633
  sourceProject,
168
634
  destProject,
@@ -173,4 +639,23 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
173
639
  idPrefix,
174
640
  idSuffix,
175
641
  };
642
+
643
+ // 5. Advanced options
644
+ console.log('');
645
+ const advancedUpdates = await promptAdvancedOptions(finalConfig);
646
+ finalConfig = { ...finalConfig, ...advancedUpdates };
647
+
648
+ // Clean up temporary Firebase app
649
+ await tempSourceApp.delete();
650
+
651
+ // 6. Final action
652
+ const action = await promptFinalAction();
653
+
654
+ if (action === 'save-ini' || action === 'save-json') {
655
+ const format = action === 'save-json' ? 'json' : 'ini';
656
+ await saveConfig(finalConfig, format);
657
+ return { config: finalConfig, action: 'save' };
658
+ }
659
+
660
+ return { config: finalConfig, action: 'execute' };
176
661
  }
@@ -265,6 +265,24 @@ async function validateTransformWithSamples(
265
265
  output.blank();
266
266
  }
267
267
 
268
+ function canWriteProgress(output: Output): boolean {
269
+ return !output.isQuiet && !output.isJson && process.stdout.isTTY === true;
270
+ }
271
+
272
+ function clearLine(): void {
273
+ if (!process.stdout.isTTY) return;
274
+ const width = typeof process.stdout.columns === 'number' && process.stdout.columns > 0
275
+ ? process.stdout.columns
276
+ : SEPARATOR_LENGTH;
277
+ process.stdout.write('\r' + ' '.repeat(width) + '\r');
278
+ }
279
+
280
+ function formatNameList(names: Set<string>, max: number = 8): string {
281
+ const arr = [...names];
282
+ if (arr.length <= max) return arr.join(', ');
283
+ return arr.slice(0, max).join(', ') + `, ... (+${arr.length - max})`;
284
+ }
285
+
268
286
  async function setupProgressTracking(
269
287
  sourceDb: Firestore,
270
288
  config: Config,
@@ -276,34 +294,66 @@ async function setupProgressTracking(
276
294
 
277
295
  if (!output.isQuiet) {
278
296
  output.info('📊 Counting documents...');
279
- let lastSubcollectionLog = Date.now();
280
- let subcollectionCount = 0;
281
-
282
- const countProgress: CountProgress = {
283
- onCollection: (path, count) => {
284
- output.info(` ${path}: ${count} documents`);
285
- },
286
- onSubcollection: (_path) => {
287
- subcollectionCount++;
288
- const now = Date.now();
289
- if (now - lastSubcollectionLog > PROGRESS_LOG_INTERVAL_MS) {
290
- process.stdout.write(`\r Scanning subcollections... (${subcollectionCount} found)`);
291
- lastSubcollectionLog = now;
292
- }
293
- },
294
- };
295
297
 
296
298
  for (const collection of config.collections) {
297
- totalDocs += await countDocuments(sourceDb, collection, config, 0, countProgress);
298
- }
299
+ let rootCount = 0;
300
+ let subcollectionInstances = 0;
301
+ const subcollectionNames = new Set<string>();
302
+ const excludedNames = new Set<string>();
303
+ let lastLog = Date.now();
304
+ let showedScanLine = false;
305
+
306
+ if (canWriteProgress(output)) {
307
+ process.stdout.write(` Counting ${collection}...`);
308
+ showedScanLine = true;
309
+ }
310
+
311
+ const countProgress: CountProgress = {
312
+ onCollection: (_path, count) => {
313
+ rootCount = count;
314
+ },
315
+ onSubcollection: (path) => {
316
+ subcollectionInstances++;
317
+ const segments = path.split('/');
318
+ subcollectionNames.add(segments[segments.length - 1]);
319
+
320
+ if (!canWriteProgress(output)) return;
321
+ const now = Date.now();
322
+ if (now - lastLog > PROGRESS_LOG_INTERVAL_MS) {
323
+ process.stdout.write(`\r Scanning ${collection}... (${subcollectionInstances} subcollections found)`);
324
+ showedScanLine = true;
325
+ lastLog = now;
326
+ }
327
+ },
328
+ onSubcollectionExcluded: (name) => {
329
+ excludedNames.add(name);
330
+ },
331
+ };
332
+
333
+ const collectionTotal = await countDocuments(sourceDb, collection, config, 0, countProgress);
334
+ totalDocs += collectionTotal;
335
+
336
+ // Clear live indicator line
337
+ if (showedScanLine && canWriteProgress(output)) {
338
+ clearLine();
339
+ }
340
+
341
+ // Print collection summary
342
+ output.info(` ${collection}: ${rootCount} documents`);
299
343
 
300
- if (subcollectionCount > 0) {
301
- process.stdout.write('\r' + ' '.repeat(SEPARATOR_LENGTH) + '\r');
302
- output.info(` Subcollections scanned: ${subcollectionCount}`);
344
+ if (subcollectionInstances > 0) {
345
+ const subDocs = collectionTotal - rootCount;
346
+ output.info(` + ${subDocs} in subcollections (${formatNameList(subcollectionNames)})`);
347
+ }
348
+ if (excludedNames.size > 0) {
349
+ output.info(` Excluded: ${formatNameList(excludedNames)}`);
350
+ }
303
351
  }
304
- output.info(` Total: ${totalDocs} documents to transfer\n`);
305
352
 
306
- progressBar.start(totalDocs, stats);
353
+ output.info(`\n Total: ${totalDocs} documents to transfer\n`);
354
+ if (canWriteProgress(output)) {
355
+ progressBar.start(totalDocs, stats);
356
+ }
307
357
  }
308
358
 
309
359
  return { totalDocs, progressBar };
@@ -376,14 +426,17 @@ async function deleteOrphanDocs(
376
426
  let lastProgressLog = Date.now();
377
427
  let subcollectionCount = 0;
378
428
 
429
+ const showProgress = canWriteProgress(output);
430
+
379
431
  const progress: DeleteOrphansProgress = {
380
432
  onScanStart: (collection) => {
381
- process.stdout.write(` Scanning ${collection}...`);
433
+ if (showProgress) process.stdout.write(` Scanning ${collection}...`);
382
434
  },
383
435
  onScanComplete: (collection, orphanCount, totalDest) => {
384
- process.stdout.write(`\r ${collection}: ${orphanCount}/${totalDest} orphan docs\n`);
436
+ if (showProgress) process.stdout.write(`\r ${collection}: ${orphanCount}/${totalDest} orphan docs\n`);
385
437
  },
386
438
  onBatchDeleted: (collection, deletedSoFar, total) => {
439
+ if (!showProgress) return;
387
440
  process.stdout.write(`\r Deleting from ${collection}... ${deletedSoFar}/${total}`);
388
441
  if (deletedSoFar === total) {
389
442
  process.stdout.write('\n');
@@ -391,6 +444,7 @@ async function deleteOrphanDocs(
391
444
  },
392
445
  onSubcollectionScan: (_path) => {
393
446
  subcollectionCount++;
447
+ if (!showProgress) return;
394
448
  const now = Date.now();
395
449
  if (now - lastProgressLog > PROGRESS_LOG_INTERVAL_MS) {
396
450
  process.stdout.write(`\r Scanning subcollections... (${subcollectionCount} checked)`);
@@ -411,8 +465,8 @@ async function deleteOrphanDocs(
411
465
  stats.documentsDeleted += deleted;
412
466
  }
413
467
 
414
- if (subcollectionCount > 0) {
415
- process.stdout.write('\r' + ' '.repeat(SEPARATOR_LENGTH) + '\r');
468
+ if (subcollectionCount > 0 && showProgress) {
469
+ clearLine();
416
470
  }
417
471
 
418
472
  if (stats.documentsDeleted > 0) {
@@ -6,6 +6,7 @@ import { getSubcollections } from './helpers.js';
6
6
  export interface CountProgress {
7
7
  onCollection?: (path: string, count: number) => void;
8
8
  onSubcollection?: (path: string) => void;
9
+ onSubcollectionExcluded?: (name: string) => void;
9
10
  }
10
11
 
11
12
  function buildQueryWithFilters(
@@ -65,11 +66,19 @@ async function countSubcollectionsForDoc(
65
66
  depth: number,
66
67
  progress?: CountProgress
67
68
  ): Promise<number> {
69
+ // Respect maxDepth to match transfer behavior
70
+ if (config.maxDepth > 0 && depth >= config.maxDepth) return 0;
71
+
68
72
  let count = 0;
69
73
  const subcollections = await getSubcollections(doc.ref);
70
74
 
71
75
  for (const subId of subcollections) {
72
- if (matchesExcludePattern(subId, config.exclude)) continue;
76
+ if (matchesExcludePattern(subId, config.exclude)) {
77
+ if (progress?.onSubcollectionExcluded) {
78
+ progress.onSubcollectionExcluded(subId);
79
+ }
80
+ continue;
81
+ }
73
82
 
74
83
  const subPath = `${collectionPath}/${doc.id}/${subId}`;
75
84
  if (progress?.onSubcollection) {
@@ -101,7 +101,7 @@ async function checkForConflicts(
101
101
  return conflicts;
102
102
  }
103
103
 
104
- function buildTransferQuery(
104
+ function buildBaseQuery(
105
105
  sourceDb: Firestore,
106
106
  collectionPath: string,
107
107
  config: Config,
@@ -115,10 +115,7 @@ function buildTransferQuery(
115
115
  }
116
116
  }
117
117
 
118
- if (config.limit > 0 && depth === 0) {
119
- query = query.limit(config.limit);
120
- }
121
-
118
+ // Limit is handled via pagination in transferCollection
122
119
  return query;
123
120
  }
124
121
 
@@ -533,25 +530,50 @@ export async function transferCollection(
533
530
  const { sourceDb, config, stats, output } = ctx;
534
531
  const destCollectionPath = getDestCollectionPath(collectionPath, config.renameCollection);
535
532
 
536
- const query = buildTransferQuery(sourceDb, collectionPath, config, depth);
533
+ const baseQuery = buildBaseQuery(sourceDb, collectionPath, config, depth);
534
+ const userLimit = config.limit > 0 && depth === 0 ? config.limit : 0;
537
535
 
538
- const snapshot = await withRetry(() => query.get(), {
539
- retries: config.retries,
540
- onRetry: (attempt, max, err, delay) => {
541
- output.logError(`Retry ${attempt}/${max} for ${collectionPath}`, {
542
- error: err.message,
543
- delay,
544
- });
545
- },
546
- });
536
+ let totalProcessed = 0;
537
+ let lastDoc: QueryDocumentSnapshot | undefined;
538
+
539
+ while (true) {
540
+ // Calculate page size respecting user limit
541
+ let pageSize = config.batchSize;
542
+ if (userLimit > 0) {
543
+ const remaining = userLimit - totalProcessed;
544
+ if (remaining <= 0) break;
545
+ pageSize = Math.min(pageSize, remaining);
546
+ }
547
+
548
+ // Build paginated query
549
+ let pageQuery = baseQuery.limit(pageSize);
550
+ if (lastDoc) {
551
+ pageQuery = pageQuery.startAfter(lastDoc);
552
+ }
553
+
554
+ const snapshot = await withRetry(() => pageQuery.get(), {
555
+ retries: config.retries,
556
+ onRetry: (attempt, max, err, delay) => {
557
+ output.logError(`Retry ${attempt}/${max} for ${collectionPath}`, {
558
+ error: err.message,
559
+ delay,
560
+ });
561
+ },
562
+ });
563
+
564
+ if (snapshot.empty) break;
565
+
566
+ if (totalProcessed === 0) {
567
+ stats.collectionsProcessed++;
568
+ output.logInfo(`Processing collection: ${collectionPath}`);
569
+ }
547
570
 
548
- if (snapshot.empty) return;
571
+ await processBatch(snapshot.docs, ctx, collectionPath, destCollectionPath, depth);
549
572
 
550
- stats.collectionsProcessed++;
551
- output.logInfo(`Processing collection: ${collectionPath}`, { documents: snapshot.size });
573
+ totalProcessed += snapshot.docs.length;
574
+ lastDoc = snapshot.docs[snapshot.docs.length - 1];
552
575
 
553
- for (let i = 0; i < snapshot.docs.length; i += config.batchSize) {
554
- const batch = snapshot.docs.slice(i, i + config.batchSize);
555
- await processBatch(batch, ctx, collectionPath, destCollectionPath, depth);
576
+ // Fewer docs than requested means we've reached the end
577
+ if (snapshot.docs.length < pageSize) break;
556
578
  }
557
579
  }
package/src/types.ts CHANGED
@@ -97,7 +97,7 @@ export interface CliArgs {
97
97
  yes: boolean;
98
98
  log?: string;
99
99
  maxLogSize?: string;
100
- retries: number;
100
+ retries?: number;
101
101
  quiet: boolean;
102
102
  where?: string[];
103
103
  exclude?: string[];