@blinkk/root-cms 2.4.4 → 2.4.7

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/dist/cli.js CHANGED
@@ -2,23 +2,363 @@
2
2
  import { Command } from "commander";
3
3
  import { bgGreen, black } from "kleur/colors";
4
4
 
5
- // cli/generate-types.ts
6
- import { promises as fs } from "fs";
5
+ // cli/export.ts
6
+ import fs from "fs";
7
7
  import path from "path";
8
+ import { loadRootConfig } from "@blinkk/root/node";
9
+
10
+ // core/client.ts
11
+ import crypto from "crypto";
12
+ import {
13
+ FieldValue as FieldValue2,
14
+ Timestamp as Timestamp2
15
+ } from "firebase-admin/firestore";
16
+
17
+ // core/translations-manager.ts
18
+ import {
19
+ FieldValue,
20
+ Timestamp
21
+ } from "firebase-admin/firestore";
22
+
23
+ // shared/strings.ts
24
+ import fnv from "fnv-plus";
25
+
26
+ // core/translations-manager.ts
27
+ var TRANSLATIONS_DB_PATH_FORMAT = "/Projects/{project}/TranslationsManager/{mode}/Translations";
28
+ var TRANSLATIONS_LOCALE_DOC_DB_PATH_FORMAT = `${TRANSLATIONS_DB_PATH_FORMAT}/{id}:{locale}`;
29
+
30
+ // core/client.ts
31
+ function getCmsPlugin(rootConfig) {
32
+ const plugins = rootConfig.plugins || [];
33
+ const plugin = plugins.find((plugin2) => plugin2.name === "root-cms");
34
+ if (!plugin) {
35
+ throw new Error("could not find root-cms plugin config in root.config.ts");
36
+ }
37
+ return plugin;
38
+ }
39
+
40
+ // cli/utils.ts
41
+ function parseFilters(filterStr) {
42
+ if (!filterStr) {
43
+ return { includes: [], excludes: [] };
44
+ }
45
+ const parts = filterStr.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
46
+ const includes = parts.filter((p) => !p.startsWith("!"));
47
+ const excludes = parts.filter((p) => p.startsWith("!")).map((p) => p.slice(1));
48
+ return { includes, excludes };
49
+ }
50
+ function getPathStatus(path4, includes, excludes) {
51
+ for (const pattern of excludes) {
52
+ if (globMatch(path4, pattern)) {
53
+ return "EXCLUDE";
54
+ }
55
+ }
56
+ if (includes.length === 0) {
57
+ return "INCLUDE";
58
+ }
59
+ let isIncluded = false;
60
+ let shouldTraverse = false;
61
+ for (const pattern of includes) {
62
+ const match = checkMatch(path4, pattern);
63
+ if (match === "FULL") {
64
+ isIncluded = true;
65
+ } else if (match === "PARTIAL") {
66
+ shouldTraverse = true;
67
+ }
68
+ }
69
+ if (isIncluded) {
70
+ return "INCLUDE";
71
+ }
72
+ if (shouldTraverse) {
73
+ return "TRAVERSE";
74
+ }
75
+ return "SKIP";
76
+ }
77
+ function globMatch(path4, pattern) {
78
+ return checkMatch(path4, pattern) === "FULL";
79
+ }
80
+ function checkMatch(path4, pattern) {
81
+ const pathParts = path4.split("/");
82
+ const patternParts = pattern.split("/");
83
+ if (matchSegments(pathParts, patternParts)) {
84
+ return "FULL";
85
+ }
86
+ if (pathParts.length < patternParts.length) {
87
+ const patternPrefix = patternParts.slice(0, pathParts.length);
88
+ if (matchSegments(pathParts, patternPrefix)) {
89
+ return "PARTIAL";
90
+ }
91
+ }
92
+ return "NONE";
93
+ }
94
+ function matchSegments(pathParts, patternParts) {
95
+ if (patternParts[patternParts.length - 1] === "**") {
96
+ const basePattern = patternParts.slice(0, -1);
97
+ if (pathParts.length < basePattern.length) {
98
+ return false;
99
+ }
100
+ return matchSegments(pathParts.slice(0, basePattern.length), basePattern);
101
+ }
102
+ if (pathParts.length !== patternParts.length) {
103
+ return false;
104
+ }
105
+ for (let i = 0; i < pathParts.length; i++) {
106
+ const pathPart = pathParts[i];
107
+ const patternPart = patternParts[i];
108
+ if (patternPart === "*") {
109
+ continue;
110
+ }
111
+ if (pathPart !== patternPart) {
112
+ return false;
113
+ }
114
+ }
115
+ return true;
116
+ }
117
+ function pLimit(concurrency) {
118
+ const queue = [];
119
+ let activeCount = 0;
120
+ const next = () => {
121
+ activeCount--;
122
+ if (queue.length > 0) {
123
+ const job = queue.shift();
124
+ if (job) job();
125
+ }
126
+ };
127
+ return async (fn) => {
128
+ if (activeCount >= concurrency) {
129
+ await new Promise((resolve) => {
130
+ queue.push(resolve);
131
+ });
132
+ }
133
+ activeCount++;
134
+ try {
135
+ return await fn();
136
+ } finally {
137
+ next();
138
+ }
139
+ };
140
+ }
141
+
142
+ // cli/export.ts
143
+ async function exportData(options) {
144
+ const rootDir = process.cwd();
145
+ const rootConfig = await loadRootConfig(rootDir, { command: "root-cms" });
146
+ const cmsPlugin = getCmsPlugin(rootConfig);
147
+ const cmsPluginOptions = cmsPlugin.getConfig();
148
+ const siteId = options.site || cmsPluginOptions.id || "default";
149
+ const databaseId = options.database || "(default)";
150
+ const db = cmsPlugin.getFirestore({ databaseId });
151
+ const projectPath = `Projects/${siteId}`;
152
+ const projectRef = db.doc(projectPath);
153
+ const collections = await projectRef.listCollections();
154
+ const allCollectionIds = collections.map((c) => c.id);
155
+ const { includes, excludes } = parseFilters(options.filter);
156
+ const collectionsToExport = allCollectionIds.filter((id) => {
157
+ const status = getPathStatus(id, includes, excludes);
158
+ return status !== "SKIP" && status !== "EXCLUDE";
159
+ });
160
+ const now = /* @__PURE__ */ new Date();
161
+ const timestamp = formatTimestamp(now);
162
+ const gcpProjectId = options.project || cmsPluginOptions.firebaseConfig?.projectId || "default";
163
+ const exportDir = `export_${gcpProjectId}_${databaseId}_${siteId}_${timestamp}`.replace(/\(/g, "").replace(/\)/g, "");
164
+ console.log("");
165
+ console.table({
166
+ Directory: exportDir,
167
+ "GCP Project": gcpProjectId,
168
+ Database: `${databaseId}/Projects/${siteId}`,
169
+ Site: siteId,
170
+ Collections: collectionsToExport.join(", "),
171
+ Filter: options.filter || "(none)"
172
+ });
173
+ console.log("");
174
+ if (!fs.existsSync(exportDir)) {
175
+ fs.mkdirSync(exportDir, { recursive: true });
176
+ }
177
+ if (!options.filter) {
178
+ const projectDoc = await projectRef.get();
179
+ if (projectDoc.exists) {
180
+ const projectData = projectDoc.data();
181
+ const projectFilePath = path.join(exportDir, "__data.json");
182
+ fs.writeFileSync(
183
+ projectFilePath,
184
+ JSON.stringify(convertForExport(projectData), null, 2)
185
+ );
186
+ console.log("Exported project document to __data.json");
187
+ }
188
+ }
189
+ const limit = pLimit(10);
190
+ for (const collectionType of collectionsToExport) {
191
+ console.log(`Exporting ${collectionType}...`);
192
+ const collectionPath = `Projects/${siteId}/${collectionType}`;
193
+ const collectionDir = path.join(exportDir, collectionType);
194
+ await exportCollection(
195
+ db,
196
+ collectionPath,
197
+ collectionDir,
198
+ collectionType,
199
+ collectionType,
200
+ includes,
201
+ excludes,
202
+ limit
203
+ );
204
+ }
205
+ console.log(`
206
+ \u2705 Export complete! Data saved to: ${exportDir}`);
207
+ }
208
+ var Spinner = class {
209
+ constructor(text) {
210
+ this.timer = null;
211
+ this.frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
212
+ this.currentFrame = 0;
213
+ this.text = text;
214
+ }
215
+ start() {
216
+ this.render();
217
+ this.timer = setInterval(() => {
218
+ this.currentFrame = (this.currentFrame + 1) % this.frames.length;
219
+ this.render();
220
+ }, 80);
221
+ }
222
+ update(text) {
223
+ this.text = text;
224
+ this.render();
225
+ }
226
+ stop() {
227
+ if (this.timer) {
228
+ clearInterval(this.timer);
229
+ this.timer = null;
230
+ }
231
+ process.stdout.write("\r\x1B[K");
232
+ }
233
+ render() {
234
+ process.stdout.write(`\r${this.frames[this.currentFrame]} ${this.text}`);
235
+ }
236
+ };
237
+ async function exportCollection(db, collectionPath, outputDir, displayName, relativePath, includes, excludes, limit) {
238
+ const stats = { count: 0 };
239
+ const spinner = new Spinner(`Exporting ${displayName}...`);
240
+ spinner.start();
241
+ await exportCollectionRecursive(
242
+ db,
243
+ collectionPath,
244
+ outputDir,
245
+ stats,
246
+ spinner,
247
+ relativePath,
248
+ includes,
249
+ excludes,
250
+ limit
251
+ );
252
+ spinner.stop();
253
+ console.log(` - ${displayName}: ${stats.count} documents`);
254
+ }
255
+ async function exportCollectionRecursive(db, collectionPath, outputDir, stats, spinner, relativePath, includes, excludes, limit) {
256
+ if (!fs.existsSync(outputDir)) {
257
+ fs.mkdirSync(outputDir, { recursive: true });
258
+ }
259
+ const collection = db.collection(collectionPath);
260
+ const refs = await limit(() => collection.listDocuments());
261
+ if (refs.length === 0) {
262
+ return;
263
+ }
264
+ const snapshot = await limit(() => collection.get());
265
+ const docMap = /* @__PURE__ */ new Map();
266
+ snapshot.docs.forEach((doc) => {
267
+ docMap.set(doc.id, doc);
268
+ });
269
+ await Promise.all(
270
+ refs.map(async (ref) => {
271
+ const doc = docMap.get(ref.id);
272
+ const subcollections = await limit(() => ref.listCollections());
273
+ const hasSubcollections = subcollections.length > 0;
274
+ const docRelativePath = `${relativePath}/${ref.id}`;
275
+ const docStatus = getPathStatus(docRelativePath, includes, excludes);
276
+ if (doc && docStatus !== "SKIP" && docStatus !== "EXCLUDE") {
277
+ const docData = doc.data();
278
+ let filePath;
279
+ if (hasSubcollections) {
280
+ const docDir = path.join(outputDir, doc.id);
281
+ if (!fs.existsSync(docDir)) {
282
+ fs.mkdirSync(docDir, { recursive: true });
283
+ }
284
+ filePath = path.join(docDir, "__data.json");
285
+ } else {
286
+ filePath = path.join(outputDir, `${doc.id}.json`);
287
+ }
288
+ fs.writeFileSync(
289
+ filePath,
290
+ JSON.stringify(convertForExport(docData), null, 2)
291
+ );
292
+ stats.count++;
293
+ spinner.update(`Exporting ${stats.count} documents...`);
294
+ }
295
+ for (const subcollection of subcollections) {
296
+ const subRelativePath = `${docRelativePath}/${subcollection.id}`;
297
+ const status = getPathStatus(subRelativePath, includes, excludes);
298
+ if (status === "SKIP" || status === "EXCLUDE") {
299
+ continue;
300
+ }
301
+ const subcollectionDir = path.join(outputDir, ref.id, subcollection.id);
302
+ await exportCollectionRecursive(
303
+ db,
304
+ subcollection.path,
305
+ subcollectionDir,
306
+ stats,
307
+ spinner,
308
+ subRelativePath,
309
+ includes,
310
+ excludes,
311
+ limit
312
+ );
313
+ }
314
+ })
315
+ );
316
+ }
317
+ function formatTimestamp(date) {
318
+ const year = date.getFullYear();
319
+ const month = String(date.getMonth() + 1).padStart(2, "0");
320
+ const day = String(date.getDate()).padStart(2, "0");
321
+ const hour = String(date.getHours()).padStart(2, "0");
322
+ const minute = String(date.getMinutes()).padStart(2, "0");
323
+ return `${year}${month}${day}t${hour}${minute}`;
324
+ }
325
+ function convertForExport(obj) {
326
+ if (obj === null || obj === void 0) {
327
+ return obj;
328
+ }
329
+ if (typeof obj === "object" && typeof obj.path === "string" && obj.constructor.name === "DocumentReference") {
330
+ return { _referencePath: obj.path };
331
+ }
332
+ if (Array.isArray(obj)) {
333
+ return obj.map((item) => convertForExport(item));
334
+ }
335
+ if (typeof obj === "object") {
336
+ const converted = {};
337
+ for (const [key, value] of Object.entries(obj)) {
338
+ converted[key] = convertForExport(value);
339
+ }
340
+ return converted;
341
+ }
342
+ return obj;
343
+ }
344
+
345
+ // cli/generate-types.ts
346
+ import { promises as fs2 } from "fs";
347
+ import path2 from "path";
8
348
  import { fileURLToPath } from "url";
