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