@fazetitans/fscopy 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,10 +33,14 @@ Transfer documents between Firebase projects with support for subcollections, fi
33
33
  - **Webhook notifications** - Send Slack, Discord, or custom webhooks on completion
34
34
  - **Resume transfers** - Continue interrupted transfers from saved state
35
35
  - **Interactive mode** - Guided setup with prompts for project and collection selection
36
- - **Progress bar** - Real-time progress with ETA
36
+ - **Progress bar** - Real-time progress with speed (docs/s) and ETA
37
37
  - **Automatic retry** - Exponential backoff on network errors
38
38
  - **Dry run mode** - Preview changes before applying (enabled by default)
39
39
  - **Flexible config** - INI, JSON, or CLI arguments
40
+ - **Rate limiting** - Control transfer speed to avoid quota issues
41
+ - **Size validation** - Skip oversized documents (>1MB)
42
+ - **JSON output** - Machine-readable output for CI/CD pipelines
43
+ - **Post-transfer verification** - Verify document counts after transfer
40
44
 
41
45
  ## Installation
42
46
 
@@ -158,6 +162,18 @@ fscopy -f config.ini --webhook https://hooks.slack.com/services/...
158
162
 
159
163
  # Resume an interrupted transfer
160
164
  fscopy -f config.ini --resume