9
- import { loadRootConfig, viteSsrLoadModule } from "@blinkk/root/node";
349
+ import { loadRootConfig as loadRootConfig2, viteSsrLoadModule } from "@blinkk/root/node";
10
350
  import * as dom from "dts-dom";
11
- var __dirname = path.dirname(fileURLToPath(import.meta.url));
351
+ var __dirname = path2.dirname(fileURLToPath(import.meta.url));
12
352
  async function generateTypes() {
13
353
  const rootDir = process.cwd();
14
- const rootConfig = await loadRootConfig(rootDir, { command: "root-cms" });
15
- const modulePath = path.resolve(__dirname, "./project.js");
354
+ const rootConfig = await loadRootConfig2(rootDir, { command: "root-cms" });
355
+ const modulePath = path2.resolve(__dirname, "./project.js");
16
356
  const project = await viteSsrLoadModule(
17
357
  rootConfig,
18
358
  modulePath
19
359
  );
20
360
  const schemas = await project.getProjectSchemas();
21
- const outputPath = path.resolve(rootDir, "root-cms.d.ts");
361
+ const outputPath = path2.resolve(rootDir, "root-cms.d.ts");
22
362
  await generateSchemaDts(outputPath, schemas);
23
363
  console.log("saved root-cms.d.ts!");
24
364
  }
