@fazetitans/fscopy 1.1.1 → 1.1.3

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
@@ -237,6 +237,8 @@ The transform function receives:
237
237
 
238
238
  Return the transformed document, or `null` to skip it.
239
239
 
240
+ > **Security Warning**: The `--transform` option executes arbitrary code from the specified file. Only use transform files from trusted sources. Never run transforms from untrusted or unverified files as they have full access to your system.
241
+
240
242
  ### Webhook Notifications
241
243
 
242
244
  Get notified when transfers complete (success or failure):
@@ -393,6 +395,14 @@ fscopy --init config.json
393
395
  4. **Retry logic** - Automatic retry with exponential backoff on failures
394
396
  5. **Subcollection discovery** - Uses `listCollections()` to find nested data
395
397
 
398
+ ## Security
399
+
400
+ - **Transform files execute arbitrary code** - The `--transform` option uses dynamic imports to load and execute JavaScript/TypeScript files. Only use transform files you have written or thoroughly reviewed. Malicious transform files could access your filesystem, network, or credentials.
401
+
402
+ - **Webhook URLs should use HTTPS** - fscopy warns if you use HTTP webhooks (except localhost). Webhook payloads contain project names and transfer statistics that could be sensitive.
403
+
404
+ - **Credentials via ADC** - fscopy uses Google Application Default Credentials. Ensure you're authenticated with the correct account before running transfers.
405
+
396
406
  ## Notes
397
407
 
398
408
  - **Dry run is ON by default** - Use `-d false` for actual transfer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fazetitans/fscopy",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
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
  ],
package/src/cli.ts CHANGED
@@ -23,7 +23,7 @@ import { validateConfig } from './config/validator.js';
23
23
  import { defaults } from './config/defaults.js';
24
24
  import { generateConfigFile } from './config/generator.js';
25
25
  import { loadTransferState, saveTransferState, createInitialState, validateStateForResume, deleteTransferState } from './state/index.js';
26
- import { sendWebhook } from './webhook/index.js';
26
+ import { sendWebhook, validateWebhookUrl } from './webhook/index.js';
27
27
  import { countDocuments, transferCollection, clearCollection, deleteOrphanDocuments, processInParallel, getDestCollectionPath, type TransferContext, type CountProgress } from './transfer/index.js';
28
28
  import { runInteractiveMode } from './interactive.js';
29
29
 
@@ -442,6 +442,18 @@ try {
442
442
  process.exit(1);
443
443
  }
444
444
 
445
+ // Validate webhook URL if configured
446
+ if (config.webhook) {
447
+ const webhookValidation = validateWebhookUrl(config.webhook);
448
+ if (!webhookValidation.valid) {
449
+ console.log(`\n❌ ${webhookValidation.warning}`);
450
+ process.exit(1);
451
+ }
452
+ if (webhookValidation.warning) {
453
+ console.log(`\n⚠️ ${webhookValidation.warning}`);
454
+ }
455
+ }
456
+
445
457
  // Skip confirmation in interactive mode (already confirmed by selection)
446
458
  if (!argv.yes && !argv.interactive) {
447
459
  const confirmed = await askConfirmation(config);
@@ -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
+ }