@fazetitans/fscopy 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,32 +77,26 @@ 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
- let tempSourceApp: admin.app.App;
99
+ let tempSourceApp: admin.app.App | undefined;
83
100
  let sourceDb: Firestore;
84
101
  let rootCollections: FirebaseFirestore.CollectionReference[];
85
102
 
@@ -92,12 +109,15 @@ 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) {
114
+ // Clean up Firebase app if it was initialized before the error
115
+ if (tempSourceApp) {
116
+ await tempSourceApp.delete().catch(() => {});
117
+ }
118
+
99
119
  const err = error as Error & { code?: string };
100
- console.error('\n❌ Cannot connect to Firebase project:', err.message);
120
+ console.error('\nCannot connect to Firebase project:', err.message);
101
121
 
102
122
  if (err.message.includes('default credentials') || err.message.includes('credential')) {
103
123
  console.error('\n Run this command to authenticate:');
@@ -114,14 +134,13 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
114
134
  const collectionIds = rootCollections.map((col) => col.id);
115
135
 
116
136
  if (collectionIds.length === 0) {
117
- console.log('\n⚠️ No collections found in source project');
137
+ console.log('\nNo collections found in source project');
118
138
  await tempSourceApp.delete();
119
139
  process.exit(0);
120
140
  }
121
141
 
122
- // Count documents in each collection for preview
123
- console.log('\n📋 Available collections:');
124
- const collectionInfo: { id: string; count: number }[] = [];
142
+ console.log('\nAvailable collections:');
143
+ const collectionInfo: CollectionInfo[] = [];
125
144
  for (const id of collectionIds) {
126
145
  const snapshot = await sourceDb.collection(id).count().get();
127
146
  const count = snapshot.data().count;
@@ -129,7 +148,462 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
129
148
  console.log(` - ${id} (${count} documents)`);
130
149
  }
131
150
 
132
- // Let user select collections
151
+ return { app: tempSourceApp, db: sourceDb, collections: collectionInfo };
152
+ }
153
+
154
+ // =============================================================================
155
+ // Advanced options
156
+ // =============================================================================
157
+
158
+ const advancedOptionChoices: Array<{ name: string; value: AdvancedOption }> = [
159
+ { name: 'Exclude subcollection patterns', value: 'exclude' },
160
+ { name: 'Where filters (filter source documents)', value: 'where' },
161
+ { name: 'Parallel transfers', value: 'parallel' },
162
+ { name: 'Batch size', value: 'batchSize' },
163
+ { name: 'Document limit per collection', value: 'limit' },
164
+ { name: 'Max subcollection depth', value: 'maxDepth' },
165
+ { name: 'Rate limit (docs/sec)', value: 'rateLimit' },
166
+ { name: 'Clear destination before transfer', value: 'clear' },
167
+ { name: 'Delete missing docs in destination (sync mode)', value: 'deleteMissing' },
168
+ { name: 'Transform file (JS/TS)', value: 'transform' },
169
+ { name: 'Rename collections in destination', value: 'renameCollection' },
170
+ { name: 'ID prefix', value: 'idPrefix' },
171
+ { name: 'ID suffix', value: 'idSuffix' },
172
+ { name: 'Webhook URL (Slack, Discord, custom)', value: 'webhook' },
173
+ { name: 'Skip oversized documents (>1MB)', value: 'skipOversized' },
174
+ { name: 'Detect conflicts during transfer', value: 'detectConflicts' },
175
+ { name: 'Verify counts after transfer', value: 'verify' },
176
+ { name: 'Verify integrity (hash comparison)', value: 'verifyIntegrity' },
177
+ { name: 'Retries on error', value: 'retries' },
178
+ ];
179
+
180
+ async function promptAdvancedOptions(config: Config): Promise<Partial<Config>> {
181
+ const wantAdvanced = await confirm({
182
+ message: 'Configure additional options?',
183
+ default: false,
184
+ });
185
+
186
+ if (!wantAdvanced) return {};
187
+
188
+ console.log('');
189
+ const selected = new Set(
190
+ await checkbox<AdvancedOption>({
191
+ message: 'Select options to configure:',
192
+ choices: advancedOptionChoices,
193
+ })
194
+ );
195
+
196
+ if (selected.size === 0) return {};
197
+
198
+ console.log('');
199
+ const updates: Partial<Config> = {};
200
+
201
+ if (selected.has('exclude')) {
202
+ const val = await input({
203
+ message: 'Exclude patterns (comma-separated, e.g. "logs, cache*, temp"):',
204
+ default: config.exclude.length > 0 ? config.exclude.join(', ') : undefined,
205
+ });
206
+ updates.exclude = parseStringList(val);
207
+ }
208
+
209
+ if (selected.has('where')) {
210
+ const filters: WhereFilter[] = [];
211
+ let addMore = true;
212
+ while (addMore) {
213
+ const filterStr = await input({
214
+ message: `Where filter${filters.length > 0 ? ' (leave empty to stop)' : ''} (e.g. "status == active"):`,
215
+ });
216
+ if (!filterStr.trim()) break;
217
+ const parsed = parseWhereFilter(filterStr);
218
+ if (parsed) {
219
+ filters.push(parsed);
220
+ console.log(` Added: ${parsed.field} ${parsed.operator} ${parsed.value}`);
221
+ }
222
+ if (filters.length > 0) {
223
+ addMore = await confirm({ message: 'Add another filter?', default: false });
224
+ }
225
+ }
226
+ if (filters.length > 0) {
227
+ updates.where = filters;
228
+ }
229
+ }
230
+
231
+ if (selected.has('parallel')) {
232
+ const val = await number({
233
+ message: 'Number of parallel collection transfers:',
234
+ default: config.parallel,
235
+ min: 1,
236
+ max: 20,
237
+ step: 1,
238
+ });
239
+ if (val !== undefined) updates.parallel = val;
240
+ }
241
+
242
+ if (selected.has('batchSize')) {
243
+ const val = await number({
244
+ message: 'Batch size (documents per write):',
245
+ default: config.batchSize,
246
+ min: 1,
247
+ max: 500,
248
+ step: 1,
249
+ });
250
+ if (val !== undefined) updates.batchSize = val;
251
+ }
252
+
253
+ if (selected.has('limit')) {
254
+ const val = await number({
255
+ message: 'Document limit per collection (0 = no limit):',
256
+ default: config.limit,
257
+ min: 0,
258
+ step: 1,
259
+ });
260
+ if (val !== undefined) updates.limit = val;
261
+ }
262
+
263
+ if (selected.has('maxDepth')) {
264
+ const val = await number({
265
+ message: 'Max subcollection depth (0 = unlimited):',
266
+ default: config.maxDepth,
267
+ min: 0,
268
+ step: 1,
269
+ });
270
+ if (val !== undefined) updates.maxDepth = val;
271
+ }
272
+
273
+ if (selected.has('rateLimit')) {
274
+ const val = await number({
275
+ message: 'Rate limit in docs/sec (0 = unlimited):',
276
+ default: config.rateLimit,
277
+ min: 0,
278
+ step: 1,
279
+ });
280
+ if (val !== undefined) updates.rateLimit = val;
281
+ }
282
+
283
+ if (selected.has('clear')) {
284
+ updates.clear = await confirm({
285
+ message: 'Clear destination collections before transfer? (DESTRUCTIVE)',
286
+ default: config.clear,
287
+ });
288
+ }
289
+
290
+ if (selected.has('deleteMissing')) {
291
+ updates.deleteMissing = await confirm({
292
+ message: 'Delete docs in destination not present in source? (sync mode)',
293
+ default: config.deleteMissing,
294
+ });
295
+ }
296
+
297
+ if (selected.has('transform')) {
298
+ const val = await input({
299
+ message: 'Path to transform file (JS/TS):',
300
+ default: config.transform ?? undefined,
301
+ validate: (value) => {
302
+ if (!value.trim()) return 'Path is required';
303
+ return true;
304
+ },
305
+ });
306
+ updates.transform = val.trim();
307
+ }
308
+
309
+ if (selected.has('renameCollection')) {
310
+ const val = await input({
311
+ message: 'Rename mappings (e.g. "users:users_backup, orders:orders_v2"):',
312
+ default: Object.entries(config.renameCollection).map(([s, d]) => `${s}:${d}`).join(', ') || undefined,
313
+ });
314
+ updates.renameCollection = parseRenameMapping(parseStringList(val));
315
+ }
316
+
317
+ if (selected.has('idPrefix')) {
318
+ const val = await input({
319
+ message: 'Document ID prefix:',
320
+ default: config.idPrefix ?? undefined,
321
+ });
322
+ updates.idPrefix = val.trim() || null;
323
+ }
324
+
325
+ if (selected.has('idSuffix')) {
326
+ const val = await input({
327
+ message: 'Document ID suffix:',
328
+ default: config.idSuffix ?? undefined,
329
+ });
330
+ updates.idSuffix = val.trim() || null;
331
+ }
332
+
333
+ if (selected.has('webhook')) {
334
+ const val = await input({
335
+ message: 'Webhook URL:',
336
+ default: config.webhook ?? undefined,
337
+ });
338
+ updates.webhook = val.trim() || null;
339
+ }
340
+
341
+ if (selected.has('skipOversized')) {
342
+ updates.skipOversized = await confirm({
343
+ message: 'Skip documents exceeding 1MB instead of failing?',
344
+ default: config.skipOversized,
345
+ });
346
+ }
347
+
348
+ if (selected.has('detectConflicts')) {
349
+ updates.detectConflicts = await confirm({
350
+ message: 'Detect destination modifications during transfer?',
351
+ default: config.detectConflicts,
352
+ });
353
+ }
354
+
355
+ if (selected.has('verify')) {
356
+ updates.verify = await confirm({
357
+ message: 'Verify document counts after transfer?',
358
+ default: config.verify,
359
+ });
360
+ }
361
+
362
+ if (selected.has('verifyIntegrity')) {
363
+ updates.verifyIntegrity = await confirm({
364
+ message: 'Verify document integrity with hash after transfer?',
365
+ default: config.verifyIntegrity,
366
+ });
367
+ }
368
+
369
+ if (selected.has('retries')) {
370
+ const val = await number({
371
+ message: 'Number of retries on error:',
372
+ default: config.retries,
373
+ min: 0,
374
+ max: 10,
375
+ step: 1,
376
+ });
377
+ if (val !== undefined) updates.retries = val;
378
+ }
379
+
380
+ return updates;
381
+ }
382
+
383
+ // =============================================================================
384
+ // Final action
385
+ // =============================================================================
386
+
387
+ async function promptFinalAction(): Promise<'execute' | 'save-ini' | 'save-json'> {
388
+ console.log('');
389
+ return select({
390
+ message: 'What would you like to do?',
391
+ choices: [
392
+ { name: 'Execute transfer', value: 'execute' as const },
393
+ { name: 'Save as INI config file', value: 'save-ini' as const },
394
+ { name: 'Save as JSON config file', value: 'save-json' as const },
395
+ ],
396
+ });
397
+ }
398
+
399
+ // =============================================================================
400
+ // Config serialization
401
+ // =============================================================================
402
+
403
+ function serializeWhereFilters(filters: WhereFilter[]): string[] {
404
+ return filters.map((f) => `${f.field} ${f.operator} ${f.value}`);
405
+ }
406
+
407
+ function serializeRenameMapping(mapping: Record<string, string>): string {
408
+ return Object.entries(mapping)
409
+ .map(([src, dest]) => `${src}:${dest}`)
410
+ .join(', ');
411
+ }
412
+
413
+ function configToJson(config: Config): string {
414
+ const output: Record<string, unknown> = {
415
+ sourceProject: config.sourceProject,
416
+ destProject: config.destProject,
417
+ collections: config.collections,
418
+ includeSubcollections: config.includeSubcollections,
419
+ dryRun: config.dryRun,
420
+ batchSize: config.batchSize,
421
+ limit: config.limit,
422
+ where: serializeWhereFilters(config.where),
423
+ exclude: config.exclude,
424
+ merge: config.merge,
425
+ parallel: config.parallel,
426
+ clear: config.clear,
427
+ deleteMissing: config.deleteMissing,
428
+ };
429
+
430
+ // Include optional fields only if set
431
+ if (config.transform) output.transform = config.transform;
432
+ if (Object.keys(config.renameCollection).length > 0) output.renameCollection = config.renameCollection;
433
+ if (config.idPrefix) output.idPrefix = config.idPrefix;
434
+ if (config.idSuffix) output.idSuffix = config.idSuffix;
435
+ if (config.webhook) output.webhook = config.webhook;
436
+ if (config.rateLimit > 0) output.rateLimit = config.rateLimit;
437
+ if (config.skipOversized) output.skipOversized = config.skipOversized;
438
+ if (config.detectConflicts) output.detectConflicts = config.detectConflicts;
439
+ if (config.maxDepth > 0) output.maxDepth = config.maxDepth;
440
+ if (config.verify) output.verify = config.verify;
441
+ if (config.verifyIntegrity) output.verifyIntegrity = config.verifyIntegrity;
442
+ if (config.retries !== 3) output.retries = config.retries;
443
+
444
+ return JSON.stringify(output, null, 4);
445
+ }
446
+
447
+ function iniLine(key: string, value: string | number | boolean): string {
448
+ return `${key} = ${value}\n`;
449
+ }
450
+
451
+ function iniComment(key: string, value: string | number | boolean): string {
452
+ return `; ${key} = ${value}\n`;
453
+ }
454
+
455
+ function configToIni(config: Config): string {
456
+ let ini = '; fscopy configuration file\n';
457
+ ini += '; Generated by interactive mode\n\n';
458
+
459
+ // [projects]
460
+ ini += '[projects]\n';
461
+ ini += iniLine('source', config.sourceProject ?? '');
462
+ ini += iniLine('dest', config.destProject ?? '');
463
+ ini += '\n';
464
+
465
+ // [transfer]
466
+ ini += '[transfer]\n';
467
+ ini += iniLine('collections', config.collections.join(', '));
468
+ ini += iniLine('includeSubcollections', config.includeSubcollections);
469
+ ini += iniLine('dryRun', config.dryRun);
470
+ ini += iniLine('batchSize', config.batchSize);
471
+ ini += iniLine('limit', config.limit);
472
+ ini += '\n';
473
+
474
+ // [options]
475
+ ini += '[options]\n';
476
+
477
+ if (config.where.length > 0) {
478
+ ini += iniLine('where', serializeWhereFilters(config.where).join(', '));
479
+ } else {
480
+ ini += iniComment('where', 'status == active');
481
+ }
482
+
483
+ if (config.exclude.length > 0) {
484
+ ini += iniLine('exclude', config.exclude.join(', '));
485
+ } else {
486
+ ini += iniComment('exclude', 'logs, temp/*, cache');
487
+ }
488
+
489
+ ini += iniLine('merge', config.merge);
490
+ ini += iniLine('parallel', config.parallel);
491
+ ini += iniLine('clear', config.clear);
492
+ ini += iniLine('deleteMissing', config.deleteMissing);
493
+
494
+ if (config.transform) {
495
+ ini += iniLine('transform', config.transform);
496
+ } else {
497
+ ini += iniComment('transform', './transforms/anonymize.ts');
498
+ }
499
+
500
+ if (Object.keys(config.renameCollection).length > 0) {
501
+ ini += iniLine('renameCollection', serializeRenameMapping(config.renameCollection));
502
+ } else {
503
+ ini += iniComment('renameCollection', 'users:users_backup, orders:orders_2024');
504
+ }
505
+
506
+ if (config.idPrefix) {
507
+ ini += iniLine('idPrefix', config.idPrefix);
508
+ } else {
509
+ ini += iniComment('idPrefix', 'backup_');
510
+ }
511
+
512
+ if (config.idSuffix) {
513
+ ini += iniLine('idSuffix', config.idSuffix);
514
+ } else {
515
+ ini += iniComment('idSuffix', '_v2');
516
+ }
517
+
518
+ if (config.webhook) {
519
+ ini += iniLine('webhook', config.webhook);
520
+ } else {
521
+ ini += iniComment('webhook', 'https://hooks.slack.com/services/...');
522
+ }
523
+
524
+ if (config.retries !== 3) {
525
+ ini += iniLine('retries', config.retries);
526
+ } else {
527
+ ini += iniComment('retries', 3);
528
+ }
529
+
530
+ if (config.rateLimit > 0) {
531
+ ini += iniLine('rateLimit', config.rateLimit);
532
+ } else {
533
+ ini += iniComment('rateLimit', 0);
534
+ }
535
+
536
+ ini += iniLine('skipOversized', config.skipOversized);
537
+ ini += iniLine('detectConflicts', config.detectConflicts);
538
+
539
+ if (config.maxDepth > 0) {
540
+ ini += iniLine('maxDepth', config.maxDepth);
541
+ } else {
542
+ ini += iniComment('maxDepth', 0);
543
+ }
544
+
545
+ ini += iniLine('verify', config.verify);
546
+ ini += iniLine('verifyIntegrity', config.verifyIntegrity);
547
+
548
+ return ini;
549
+ }
550
+
551
+ async function saveConfig(config: Config, format: 'ini' | 'json'): Promise<string> {
552
+ const defaultName = format === 'json' ? 'fscopy-config.json' : 'fscopy-config.ini';
553
+
554
+ const filePath = await input({
555
+ message: `Save path:`,
556
+ default: defaultName,
557
+ });
558
+
559
+ const resolvedPath = path.resolve(filePath);
560
+
561
+ if (fs.existsSync(resolvedPath)) {
562
+ const overwrite = await confirm({
563
+ message: `File "${filePath}" already exists. Overwrite?`,
564
+ default: false,
565
+ });
566
+ if (!overwrite) {
567
+ console.log('\nSave cancelled.\n');
568
+ process.exit(0);
569
+ }
570
+ }
571
+
572
+ const content = format === 'json' ? configToJson(config) : configToIni(config);
573
+ fs.writeFileSync(resolvedPath, content, 'utf-8');
574
+
575
+ console.log(`\nConfig saved: ${resolvedPath}`);
576
+ console.log(`\n Run with: fscopy -f ${filePath}\n`);
577
+
578
+ return resolvedPath;
579
+ }
580
+
581
+ // =============================================================================
582
+ // Main interactive flow
583
+ // =============================================================================
584
+
585
+ export async function runInteractiveMode(config: Config): Promise<InteractiveResult> {
586
+ console.log('\n' + '='.repeat(SEPARATOR_LENGTH));
587
+ console.log('FSCOPY - INTERACTIVE MODE');
588
+ console.log('='.repeat(SEPARATOR_LENGTH) + '\n');
589
+
590
+ // 1. Projects
591
+ const sourceProject = await promptForProject(config.sourceProject, 'Source Firebase project ID', '>>');
592
+ const destProject = await promptForProject(config.destProject, 'Destination Firebase project ID', '>>');
593
+
594
+ let idPrefix = config.idPrefix;
595
+ let idSuffix = config.idSuffix;
596
+
597
+ if (sourceProject === destProject) {
598
+ const mods = await promptForIdModification(idPrefix, idSuffix);
599
+ idPrefix = mods.idPrefix;
600
+ idSuffix = mods.idSuffix;
601
+ }
602
+
603
+ // 2. Discover collections
604
+ const { app: tempSourceApp, collections: collectionInfo } = await discoverCollections(sourceProject);
605
+
606
+ // 3. Select collections
133
607
  console.log('');
134
608
  const selectedCollections = await checkbox({
135
609
  message: 'Select collections to transfer:',
@@ -141,7 +615,7 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
141
615
  validate: (value) => value.length > 0 || 'Select at least one collection',
142
616
  });
143
617
 
144
- // Ask about options
618
+ // 4. Basic options
145
619
  console.log('');
146
620
  const includeSubcollections = await confirm({
147
621
  message: 'Include subcollections?',
@@ -158,11 +632,8 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
158
632
  default: config.merge,
159
633
  });
160
634
 
161
- // Clean up temporary app
162
- await tempSourceApp.delete();
163
-
164
- // Return updated config
165
- return {
635
+ // Build config so far
636
+ let finalConfig: Config = {
166
637
  ...config,
167
638
  sourceProject,
168
639
  destProject,
@@ -173,4 +644,23 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
173
644
  idPrefix,
174
645
  idSuffix,
175
646
  };
647
+
648
+ // 5. Advanced options
649
+ console.log('');
650
+ const advancedUpdates = await promptAdvancedOptions(finalConfig);
651
+ finalConfig = { ...finalConfig, ...advancedUpdates };
652
+
653
+ // Clean up temporary Firebase app
654
+ await tempSourceApp.delete();
655
+
656
+ // 6. Final action
657
+ const action = await promptFinalAction();
658
+
659
+ if (action === 'save-ini' || action === 'save-json') {
660
+ const format = action === 'save-json' ? 'json' : 'ini';
661
+ await saveConfig(finalConfig, format);
662
+ return { config: finalConfig, action: 'save' };
663
+ }
664
+
665
+ return { config: finalConfig, action: 'execute' };
176
666
  }