@fazetitans/fscopy 1.1.1 ā 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/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/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
|
+
}
|