@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.
- package/README.md +12 -1
- package/package.json +19 -15
- package/src/cli.ts +14 -9
- package/src/config/defaults.ts +1 -0
- package/src/config/parser.ts +120 -49
- package/src/config/validator.ts +51 -0
- package/src/constants.ts +39 -0
- package/src/firebase/index.ts +29 -18
- package/src/interactive.ts +526 -36
- package/src/orchestrator.ts +100 -34
- package/src/state/index.ts +42 -26
- package/src/transfer/clear.ts +104 -68
- package/src/transfer/count.ts +54 -36
- package/src/transfer/helpers.ts +36 -1
- package/src/transfer/index.ts +7 -1
- package/src/transfer/transfer.ts +181 -167
- package/src/transform/loader.ts +13 -1
- package/src/types.ts +4 -2
- package/src/utils/credentials.ts +7 -4
- package/src/utils/errors.ts +7 -2
- package/src/utils/index.ts +6 -1
- package/src/utils/output.ts +6 -0
- package/src/utils/progress.ts +10 -0
- package/src/webhook/index.ts +127 -44
package/src/interactive.ts
CHANGED
|
@@ -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('\
|
|
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('\
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
89
|
+
interface CollectionInfo {
|
|
90
|
+
id: string;
|
|
91
|
+
count: number;
|
|
92
|
+
}
|
|
78
93
|
|
|
79
|
-
|
|
80
|
-
|
|
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('\
|
|
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('\
|
|
137
|
+
console.log('\nNo collections found in source project');
|
|
118
138
|
await tempSourceApp.delete();
|
|
119
139
|
process.exit(0);
|
|
120
140
|
}
|
|
121
141
|
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
162
|
-
|
|
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
|
}
|