@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 +10 -0
- package/package.json +3 -2
- package/src/cli.ts +13 -1
- package/src/config/defaults.ts +88 -0
- package/src/config/generator.ts +27 -0
- package/src/config/index.ts +15 -0
- package/src/config/parser.ts +261 -0
- package/src/config/validator.ts +29 -0
- package/src/interactive.ts +172 -0
- package/src/state/index.ts +123 -0
- package/src/transfer/clear.ts +157 -0
- package/src/transfer/count.ts +71 -0
- package/src/transfer/helpers.ts +37 -0
- package/src/transfer/index.ts +5 -0
- package/src/transfer/parallel.ts +51 -0
- package/src/transfer/transfer.ts +214 -0
- package/src/types.ts +101 -0
- package/src/utils/credentials.ts +32 -0
- package/src/utils/doc-size.ts +129 -0
- package/src/utils/errors.ts +157 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/logger.ts +61 -0
- package/src/utils/patterns.ts +14 -0
- package/src/utils/rate-limiter.ts +62 -0
- package/src/utils/retry.ts +29 -0
- package/src/webhook/index.ts +146 -0
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.
|
|
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
|
|
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
|
+
}
|