165
+
166
+ # Verify document counts after transfer
167
+ fscopy -f config.ini --verify
168
+
169
+ # Rate limit to 100 docs/second (avoid quota issues)
170
+ fscopy -f config.ini --rate-limit 100
171
+
172
+ # Skip documents larger than 1MB
173
+ fscopy -f config.ini --skip-oversized
174
+
175
+ # JSON output for CI/CD pipelines
176
+ fscopy -f config.ini --json
161
177
  ```
162
178
 
163
179
  ### Collection Renaming
@@ -364,6 +380,10 @@ fscopy --init config.json
364
380
  | `--webhook` | | string | | Webhook URL for notifications |
365
381
  | `--resume` | | boolean | `false` | Resume from saved state |
366
382
  | `--state-file` | | string | `.fscopy-state.json` | State file path |
383
+ | `--verify` | | boolean | `false` | Verify counts after transfer |
384
+ | `--rate-limit` | | number | `0` | Limit docs/second (0 = unlimited) |
385
+ | `--skip-oversized` | | boolean | `false` | Skip documents > 1MB |
386
+ | `--json` | | boolean | `false` | JSON output for CI/CD |
367
387
 
368
388
  ## How It Works
369
389
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fazetitans/fscopy",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
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": {
@@ -46,7 +46,8 @@
46
46
  "node": ">=18.0.0"
47
47
  },
48
48
  "files": [
49
- "src/cli.ts",
49
+ "src/**/*.ts",
50
+ "!src/__tests__/**",
50
51
  "README.md",
51
52
  "LICENSE"
52
53
  ],
@@ -0,0 +1,88 @@
1
+ import type { Config } from '../types.js';
2
+
3
+ export const defaults: Config = {
4
+ collections: [],
5
+ includeSubcollections: false,
6
+ dryRun: true,
7
+ batchSize: 500,
8
+ limit: 0,
9
+ sourceProject: null,
10
+ destProject: null,
11
+ retries: 3,
12
+ where: [],
13
+ exclude: [],
14
+ merge: false,
15
+ parallel: 1,
16
+ clear: false,
17
+ deleteMissing: false,
18
+ transform: null,
19
+ renameCollection: {},
20
+ idPrefix: null,
21
+ idSuffix: null,
22
+ webhook: null,
23
+ resume: false,
24
+ stateFile: '.fscopy-state.json',
25
+ verify: false,
26
+ rateLimit: 0,
27
+ skipOversized: false,
28
+ json: false,
29
+ };
30
+
31
+ export const iniTemplate = `; fscopy configuration file
32
+
33
+ [projects]
34
+ source = my-source-project
35
+ dest = my-dest-project
36
+
37
+ [transfer]
38
+ ; Comma-separated list of collections
39
+ collections = collection1, collection2
40
+ includeSubcollections = false
41
+ dryRun = true
42
+ batchSize = 500
43
+ limit = 0
44
+
45
+ [options]
46
+ ; Filter documents: "field operator value" (operators: ==, !=, <, >, <=, >=)
47
+ ; where = status == active
48
+ ; Exclude subcollections by pattern (comma-separated, supports glob)
49
+ ; exclude = logs, temp/*, cache
50
+ ; Merge documents instead of overwriting
51
+ merge = false
52
+ ; Number of parallel collection transfers
53
+ parallel = 1
54
+ ; Clear destination collections before transfer (DESTRUCTIVE)
55
+ clear = false
56
+ ; Delete destination docs not present in source (sync mode)
57
+ deleteMissing = false
58
+ ; Transform documents during transfer (path to JS/TS file)
59
+ ; transform = ./transforms/anonymize.ts
60
+ ; Rename collections in destination (format: source:dest, comma-separated)
61
+ ; renameCollection = users:users_backup, orders:orders_2024
62
+ ; Add prefix or suffix to document IDs
63
+ ; idPrefix = backup_
64
+ ; idSuffix = _v2
65
+ ; Webhook URL for transfer notifications (Slack, Discord, or custom)
66
+ ; webhook = https://hooks.slack.com/services/...
67
+ `;
68
+
69
+ export const jsonTemplate = {
70
+ sourceProject: 'my-source-project',
71
+ destProject: 'my-dest-project',
72
+ collections: ['collection1', 'collection2'],
73
+ includeSubcollections: false,
74
+ dryRun: true,
75
+ batchSize: 500,
76
+ limit: 0,
77
+ where: [],
78
+ exclude: [],
79
+ merge: false,
80
+ parallel: 1,
81
+ clear: false,
82
+ deleteMissing: false,
83
+ transform: null,
84
+ renameCollection: {},
85
+ idPrefix: null,
86
+ idSuffix: null,
87
+ webhook: null,
88
+ };
@@ -0,0 +1,27 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getFileFormat } from './parser.js';
4
+ import { iniTemplate, jsonTemplate } from './defaults.js';
5
+
6
+ export function generateConfigFile(outputPath: string): boolean {
7
+ const filePath = path.resolve(outputPath);
8
+ const format = getFileFormat(filePath);
9
+
10
+ if (fs.existsSync(filePath)) {
11
+ console.error(`āŒ File already exists: ${filePath}`);
12
+ console.error(' Use a different filename or delete the existing file.');
13
+ process.exitCode = 1;
14
+ return false;
15
+ }
16
+
17
+ const content = format === 'json' ? JSON.stringify(jsonTemplate, null, 4) : iniTemplate;
18
+
19
+ fs.writeFileSync(filePath, content, 'utf-8');
20
+
21
+ console.log(`āœ“ Config template created: ${filePath}`);
22
+ console.log('');
23
+ console.log('Edit the file to configure your transfer, then run:');
24
+ console.log(` fscopy -f ${outputPath}`);
25
+
26
+ return true;
27
+ }
@@ -0,0 +1,15 @@
1
+ export { defaults, iniTemplate, jsonTemplate } from './defaults.js';
2
+ export {
3
+ getFileFormat,
4
+ parseBoolean,
5
+ parseWhereFilter,
6
+ parseWhereFilters,
7
+ parseStringList,
8
+ parseRenameMapping,
9
+ parseIniConfig,
10
+ parseJsonConfig,
11
+ loadConfigFile,
12
+ mergeConfig,
13
+ } from './parser.js';
14
+ export { validateConfig } from './validator.js';
15
+ export { generateConfigFile } from './generator.js';
@@ -0,0 +1,261 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import ini from 'ini';
4
+ import type { Config, WhereFilter, CliArgs } from '../types.js';
5
+
6
+ export function getFileFormat(filePath: string): 'json' | 'ini' {
7
+ const ext = path.extname(filePath).toLowerCase();
8
+ if (ext === '.json') return 'json';
9
+ return 'ini';
10
+ }
11
+
12
+ export function parseBoolean(val: unknown): boolean {
13
+ if (typeof val === 'boolean') return val;
14
+ if (typeof val === 'string') {
15
+ return val.toLowerCase() === 'true';
16
+ }
17
+ return false;
18
+ }
19
+
20
+ export function parseWhereFilter(filterStr: string): WhereFilter | null {
21
+ const operatorRegex = /(==|!=|<=|>=|<|>)/;
22
+ const match = new RegExp(operatorRegex).exec(filterStr);
23
+
24
+ if (!match) {
25
+ console.warn(`āš ļø Invalid where filter: "${filterStr}" (missing operator)`);
26
+ return null;
27
+ }
28
+
29
+ const operator = match[0] as FirebaseFirestore.WhereFilterOp;
30
+ const [fieldPart, valuePart] = filterStr.split(operatorRegex).filter((_, i) => i !== 1);
31
+
32
+ if (!fieldPart || !valuePart) {
33
+ console.warn(`āš ļø Invalid where filter: "${filterStr}" (missing field or value)`);
34
+ return null;
35
+ }
36
+
37
+ const field = fieldPart.trim();
38
+ const rawValue = valuePart.trim();
39
+
40
+ let value: string | number | boolean;
41
+ if (rawValue === 'true') {
42
+ value = true;
43
+ } else if (rawValue === 'false') {
44
+ value = false;
45
+ } else if (rawValue === 'null') {
46
+ value = null as unknown as string;
47
+ } else if (!Number.isNaN(Number(rawValue)) && rawValue !== '') {
48
+ value = Number(rawValue);
49
+ } else {
50
+ value = rawValue.replaceAll(/(?:^["'])|(?:["']$)/g, '');
51
+ }
52
+
53
+ return { field, operator, value };
54
+ }
55
+
56
+ export function parseWhereFilters(filters: string[] | undefined): WhereFilter[] {
57
+ if (!filters || filters.length === 0) return [];
58
+ return filters.map(parseWhereFilter).filter((f): f is WhereFilter => f !== null);
59
+ }
60
+
61
+ export function parseStringList(value: string | undefined): string[] {
62
+ if (!value) return [];
63
+ return value
64
+ .split(',')
65
+ .map((s) => s.trim())
66
+ .filter((s) => s.length > 0);
67
+ }
68
+
69
+ export function parseRenameMapping(
70
+ mappings: string[] | string | undefined
71
+ ): Record<string, string> {
72
+ if (!mappings) return {};
73
+
74
+ const result: Record<string, string> = {};
75
+ const items = Array.isArray(mappings) ? mappings : parseStringList(mappings);
76
+
77
+ for (const item of items) {
78
+ const mapping = String(item).trim();
79
+ const colonIndex = mapping.indexOf(':');
80
+ if (colonIndex === -1) {
81
+ console.warn(`āš ļø Invalid rename mapping: "${mapping}" (missing ':')`);
82
+ continue;
83
+ }
84
+ const source = mapping.slice(0, colonIndex).trim();
85
+ const dest = mapping.slice(colonIndex + 1).trim();
86
+ if (!source || !dest) {
87
+ console.warn(`āš ļø Invalid rename mapping: "${mapping}" (empty source or dest)`);
88
+ continue;
89
+ }
90
+ result[source] = dest;
91
+ }
92
+
93
+ return result;
94
+ }
95
+
96
+ export function parseIniConfig(content: string): Partial<Config> {
97
+ const parsed = ini.parse(content) as {
98
+ projects?: { source?: string; dest?: string };
99
+ transfer?: {
100
+ collections?: string;
101
+ includeSubcollections?: string | boolean;
102
+ dryRun?: string | boolean;
103
+ batchSize?: string;
104
+ limit?: string;
105
+ };
106
+ options?: {
107
+ where?: string;
108
+ exclude?: string;
109
+ merge?: string | boolean;
110
+ parallel?: string;
111
+ clear?: string | boolean;
112
+ deleteMissing?: string | boolean;
113
+ transform?: string;
114
+ renameCollection?: string;
115
+ idPrefix?: string;
116
+ idSuffix?: string;
117
+ webhook?: string;
118
+ };
119
+ };
120
+
121
+ let collections: string[] = [];
122
+ if (parsed.transfer?.collections) {
123
+ collections = parsed.transfer.collections
124
+ .split(',')
125
+ .map((c) => c.trim())
126
+ .filter((c) => c.length > 0);
127
+ }
128
+
129
+ const whereFilters = parsed.options?.where
130
+ ? parseWhereFilters(parseStringList(parsed.options.where))
131
+ : [];
132
+
133
+ return {
134
+ sourceProject: parsed.projects?.source ?? null,
135
+ destProject: parsed.projects?.dest ?? null,
136
+ collections,
137
+ includeSubcollections: parseBoolean(parsed.transfer?.includeSubcollections),
138
+ dryRun: parseBoolean(parsed.transfer?.dryRun ?? 'true'),
139
+ batchSize: Number.parseInt(parsed.transfer?.batchSize ?? '', 10) || 500,
140
+ limit: Number.parseInt(parsed.transfer?.limit ?? '', 10) || 0,
141
+ where: whereFilters,
142
+ exclude: parseStringList(parsed.options?.exclude),
143
+ merge: parseBoolean(parsed.options?.merge),
144
+ parallel: Number.parseInt(parsed.options?.parallel ?? '', 10) || 1,
145
+ clear: parseBoolean(parsed.options?.clear),
146
+ deleteMissing: parseBoolean(parsed.options?.deleteMissing),
147
+ transform: parsed.options?.transform ?? null,
148
+ renameCollection: parseRenameMapping(parsed.options?.renameCollection),
149
+ idPrefix: parsed.options?.idPrefix ?? null,
150
+ idSuffix: parsed.options?.idSuffix ?? null,
151
+ webhook: parsed.options?.webhook ?? null,
152
+ };
153
+ }
154
+
155
+ export function parseJsonConfig(content: string): Partial<Config> {
156
+ const config = JSON.parse(content) as {
157
+ sourceProject?: string;
158
+ destProject?: string;
159
+ collections?: string[];
160
+ includeSubcollections?: boolean;
161
+ dryRun?: boolean;
162
+ batchSize?: number;
163
+ limit?: number;
164
+ where?: string[];
165
+ exclude?: string[];
166
+ merge?: boolean;
167
+ parallel?: number;
168
+ clear?: boolean;
169
+ deleteMissing?: boolean;
170
+ transform?: string;
171
+ renameCollection?: Record<string, string>;
172
+ idPrefix?: string;
173
+ idSuffix?: string;
174
+ webhook?: string;
175
+ };
176
+
177
+ return {
178
+ sourceProject: config.sourceProject ?? null,
179
+ destProject: config.destProject ?? null,
180
+ collections: config.collections,
181
+ includeSubcollections: config.includeSubcollections,
182
+ dryRun: config.dryRun,
183
+ batchSize: config.batchSize,
184
+ limit: config.limit,
185
+ where: parseWhereFilters(config.where),
186
+ exclude: config.exclude,
187
+ merge: config.merge,
188
+ parallel: config.parallel,
189
+ clear: config.clear,
190
+ deleteMissing: config.deleteMissing,
191
+ transform: config.transform ?? null,
192
+ renameCollection: config.renameCollection ?? {},
193
+ idPrefix: config.idPrefix ?? null,
194
+ idSuffix: config.idSuffix ?? null,
195
+ webhook: config.webhook ?? null,
196
+ };
197
+ }
198
+
199
+ export function loadConfigFile(configPath?: string): Partial<Config> {
200
+ if (!configPath) return {};
201
+
202
+ const absolutePath = path.resolve(configPath);
203
+ if (!fs.existsSync(absolutePath)) {
204
+ throw new Error(`Config file not found: ${absolutePath}`);
205
+ }
206
+
207
+ const content = fs.readFileSync(absolutePath, 'utf-8');
208
+ const format = getFileFormat(absolutePath);
209
+
210
+ console.log(`šŸ“„ Loaded config from: ${absolutePath} (${format.toUpperCase()})\n`);
211
+
212
+ return format === 'json' ? parseJsonConfig(content) : parseIniConfig(content);
213
+ }
214
+
215
+ export function mergeConfig(
216
+ defaultConfig: Config,
217
+ fileConfig: Partial<Config>,
218
+ cliArgs: CliArgs
219
+ ): Config {
220
+ const cliWhereFilters = parseWhereFilters(cliArgs.where);
221
+ const cliRenameCollection = parseRenameMapping(cliArgs.renameCollection);
222
+
223
+ return {
224
+ collections: cliArgs.collections ?? fileConfig.collections ?? defaultConfig.collections,
225
+ includeSubcollections:
226
+ cliArgs.includeSubcollections ??
227
+ fileConfig.includeSubcollections ??
228
+ defaultConfig.includeSubcollections,
229
+ dryRun: cliArgs.dryRun ?? fileConfig.dryRun ?? defaultConfig.dryRun,
230
+ batchSize: cliArgs.batchSize ?? fileConfig.batchSize ?? defaultConfig.batchSize,
231
+ limit: cliArgs.limit ?? fileConfig.limit ?? defaultConfig.limit,
232
+ sourceProject:
233
+ cliArgs.sourceProject ?? fileConfig.sourceProject ?? defaultConfig.sourceProject,
234
+ destProject: cliArgs.destProject ?? fileConfig.destProject ?? defaultConfig.destProject,
235
+ retries: cliArgs.retries ?? defaultConfig.retries,
236
+ where:
237
+ cliWhereFilters.length > 0
238
+ ? cliWhereFilters
239
+ : (fileConfig.where ?? defaultConfig.where),
240
+ exclude: cliArgs.exclude ?? fileConfig.exclude ?? defaultConfig.exclude,
241
+ merge: cliArgs.merge ?? fileConfig.merge ?? defaultConfig.merge,
242
+ parallel: cliArgs.parallel ?? fileConfig.parallel ?? defaultConfig.parallel,
243
+ clear: cliArgs.clear ?? fileConfig.clear ?? defaultConfig.clear,
244
+ deleteMissing:
245
+ cliArgs.deleteMissing ?? fileConfig.deleteMissing ?? defaultConfig.deleteMissing,
246
+ transform: cliArgs.transform ?? fileConfig.transform ?? defaultConfig.transform,
247
+ renameCollection:
248
+ Object.keys(cliRenameCollection).length > 0
249
+ ? cliRenameCollection
250
+ : (fileConfig.renameCollection ?? defaultConfig.renameCollection),
251
+ idPrefix: cliArgs.idPrefix ?? fileConfig.idPrefix ?? defaultConfig.idPrefix,
252
+ idSuffix: cliArgs.idSuffix ?? fileConfig.idSuffix ?? defaultConfig.idSuffix,
253
+ webhook: cliArgs.webhook ?? fileConfig.webhook ?? defaultConfig.webhook,
254
+ resume: cliArgs.resume ?? defaultConfig.resume,
255
+ stateFile: cliArgs.stateFile ?? defaultConfig.stateFile,
256
+ verify: cliArgs.verify ?? defaultConfig.verify,
257
+ rateLimit: cliArgs.rateLimit ?? defaultConfig.rateLimit,
258
+ skipOversized: cliArgs.skipOversized ?? defaultConfig.skipOversized,
259
+ json: cliArgs.json ?? defaultConfig.json,
260
+ };
261
+ }
@@ -0,0 +1,29 @@
1
+ import type { Config } from '../types.js';
2
+
3
+ export function validateConfig(config: Config): string[] {
4
+ const errors: string[] = [];
5
+
6
+ if (!config.sourceProject) {
7
+ errors.push('Source project is required (--source-project or in config file)');
8
+ }
9
+ if (!config.destProject) {
10
+ errors.push('Destination project is required (--dest-project or in config file)');
11
+ }
12
+ if (config.sourceProject && config.destProject && config.sourceProject === config.destProject) {
13
+ // Same project is allowed only if we're renaming collections or modifying IDs
14
+ const hasRenamedCollections = Object.keys(config.renameCollection).length > 0;
15
+ const hasIdModification = config.idPrefix !== null || config.idSuffix !== null;
16
+
17
+ if (!hasRenamedCollections && !hasIdModification) {
18
+ errors.push(
19
+ 'Source and destination projects are the same. ' +
20
+ 'Use --rename-collection or --id-prefix/--id-suffix to avoid overwriting data.'
21
+ );
22
+ }
23
+ }
24
+ if (!config.collections || config.collections.length === 0) {
25
+ errors.push('At least one collection is required (-c or --collections)');
26
+ }
27
+
28
+ return errors;
29
+ }
@@ -0,0 +1,172 @@
1
+ import admin from 'firebase-admin';
2
+ import type { Firestore } from 'firebase-admin/firestore';
3
+ import { input, checkbox, confirm } from '@inquirer/prompts';
4
+ import type { Config } from './types.js';
5
+
6
+ export async function runInteractiveMode(config: Config): Promise<Config> {
7
+ console.log('\n' + '='.repeat(60));
8
+ console.log('šŸ”„ FSCOPY - INTERACTIVE MODE');
9
+ console.log('='.repeat(60) + '\n');
10
+
11
+ // Prompt for source project if not set
12
+ let sourceProject = config.sourceProject;
13
+ if (!sourceProject) {
14
+ sourceProject = await input({
15
+ message: 'Source Firebase project ID:',
16
+ validate: (value) => value.length > 0 || 'Project ID is required',
17
+ });
18
+ } else {
19
+ console.log(`šŸ“¤ Source project: ${sourceProject}`);
20
+ }
21
+
22
+ // Prompt for destination project if not set
23
+ let destProject = config.destProject;
24
+ if (!destProject) {
25
+ destProject = await input({
26
+ message: 'Destination Firebase project ID:',
27
+ validate: (value) => value.length > 0 || 'Project ID is required',
28
+ });
29
+ } else {
30
+ console.log(`šŸ“„ Destination project: ${destProject}`);
31
+ }
32
+
33
+ // If source = destination, ask for rename/id modifications
34
+ const renameCollection = config.renameCollection;
35
+ let idPrefix = config.idPrefix;
36
+ let idSuffix = config.idSuffix;
37
+
38
+ if (sourceProject === destProject) {
39
+ console.log('\nāš ļø Source and destination are the same project.');
40
+ console.log(' You need to rename collections or modify document IDs to avoid overwriting.\n');
41
+
42
+ const modifyIds = await confirm({
43
+ message: 'Add a prefix to document IDs?',
44
+ default: true,
45
+ });
46
+
47
+ if (modifyIds) {
48
+ idPrefix = await input({
49
+ message: 'Document ID prefix (e.g., "backup_"):',
50
+ default: 'backup_',
51
+ validate: (value) => value.length > 0 || 'Prefix is required',
52
+ });
53
+ } else {
54
+ // Ask for suffix as alternative
55
+ const useSuffix = await confirm({
56
+ message: 'Add a suffix to document IDs instead?',
57
+ default: true,
58
+ });
59
+
60
+ if (useSuffix) {
61
+ idSuffix = await input({
62
+ message: 'Document ID suffix (e.g., "_backup"):',
63
+ default: '_backup',
64
+ validate: (value) => value.length > 0 || 'Suffix is required',
65
+ });
66
+ } else {
67
+ console.log('\nāŒ Cannot proceed: source and destination are the same without ID modification.');
68
+ console.log(' This would overwrite your data. Use --rename-collection, --id-prefix, or --id-suffix.\n');
69
+ process.exit(1);
70
+ }
71
+ }
72
+ }
73
+
74
+ // Initialize source Firebase to list collections
75
+ console.log('\nšŸ“Š Connecting to source project...');
76
+
77
+ let tempSourceApp: admin.app.App;
78
+ let sourceDb: Firestore;
79
+ let rootCollections: FirebaseFirestore.CollectionReference[];
80
+
81
+ try {
82
+ tempSourceApp = admin.initializeApp(
83
+ {
84
+ credential: admin.credential.applicationDefault(),
85
+ projectId: sourceProject,
86
+ },
87
+ 'interactive-source'
88
+ );
89
+ sourceDb = tempSourceApp.firestore();
90
+
91
+ // List collections (also tests connectivity)
92
+ rootCollections = await sourceDb.listCollections();
93
+ } catch (error) {
94
+ const err = error as Error & { code?: string };
95
+ console.error('\nāŒ Cannot connect to Firebase project:', err.message);
96
+
97
+ if (err.message.includes('default credentials') || err.message.includes('credential')) {
98
+ console.error('\n Run this command to authenticate:');
99
+ console.error(' gcloud auth application-default login\n');
100
+ } else if (err.message.includes('not found') || err.message.includes('NOT_FOUND')) {
101
+ console.error(`\n Project "${sourceProject}" not found. Check the project ID.\n`);
102
+ } else if (err.message.includes('permission') || err.message.includes('PERMISSION_DENIED')) {
103
+ console.error('\n You don\'t have permission to access this project\'s Firestore.\n');
104
+ }
105
+
106
+ process.exit(1);
107
+ }
108
+
109
+ const collectionIds = rootCollections.map((col) => col.id);
110
+
111
+ if (collectionIds.length === 0) {
112
+ console.log('\nāš ļø No collections found in source project');
113
+ await tempSourceApp.delete();
114
+ process.exit(0);
115
+ }
116
+
117
+ // Count documents in each collection for preview
118
+ console.log('\nšŸ“‹ Available collections:');
119
+ const collectionInfo: { id: string; count: number }[] = [];
120
+ for (const id of collectionIds) {
121
+ const snapshot = await sourceDb.collection(id).count().get();
122
+ const count = snapshot.data().count;
123
+ collectionInfo.push({ id, count });
124
+ console.log(` - ${id} (${count} documents)`);
125
+ }
126
+
127
+ // Let user select collections
128
+ console.log('');
129
+ const selectedCollections = await checkbox({
130
+ message: 'Select collections to transfer:',
131
+ choices: collectionInfo.map((col) => ({
132
+ name: `${col.id} (${col.count} docs)`,
133
+ value: col.id,
134
+ checked: config.collections.includes(col.id),
135
+ })),
136
+ validate: (value) => value.length > 0 || 'Select at least one collection',
137
+ });
138
+
139
+ // Ask about options
140
+ console.log('');
141
+ const includeSubcollections = await confirm({
142
+ message: 'Include subcollections?',
143
+ default: config.includeSubcollections,
144
+ });
145
+
146
+ const dryRun = await confirm({
147
+ message: 'Dry run mode (preview without writing)?',
148
+ default: config.dryRun,
149
+ });
150
+
151
+ const merge = await confirm({
152
+ message: 'Merge mode (update instead of overwrite)?',
153
+ default: config.merge,
154
+ });
155
+
156
+ // Clean up temporary app
157
+ await tempSourceApp.delete();
158
+
159
+ // Return updated config
160
+ return {
161
+ ...config,
162
+ sourceProject,
163
+ destProject,
164
+ collections: selectedCollections,
165
+ includeSubcollections,
166
+ dryRun,
167
+ merge,
168
+ renameCollection,
169
+ idPrefix,
170
+ idSuffix,
171
+ };
172
+ }