@@ -95,7 +435,7 @@ var DtsFormatter = class {
95
435
  /** Adds the types for a `.schema.ts` file to the `.d.ts` file. */
96
436
  addSchemaFile(fileId, schema) {
97
437
  const jsdoc = `Generated from \`${fileId}\`.`;
98
- const typeId = alphanumeric(path.parse(fileId).name.split(".")[0]);
438
+ const typeId = alphanumeric(path2.parse(fileId).name.split(".")[0]);
99
439
  const oneOfTypes = {};
100
440
  const fieldsTypeId = `${typeId}Fields`;
101
441
  const fieldsType = dom.create.interface(
@@ -178,7 +518,7 @@ async function generateSchemaDts(outputPath, schemas) {
178
518
  dtsFormatter.addSchemaFile(fileId, schema);
179
519
  }
180
520
  const output = dtsFormatter.toString();
181
- await fs.writeFile(outputPath, output, "utf-8");
521
+ await fs2.writeFile(outputPath, output, "utf-8");
182
522
  }
183
523
  function fieldProperty(field, options) {
184
524
  const prop = dom.create.property(
@@ -300,39 +640,269 @@ function alphanumeric(input) {
300
640
  return input.replace(/[^a-zA-Z0-9]/g, "");
301
641
  }
302
642
 
303
- // cli/init-firebase.ts
304
- import { loadRootConfig as loadRootConfig2 } from "@blinkk/root/node";
305
-
306
- // core/client.ts
307
- import crypto from "crypto";
308
- import {
309
- FieldValue as FieldValue2,
310
- Timestamp as Timestamp2
311
- } from "firebase-admin/firestore";
312
-
313
- // core/translations-manager.ts
314
- import {
315
- FieldValue,
316
- Timestamp
317
- } from "firebase-admin/firestore";
318
-
319
- // shared/strings.ts
320
- import fnv from "fnv-plus";
321
-
322
- // core/translations-manager.ts
323
- var TRANSLATIONS_DB_PATH_FORMAT = "/Projects/{project}/TranslationsManager/{mode}/Translations";
324
- var TRANSLATIONS_LOCALE_DOC_DB_PATH_FORMAT = `${TRANSLATIONS_DB_PATH_FORMAT}/{id}:{locale}`;
325
-
326
- // core/client.ts
327
- function getCmsPlugin(rootConfig) {
328
- const plugins = rootConfig.plugins || [];
329
- const plugin = plugins.find((plugin2) => plugin2.name === "root-cms");
330
- if (!plugin) {
331
- throw new Error("could not find root-cms plugin config in root.config.ts");
643
+ // cli/import.ts
644
+ import fs3 from "fs";
645
+ import path3 from "path";
646
+ import * as readline from "readline";
647
+ import { loadRootConfig as loadRootConfig3 } from "@blinkk/root/node";
648
+ import cliProgress from "cli-progress";
649
+ import { Timestamp as Timestamp3, GeoPoint } from "firebase-admin/firestore";
650
+ async function importData(options) {
651
+ if (!options.dir) {
652
+ throw new Error(
653
+ "Error: --dir flag is required. Usage: root-cms import --dir=export_siteId_timestamp"
654
+ );
332
655
  }
333
- return plugin;
656
+ const importDir = options.dir;
657
+ if (!fs3.existsSync(importDir)) {
658
+ throw new Error(`Error: Directory not found: ${importDir}`);
659
+ }
660
+ const rootDir = process.cwd();
661
+ const rootConfig = await loadRootConfig3(rootDir, { command: "root-cms" });
662
+ const cmsPlugin = getCmsPlugin(rootConfig);
663
+ const cmsPluginOptions = cmsPlugin.getConfig();
664
+ const siteId = options.site || cmsPluginOptions.id || "default";
665
+ const databaseId = options.database || "(default)";
666
+ const gcpProjectId = options.project || cmsPluginOptions.firebaseConfig?.projectId || "unknown";
667
+ const db = cmsPlugin.getFirestore({ databaseId });
668
+ const entries = fs3.readdirSync(importDir, { withFileTypes: true });
669
+ const availableCollections = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
670
+ const { includes, excludes } = parseFilters(options.filter);
671
+ const collectionsToImport = availableCollections.filter((id) => {
672
+ const status = getPathStatus(id, includes, excludes);
673
+ return status !== "SKIP" && status !== "EXCLUDE";
674
+ });
675
+ if (collectionsToImport.length === 0) {
676
+ throw new Error(
677
+ `No valid collection types found matching filter. Available types: ${availableCollections.join(
678
+ ", "
679
+ )}`
680
+ );
681
+ }
682
+ const summary = [];
683
+ for (const collectionType of collectionsToImport) {
684
+ const collectionDir = path3.join(importDir, collectionType);
685
+ if (fs3.existsSync(collectionDir)) {
686
+ const count = countJsonFilesRecursive(
687
+ collectionDir,
688
+ collectionType,
689
+ includes,
690
+ excludes
691
+ );
692
+ if (count > 0) {
693
+ summary.push({
694
+ collectionType,
695
+ count
696
+ });
697
+ }
698
+ }
699
+ }
700
+ if (summary.length === 0) {
701
+ console.log("No data found to import.");
702
+ return;
703
+ }
704
+ const tableData = {};
705
+ tableData["Import Directory"] = importDir;
706
+ tableData["GCP Project"] = gcpProjectId;
707
+ tableData["Database"] = `${databaseId}/Projects/${siteId}`;
708
+ tableData["Site"] = siteId;
709
+ for (const item of summary) {
710
+ tableData[item.collectionType] = `${item.count} documents`;
711
+ }
712
+ console.log("");
713
+ console.table(tableData);
714
+ console.log("");
715
+ const proceed = await promptYesNo("Proceed with import? (yes/no): ");
716
+ if (!proceed) {
717
+ console.log("Import cancelled.");
718
+ return;
719
+ }
720
+ console.log("\nStarting import...\n");
721
+ const limit = pLimit(10);
722
+ for (const item of summary) {
723
+ const collectionPath = `Projects/${siteId}/${item.collectionType}`;
724
+ const collectionDir = path3.join(importDir, item.collectionType);
725
+ await importCollection(
726
+ db,
727
+ collectionPath,
728
+ collectionDir,
729
+ item.collectionType,
730
+ item.count,
731
+ item.collectionType,
732
+ includes,
733
+ excludes,
734
+ limit
735
+ );
736
+ }
737
+ const projectFilePath = path3.join(importDir, "__data.json");
738
+ if (fs3.existsSync(projectFilePath)) {
739
+ console.log("Importing project document...");
740
+ const rawData = JSON.parse(fs3.readFileSync(projectFilePath, "utf-8"));
741
+ const projectData = convertFirestoreTypes(rawData, db);
742
+ const projectPath = `Projects/${siteId}`;
743
+ await db.doc(projectPath).set(projectData, { merge: true });
744
+ console.log(" - Project document updated");
745
+ }
746
+ console.log("\n\u2705 Import complete!");
747
+ }
748
+ function countJsonFilesRecursive(dir, relativePath, includes, excludes) {
749
+ let count = 0;
750
+ const entries = fs3.readdirSync(dir, { withFileTypes: true });
751
+ for (const entry of entries) {
752
+ if (entry.isDirectory()) {
753
+ const subRelativePath = `${relativePath}/${entry.name}`;
754
+ const status = getPathStatus(subRelativePath, includes, excludes);
755
+ if (status !== "SKIP" && status !== "EXCLUDE") {
756
+ count += countJsonFilesRecursive(
757
+ path3.join(dir, entry.name),
758
+ subRelativePath,
759
+ includes,
760
+ excludes
761
+ );
762
+ }
763
+ } else if (entry.isFile() && entry.name.endsWith(".json")) {
764
+ if (entry.name === "__data.json") {
765
+ const status2 = getPathStatus(relativePath, includes, excludes);
766
+ if (status2 === "INCLUDE") {
767
+ count++;
768
+ }
769
+ continue;
770
+ }
771
+ const docId = path3.basename(entry.name, ".json");
772
+ const docRelativePath = `${relativePath}/${docId}`;
773
+ const status = getPathStatus(docRelativePath, includes, excludes);
774
+ if (status !== "SKIP" && status !== "EXCLUDE") {
775
+ count++;
776
+ }
777
+ }
778
+ }
779
+ return count;
780
+ }
781
+ async function importCollection(db, collectionPath, inputDir, displayName, totalDocs, relativePath, includes, excludes, limit) {
782
+ console.log(`Importing ${displayName}...`);
783
+ const progressBar = new cliProgress.SingleBar({
784
+ format: `Importing ${displayName} [{bar}] {percentage}% | {value}/{total}`,
785
+ barCompleteChar: "\u2588",
786
+ barIncompleteChar: "\u2591",
787
+ hideCursor: true
788
+ });
789
+ progressBar.start(totalDocs, 0);
790
+ await importCollectionRecursive(
791
+ db,
792
+ collectionPath,
793
+ inputDir,
794
+ progressBar,
795
+ relativePath,
796
+ includes,
797
+ excludes,
798
+ limit
799
+ );
800
+ progressBar.stop();
801
+ console.log(` - ${displayName}: ${totalDocs} documents`);
802
+ }
803
+ function convertFirestoreTypes(obj, db) {
804
+ if (obj === null || obj === void 0) {
805
+ return obj;
806
+ }
807
+ if (typeof obj === "object" && "_seconds" in obj && "_nanoseconds" in obj && Object.keys(obj).length === 2) {
808
+ return new Timestamp3(obj._seconds, obj._nanoseconds);
809
+ }
810
+ if (typeof obj === "object" && "_latitude" in obj && "_longitude" in obj && Object.keys(obj).length === 2) {
811
+ return new GeoPoint(obj._latitude, obj._longitude);
812
+ }
813
+ if (typeof obj === "object" && "_referencePath" in obj && Object.keys(obj).length === 1) {
814
+ return db.doc(obj._referencePath);
815
+ }
816
+ if (Array.isArray(obj)) {
817
+ return obj.map((item) => convertFirestoreTypes(item, db));
818
+ }
819
+ if (typeof obj === "object") {
820
+ const converted = {};
821
+ for (const [key, value] of Object.entries(obj)) {
822
+ converted[key] = convertFirestoreTypes(value, db);
823
+ }
824
+ return converted;
825
+ }
826
+ return obj;
827
+ }
828
+ async function importCollectionRecursive(db, collectionPath, inputDir, progressBar, relativePath, includes, excludes, limit) {
829
+ if (!fs3.existsSync(inputDir)) {
830
+ return;
831
+ }
832
+ const entries = fs3.readdirSync(inputDir, { withFileTypes: true });
833
+ await Promise.all(
834
+ entries.map(async (entry) => {
835
+ if (entry.isFile() && entry.name.endsWith(".json")) {
836
+ const rawData = JSON.parse(
837
+ fs3.readFileSync(path3.join(inputDir, entry.name), "utf-8")
838
+ );
839
+ const docData = convertFirestoreTypes(rawData, db);
840
+ if (entry.name === "__data.json") {
841
+ const status = includes && excludes ? getPathStatus(relativePath || "", includes, excludes) : "INCLUDE";
842
+ if (status === "INCLUDE") {
843
+ if (limit) {
844
+ await limit(
845
+ () => db.doc(collectionPath).set(docData, { merge: true })
846
+ );
847
+ } else {
848
+ await db.doc(collectionPath).set(docData, { merge: true });
849
+ }
850
+ }
851
+ } else {
852
+ const docId = path3.basename(entry.name, ".json");
853
+ const docRelativePath = relativePath ? `${relativePath}/${docId}` : docId;
854
+ const status = includes && excludes ? getPathStatus(docRelativePath, includes, excludes) : "INCLUDE";
855
+ if (status !== "SKIP" && status !== "EXCLUDE") {
856
+ if (limit) {
857
+ await limit(
858
+ () => db.doc(`${collectionPath}/${docId}`).set(docData)
859
+ );
860
+ } else {
861
+ await db.doc(`${collectionPath}/${docId}`).set(docData);
862
+ }
863
+ if (progressBar) {
864
+ progressBar.increment();
865
+ }
866
+ }
867
+ }
868
+ } else if (entry.isDirectory()) {
869
+ const subCollectionPath = `${collectionPath}/${entry.name}`;
870
+ const subCollectionDir = path3.join(inputDir, entry.name);
871
+ const subRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
872
+ const status = includes && excludes ? getPathStatus(subRelativePath, includes, excludes) : "INCLUDE";
873
+ if (status !== "SKIP" && status !== "EXCLUDE") {
874
+ await importCollectionRecursive(
875
+ db,
876
+ subCollectionPath,
877
+ subCollectionDir,
878
+ progressBar,
879
+ subRelativePath,
880
+ includes,
881
+ excludes,
882
+ limit
883
+ );
884
+ }
885
+ }
886
+ })
887
+ );
888
+ }
889
+ async function promptYesNo(question) {
890
+ const rl = readline.createInterface({
891
+ input: process.stdin,
892
+ output: process.stdout
893
+ });
894
+ return new Promise((resolve) => {
895
+ rl.question(question, (answer) => {
896
+ rl.close();
897
+ const normalized = answer.trim().toLowerCase();
898
+ resolve(normalized === "yes" || normalized === "y");
899
+ });
900
+ });
334
901
  }
335
902
 
903
+ // cli/init-firebase.ts
904
+ import { loadRootConfig as loadRootConfig4 } from "@blinkk/root/node";
905
+
336
906
  // core/security.ts
337
907
  import { initializeApp } from "firebase-admin/app";
338
908
  import { getSecurityRules } from "firebase-admin/security-rules";
@@ -397,7 +967,7 @@ async function applySecurityRules(projectId) {
397
967
  // cli/init-firebase.ts
398
968
  async function initFirebase(options) {
399
969
  const rootDir = process.cwd();
400
- const rootConfig = await loadRootConfig2(rootDir, { command: "root-cms" });
970
+ const rootConfig = await loadRootConfig4(rootDir, { command: "root-cms" });
401
971
  const cmsPlugin = getCmsPlugin(rootConfig);
402
972
  const cmsPluginOptions = cmsPlugin.getConfig();
403
973
  const gcpProjectId = options.project || cmsPluginOptions.firebaseConfig.projectId;
@@ -453,11 +1023,31 @@ var CliRunner = class {
453
1023
  program.command("generate-types").alias("types").description(
454
1024
  "generates root-cms.d.ts from *.schema.ts files in the project"
455
1025
  ).action(generateTypes);
1026
+ program.command("export").description(
1027
+ 'exports firestore data to a local directory\n\nBy default, all content in the Firestore database associated with the CMS will be exported.\n\nA unique new directory will be created for each export.\n\nFile naming conventions:\n- Documents that act as containers for subcollections are exported as directories containing a `__data.json` file for the document\'s own data.\n- Standalone documents are exported as JSON files named after their document ID (e.g. `page.json`).\n- For collections like ActionLogs and Translations, the document ID (often a hash) is used as the filename.\n\nUsage examples:\n $ root-cms export\n $ root-cms export --filter "Collections/Pages/**"\n $ root-cms export --filter "Collections/Pages/**,!Collections/Pages/Draft/**"\n\nExample output:\n <output>/Collections/Pages/Draft/...\n <output>/Collections/Pages/Published/...\n <output>/ActionLogs/...'
1028
+ ).option(
1029
+ "--filter <pattern>",
1030
+ "comma-separated list of glob patterns to filter content (e.g. Collections/Pages/**, !ActionLogs/**)"
1031
+ ).option("--site <siteId>", "site id to export (overrides root config)").option(
1032
+ "--database <databaseId>",
1033
+ 'firestore database id (overrides root config, default: "(default)")'
1034
+ ).option("--project <projectId>", "gcp project id (overrides root config)").action(exportData);
1035
+ program.command("import").description(
1036
+ 'imports firestore data from a local directory\n\nUsage examples:\n $ root-cms import --dir export_project_site_20251209t1305\n $ root-cms import --dir export_project_site_20251209t1305 --filter "Collections/Pages/**"'
1037
+ ).option("--dir <directory>", "directory to import from (required)").option(
1038
+ "--filter <pattern>",
1039
+ "comma-separated list of glob patterns to filter content (e.g. Collections/Pages/**, !ActionLogs/**)"
1040
+ ).option("--site <siteId>", "site id to import to (overrides root config)").option(
1041
+ "--database <databaseId>",
1042
+ 'firestore database id (overrides root config, default: "(default)")'
1043
+ ).option("--project <projectId>", "gcp project id (overrides root config)").action(importData);
456
1044
  await program.parseAsync(argv);
457
1045
  }
458
1046
  };
459
1047
  export {
460
1048
  CliRunner,
1049
+ exportData,
461
1050
  generateTypes,
1051
+ importData,
462
1052
  initFirebase
463
1053
  };