firevault 0.1.1-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +363 -0
- package/dist/commands/backup.js +25 -0
- package/dist/commands/changes.js +55 -0
- package/dist/commands/commit.js +30 -0
- package/dist/commands/history.js +41 -0
- package/dist/commands/init.js +19 -0
- package/dist/commands/restoreFirestore.js +100 -0
- package/dist/commands/restoreLocal.js +45 -0
- package/dist/commands/restorePreview.js +102 -0
- package/dist/commands/snapshot.js +24 -0
- package/dist/config/loadConfig.js +53 -0
- package/dist/firestore/exportFirestore.js +15 -0
- package/dist/firestore/firebase.js +39 -0
- package/dist/firestore/stableStringify.js +17 -0
- package/dist/firestore/writeDocument.js +22 -0
- package/dist/git/git.js +155 -0
- package/dist/index.js +26 -0
- package/dist/paths/backupPaths.js +35 -0
- package/docs/architecture.md +182 -0
- package/docs/github-labels.md +18 -0
- package/docs/roadmap.md +138 -0
- package/package.json +57 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { ConfigError, loadConfig } from "../config/loadConfig.js";
|
|
5
|
+
import { GitError, assertInsideGitRepository, showFileAtCommit, } from "../git/git.js";
|
|
6
|
+
import { normalizeDocumentPath } from "../paths/backupPaths.js";
|
|
7
|
+
import { buildLineDiff, printRestorePreview } from "./restorePreview.js";
|
|
8
|
+
export function runRestoreLocal(inputPath, options) {
|
|
9
|
+
if (!options.from) {
|
|
10
|
+
throw new ConfigError("Missing required option: --from <commit>");
|
|
11
|
+
}
|
|
12
|
+
if (!options.confirm) {
|
|
13
|
+
throw new ConfigError("Missing required option: --confirm. Run restore-preview first, then rerun with --confirm to write the local backup file.");
|
|
14
|
+
}
|
|
15
|
+
const config = loadConfig();
|
|
16
|
+
const targetPath = normalizeDocumentPath(inputPath, config.outputDir);
|
|
17
|
+
assertInsideGitRepository();
|
|
18
|
+
const restoredContent = showFileAtCommit(options.from, targetPath);
|
|
19
|
+
const currentExists = existsSync(targetPath);
|
|
20
|
+
const currentContent = currentExists ? readFileSync(targetPath, "utf-8") : undefined;
|
|
21
|
+
const diff = buildLineDiff(currentContent, restoredContent);
|
|
22
|
+
printRestorePreview(targetPath, options.from, currentExists, diff);
|
|
23
|
+
mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
24
|
+
writeFileSync(targetPath, restoredContent);
|
|
25
|
+
console.log("");
|
|
26
|
+
console.log(`Restored local backup file: ${targetPath}`);
|
|
27
|
+
}
|
|
28
|
+
export const restoreLocalCommand = new Command("restore-local")
|
|
29
|
+
.description("Restore a backed-up document from Git into the local backup directory")
|
|
30
|
+
.argument("<path>", "Logical document path or backup file path")
|
|
31
|
+
.option("--from <commit>", "Git commit to restore from")
|
|
32
|
+
.option("--confirm", "Confirm writing the local backup file")
|
|
33
|
+
.action((inputPath, options) => {
|
|
34
|
+
try {
|
|
35
|
+
runRestoreLocal(inputPath, options);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof ConfigError || error instanceof GitError) {
|
|
39
|
+
console.error(error.message);
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { ConfigError, loadConfig } from "../config/loadConfig.js";
|
|
4
|
+
import { GitError, assertInsideGitRepository, showFileAtCommit, } from "../git/git.js";
|
|
5
|
+
import { normalizeDocumentPath } from "../paths/backupPaths.js";
|
|
6
|
+
function normalizeJson(content) {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.stringify(JSON.parse(content), null, 2).split("\n");
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return content.split("\n");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function buildLineDiff(currentContent, restoredContent) {
|
|
15
|
+
const currentLines = currentContent ? normalizeJson(currentContent) : [];
|
|
16
|
+
const restoredLines = normalizeJson(restoredContent);
|
|
17
|
+
const lengths = Array.from({ length: currentLines.length + 1 }, () => Array(restoredLines.length + 1).fill(0));
|
|
18
|
+
const lines = [];
|
|
19
|
+
for (let currentIndex = currentLines.length - 1; currentIndex >= 0; currentIndex -= 1) {
|
|
20
|
+
for (let restoredIndex = restoredLines.length - 1; restoredIndex >= 0; restoredIndex -= 1) {
|
|
21
|
+
if (currentLines[currentIndex] === restoredLines[restoredIndex]) {
|
|
22
|
+
lengths[currentIndex][restoredIndex] =
|
|
23
|
+
lengths[currentIndex + 1][restoredIndex + 1] + 1;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
lengths[currentIndex][restoredIndex] = Math.max(lengths[currentIndex + 1][restoredIndex], lengths[currentIndex][restoredIndex + 1]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
let currentIndex = 0;
|
|
31
|
+
let restoredIndex = 0;
|
|
32
|
+
while (currentIndex < currentLines.length && restoredIndex < restoredLines.length) {
|
|
33
|
+
if (currentLines[currentIndex] === restoredLines[restoredIndex]) {
|
|
34
|
+
lines.push(` ${currentLines[currentIndex]}`);
|
|
35
|
+
currentIndex += 1;
|
|
36
|
+
restoredIndex += 1;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (lengths[currentIndex + 1][restoredIndex] >= lengths[currentIndex][restoredIndex + 1]) {
|
|
40
|
+
lines.push(`- ${currentLines[currentIndex]}`);
|
|
41
|
+
currentIndex += 1;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
lines.push(`+ ${restoredLines[restoredIndex]}`);
|
|
45
|
+
restoredIndex += 1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
while (currentIndex < currentLines.length) {
|
|
49
|
+
lines.push(`- ${currentLines[currentIndex]}`);
|
|
50
|
+
currentIndex += 1;
|
|
51
|
+
}
|
|
52
|
+
while (restoredIndex < restoredLines.length) {
|
|
53
|
+
lines.push(`+ ${restoredLines[restoredIndex]}`);
|
|
54
|
+
restoredIndex += 1;
|
|
55
|
+
}
|
|
56
|
+
return lines;
|
|
57
|
+
}
|
|
58
|
+
export function printRestorePreview(targetPath, sourceCommit, currentExists, diff) {
|
|
59
|
+
console.log(`Target: ${targetPath}`);
|
|
60
|
+
console.log(`Source commit: ${sourceCommit}`);
|
|
61
|
+
console.log(`Current file exists: ${currentExists ? "yes" : "no"}`);
|
|
62
|
+
console.log("");
|
|
63
|
+
console.log("Diff:");
|
|
64
|
+
console.log("");
|
|
65
|
+
if (diff.length === 0) {
|
|
66
|
+
console.log("No changes.");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
for (const line of diff) {
|
|
70
|
+
console.log(line);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function runRestorePreview(inputPath, options) {
|
|
74
|
+
if (!options.from) {
|
|
75
|
+
throw new ConfigError("Missing required option: --from <commit>");
|
|
76
|
+
}
|
|
77
|
+
const config = loadConfig();
|
|
78
|
+
const targetPath = normalizeDocumentPath(inputPath, config.outputDir);
|
|
79
|
+
assertInsideGitRepository();
|
|
80
|
+
const restoredContent = showFileAtCommit(options.from, targetPath);
|
|
81
|
+
const currentExists = existsSync(targetPath);
|
|
82
|
+
const currentContent = currentExists ? readFileSync(targetPath, "utf-8") : undefined;
|
|
83
|
+
const diff = buildLineDiff(currentContent, restoredContent);
|
|
84
|
+
printRestorePreview(targetPath, options.from, currentExists, diff);
|
|
85
|
+
}
|
|
86
|
+
export const restorePreviewCommand = new Command("restore-preview")
|
|
87
|
+
.description("Preview restoring a backed-up document from Git")
|
|
88
|
+
.argument("<path>", "Logical document path or backup file path")
|
|
89
|
+
.requiredOption("--from <commit>", "Git commit to restore from")
|
|
90
|
+
.action((inputPath, options) => {
|
|
91
|
+
try {
|
|
92
|
+
runRestorePreview(inputPath, options);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error instanceof ConfigError || error instanceof GitError) {
|
|
96
|
+
console.error(error.message);
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { ConfigError } from "../config/loadConfig.js";
|
|
3
|
+
import { GitError } from "../git/git.js";
|
|
4
|
+
import { runBackup } from "./backup.js";
|
|
5
|
+
import { runCommit } from "./commit.js";
|
|
6
|
+
export async function runSnapshot(backup = runBackup, commit = runCommit) {
|
|
7
|
+
await backup();
|
|
8
|
+
commit();
|
|
9
|
+
}
|
|
10
|
+
export const snapshotCommand = new Command("snapshot")
|
|
11
|
+
.description("Back up Firestore and commit backup changes locally")
|
|
12
|
+
.action(async () => {
|
|
13
|
+
try {
|
|
14
|
+
await runSnapshot();
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error instanceof ConfigError || error instanceof GitError) {
|
|
18
|
+
console.error(error.message);
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
export class ConfigError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "ConfigError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function isRecord(value) {
|
|
9
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
function requireString(config, field) {
|
|
12
|
+
const value = config[field];
|
|
13
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
14
|
+
throw new ConfigError(`Invalid firevault.config.json: "${field}" is required and must be a string.`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function requireStringArray(config, field) {
|
|
19
|
+
const value = config[field];
|
|
20
|
+
if (!Array.isArray(value) ||
|
|
21
|
+
value.length === 0 ||
|
|
22
|
+
value.some((item) => typeof item !== "string" || item.trim() === "")) {
|
|
23
|
+
throw new ConfigError(`Invalid firevault.config.json: "${field}" is required and must include at least one collection name.`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
export function loadConfig() {
|
|
28
|
+
const configPath = "firevault.config.json";
|
|
29
|
+
if (!existsSync(configPath)) {
|
|
30
|
+
throw new ConfigError("Missing firevault.config.json. Run `firevault init` first.");
|
|
31
|
+
}
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
35
|
+
parsed = JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof SyntaxError) {
|
|
39
|
+
throw new ConfigError("Invalid firevault.config.json: file is not valid JSON.");
|
|
40
|
+
}
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
if (!isRecord(parsed)) {
|
|
44
|
+
throw new ConfigError("Invalid firevault.config.json: expected a JSON object.");
|
|
45
|
+
}
|
|
46
|
+
const config = {
|
|
47
|
+
projectId: requireString(parsed, "projectId"),
|
|
48
|
+
serviceAccountPath: requireString(parsed, "serviceAccountPath"),
|
|
49
|
+
outputDir: requireString(parsed, "outputDir"),
|
|
50
|
+
collections: requireStringArray(parsed, "collections"),
|
|
51
|
+
};
|
|
52
|
+
return config;
|
|
53
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getFirestore } from "./firebase.js";
|
|
4
|
+
import { stableStringify } from "./stableStringify.js";
|
|
5
|
+
export async function exportCollection(outputDir, collectionName) {
|
|
6
|
+
const db = getFirestore();
|
|
7
|
+
const snapshot = await db.collection(collectionName).get();
|
|
8
|
+
const collectionDir = path.join(outputDir, collectionName);
|
|
9
|
+
mkdirSync(collectionDir, { recursive: true });
|
|
10
|
+
for (const doc of snapshot.docs) {
|
|
11
|
+
const filePath = path.join(collectionDir, `${doc.id}.json`);
|
|
12
|
+
writeFileSync(filePath, stableStringify(doc.data()));
|
|
13
|
+
}
|
|
14
|
+
console.log(`Exported ${snapshot.docs.length} docs from ${collectionName}`);
|
|
15
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import admin from "firebase-admin";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { ConfigError, loadConfig } from "../config/loadConfig.js";
|
|
4
|
+
let initialized = false;
|
|
5
|
+
export function getFirestore() {
|
|
6
|
+
if (!initialized) {
|
|
7
|
+
const config = loadConfig();
|
|
8
|
+
let serviceAccount;
|
|
9
|
+
const emulatorHost = process.env.FIRESTORE_EMULATOR_HOST;
|
|
10
|
+
try {
|
|
11
|
+
if (emulatorHost) {
|
|
12
|
+
admin.initializeApp({
|
|
13
|
+
projectId: config.projectId,
|
|
14
|
+
});
|
|
15
|
+
initialized = true;
|
|
16
|
+
return admin.firestore();
|
|
17
|
+
}
|
|
18
|
+
if (!existsSync(config.serviceAccountPath)) {
|
|
19
|
+
throw new ConfigError(`Service account file not found: ${config.serviceAccountPath}`);
|
|
20
|
+
}
|
|
21
|
+
serviceAccount = JSON.parse(readFileSync(config.serviceAccountPath, "utf-8"));
|
|
22
|
+
admin.initializeApp({
|
|
23
|
+
credential: admin.credential.cert(serviceAccount),
|
|
24
|
+
projectId: config.projectId,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error instanceof SyntaxError) {
|
|
29
|
+
throw new ConfigError(`Invalid service account file: ${config.serviceAccountPath}`);
|
|
30
|
+
}
|
|
31
|
+
if (error instanceof Error && error.message.includes("Failed to parse")) {
|
|
32
|
+
throw new ConfigError(`Invalid service account file: ${config.serviceAccountPath}`);
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
initialized = true;
|
|
37
|
+
}
|
|
38
|
+
return admin.firestore();
|
|
39
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
function sortObject(value) {
|
|
2
|
+
if (Array.isArray(value)) {
|
|
3
|
+
return value.map(sortObject);
|
|
4
|
+
}
|
|
5
|
+
if (value && typeof value === "object") {
|
|
6
|
+
return Object.keys(value)
|
|
7
|
+
.sort()
|
|
8
|
+
.reduce((result, key) => {
|
|
9
|
+
result[key] = sortObject(value[key]);
|
|
10
|
+
return result;
|
|
11
|
+
}, {});
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
export function stableStringify(value) {
|
|
16
|
+
return JSON.stringify(sortObject(value), null, 2);
|
|
17
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getFirestore } from "./firebase.js";
|
|
2
|
+
export class FirestoreError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "FirestoreError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export async function writeDocument(collection, documentId, data) {
|
|
9
|
+
try {
|
|
10
|
+
const db = getFirestore();
|
|
11
|
+
await db.collection(collection).doc(documentId).set(data);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error instanceof FirestoreError) {
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
if (error instanceof Error) {
|
|
18
|
+
throw new FirestoreError(`Failed to write Firestore document ${collection}/${documentId}: ${error.message}`);
|
|
19
|
+
}
|
|
20
|
+
throw new FirestoreError(`Failed to write Firestore document ${collection}/${documentId}.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
package/dist/git/git.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
export class GitError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "GitError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function runGit(args) {
|
|
9
|
+
try {
|
|
10
|
+
return execFileSync("git", args, {
|
|
11
|
+
encoding: "utf-8",
|
|
12
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error &&
|
|
17
|
+
typeof error === "object" &&
|
|
18
|
+
"stderr" in error &&
|
|
19
|
+
typeof error.stderr === "string" &&
|
|
20
|
+
error.stderr.trim() !== "") {
|
|
21
|
+
throw new GitError(error.stderr.trim());
|
|
22
|
+
}
|
|
23
|
+
throw new GitError("Git command failed.");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function assertInsideGitRepository() {
|
|
27
|
+
let result;
|
|
28
|
+
try {
|
|
29
|
+
result = runGit(["rev-parse", "--is-inside-work-tree"]).trim();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new GitError("Current directory is not inside a Git repository.");
|
|
33
|
+
}
|
|
34
|
+
if (result !== "true") {
|
|
35
|
+
throw new GitError("Current directory is not inside a Git repository.");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function hasChangesUnder(path) {
|
|
39
|
+
return runGit(["status", "--porcelain", "--", path]).trim() !== "";
|
|
40
|
+
}
|
|
41
|
+
function emptyFileChanges() {
|
|
42
|
+
return {
|
|
43
|
+
added: [],
|
|
44
|
+
modified: [],
|
|
45
|
+
deleted: [],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function addFileChange(changes, status, filePath) {
|
|
49
|
+
if (status.includes("D")) {
|
|
50
|
+
changes.deleted.push(filePath);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (status.includes("A") || status === "??") {
|
|
54
|
+
changes.added.push(filePath);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
changes.modified.push(filePath);
|
|
58
|
+
}
|
|
59
|
+
function dedupeSorted(paths) {
|
|
60
|
+
return [...new Set(paths)].sort();
|
|
61
|
+
}
|
|
62
|
+
function normalizeFileChanges(changes) {
|
|
63
|
+
const deleted = new Set(changes.deleted);
|
|
64
|
+
const added = new Set(changes.added.filter((path) => !deleted.has(path)));
|
|
65
|
+
const modified = new Set(changes.modified.filter((path) => !deleted.has(path) && !added.has(path)));
|
|
66
|
+
return {
|
|
67
|
+
added: dedupeSorted([...added]),
|
|
68
|
+
modified: dedupeSorted([...modified]),
|
|
69
|
+
deleted: dedupeSorted([...deleted]),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function getWorkingTreeChanges(path) {
|
|
73
|
+
const output = runGit(["status", "--porcelain", "--", path]);
|
|
74
|
+
const changes = emptyFileChanges();
|
|
75
|
+
for (const line of output.split("\n")) {
|
|
76
|
+
if (line.trim() === "") {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const status = line.slice(0, 2);
|
|
80
|
+
const filePath = line.slice(3).trim();
|
|
81
|
+
addFileChange(changes, status, filePath);
|
|
82
|
+
}
|
|
83
|
+
return normalizeFileChanges(changes);
|
|
84
|
+
}
|
|
85
|
+
export function getHistoricalChanges(path, since) {
|
|
86
|
+
const output = runGit([
|
|
87
|
+
"log",
|
|
88
|
+
`--since=${since}`,
|
|
89
|
+
"--name-status",
|
|
90
|
+
"--format=",
|
|
91
|
+
"--",
|
|
92
|
+
path,
|
|
93
|
+
]);
|
|
94
|
+
const changes = emptyFileChanges();
|
|
95
|
+
for (const line of output.split("\n")) {
|
|
96
|
+
if (line.trim() === "") {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const [status, filePath] = line.split("\t");
|
|
100
|
+
if (!status || !filePath) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
addFileChange(changes, status, filePath);
|
|
104
|
+
}
|
|
105
|
+
return normalizeFileChanges(changes);
|
|
106
|
+
}
|
|
107
|
+
export function getHistory(path, includeChangedFileCount) {
|
|
108
|
+
const format = "%h%x09%cs%x09%s";
|
|
109
|
+
const output = runGit(["log", `--format=${format}`, "--name-only", "--", path]);
|
|
110
|
+
const entries = [];
|
|
111
|
+
let current;
|
|
112
|
+
let changedFiles = new Set();
|
|
113
|
+
function finishCurrent() {
|
|
114
|
+
if (!current) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (includeChangedFileCount) {
|
|
118
|
+
current.changedFileCount = changedFiles.size;
|
|
119
|
+
}
|
|
120
|
+
entries.push(current);
|
|
121
|
+
}
|
|
122
|
+
for (const line of output.split("\n")) {
|
|
123
|
+
if (line.trim() === "") {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const parts = line.split("\t");
|
|
127
|
+
if (parts.length >= 3) {
|
|
128
|
+
finishCurrent();
|
|
129
|
+
current = {
|
|
130
|
+
shortSha: parts[0],
|
|
131
|
+
date: parts[1],
|
|
132
|
+
message: parts.slice(2).join("\t"),
|
|
133
|
+
};
|
|
134
|
+
changedFiles = new Set();
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
changedFiles.add(line.trim());
|
|
138
|
+
}
|
|
139
|
+
finishCurrent();
|
|
140
|
+
return entries;
|
|
141
|
+
}
|
|
142
|
+
export function showFileAtCommit(commit, path) {
|
|
143
|
+
try {
|
|
144
|
+
return runGit(["show", `${commit}:${path}`]);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
throw new GitError(`File not found at ${commit}: ${path}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export function stagePath(path) {
|
|
151
|
+
runGit(["add", "--", path]);
|
|
152
|
+
}
|
|
153
|
+
export function commitPath(message, path) {
|
|
154
|
+
runGit(["commit", "-m", message, "--", path]);
|
|
155
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { initCommand } from "./commands/init.js";
|
|
4
|
+
import { backupCommand } from "./commands/backup.js";
|
|
5
|
+
import { commitCommand } from "./commands/commit.js";
|
|
6
|
+
import { snapshotCommand } from "./commands/snapshot.js";
|
|
7
|
+
import { changesCommand } from "./commands/changes.js";
|
|
8
|
+
import { historyCommand } from "./commands/history.js";
|
|
9
|
+
import { restorePreviewCommand } from "./commands/restorePreview.js";
|
|
10
|
+
import { restoreLocalCommand } from "./commands/restoreLocal.js";
|
|
11
|
+
import { restoreFirestoreCommand } from "./commands/restoreFirestore.js";
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program
|
|
14
|
+
.name("firevault")
|
|
15
|
+
.description("Git-native backup, diff, and recovery tooling for Firestore.")
|
|
16
|
+
.version("0.1.0");
|
|
17
|
+
program.addCommand(initCommand);
|
|
18
|
+
program.addCommand(backupCommand);
|
|
19
|
+
program.addCommand(commitCommand);
|
|
20
|
+
program.addCommand(snapshotCommand);
|
|
21
|
+
program.addCommand(changesCommand);
|
|
22
|
+
program.addCommand(historyCommand);
|
|
23
|
+
program.addCommand(restorePreviewCommand);
|
|
24
|
+
program.addCommand(restoreLocalCommand);
|
|
25
|
+
program.addCommand(restoreFirestoreCommand);
|
|
26
|
+
program.parse();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export function normalizeSlashes(value) {
|
|
3
|
+
return value.replaceAll("\\", "/").replace(/^\.?\//, "").replace(/\/+$/, "");
|
|
4
|
+
}
|
|
5
|
+
export function normalizeHistoryPath(input, outputDir) {
|
|
6
|
+
const normalizedInput = normalizeSlashes(input);
|
|
7
|
+
const normalizedOutputDir = normalizeSlashes(outputDir);
|
|
8
|
+
if (normalizedInput === normalizedOutputDir ||
|
|
9
|
+
normalizedInput.startsWith(`${normalizedOutputDir}/`)) {
|
|
10
|
+
return {
|
|
11
|
+
path: normalizedInput,
|
|
12
|
+
isCollection: !normalizedInput.endsWith(".json"),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
const parts = normalizedInput.split("/");
|
|
16
|
+
if (parts.length === 1) {
|
|
17
|
+
return {
|
|
18
|
+
path: path.posix.join(normalizedOutputDir, parts[0]),
|
|
19
|
+
isCollection: true,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
path: path.posix.join(normalizedOutputDir, parts[0], `${parts.slice(1).join("/")}.json`),
|
|
24
|
+
isCollection: false,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function normalizeDocumentPath(input, outputDir) {
|
|
28
|
+
const normalizedInput = normalizeSlashes(input);
|
|
29
|
+
const normalizedOutputDir = normalizeSlashes(outputDir);
|
|
30
|
+
if (normalizedInput.startsWith(`${normalizedOutputDir}/`)) {
|
|
31
|
+
return normalizedInput;
|
|
32
|
+
}
|
|
33
|
+
const parts = normalizedInput.split("/");
|
|
34
|
+
return path.posix.join(normalizedOutputDir, parts[0], `${parts.slice(1).join("/")}.json`);
|
|
35
|
+
}
|