firevault 0.1.1-beta.2 → 0.2.0-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 +11 -1
- package/README.md +42 -15
- package/dist/commands/backup.js +1 -1
- package/dist/commands/changes.js +3 -3
- package/dist/commands/commit.js +4 -4
- package/dist/commands/history.js +2 -2
- package/dist/commands/init.js +182 -32
- package/dist/commands/restoreFirestore.js +6 -5
- package/dist/commands/restoreLocal.js +8 -7
- package/dist/commands/restorePreview.js +6 -5
- package/dist/config/loadConfig.js +37 -9
- package/dist/firestore/firebase.js +2 -2
- package/dist/git/git.js +24 -23
- package/dist/index.js +1 -1
- package/dist/init/detectFirebaseProject.js +79 -0
- package/dist/init/detectServiceAccount.js +29 -0
- package/dist/init/listCollections.js +33 -0
- package/docs/architecture.md +49 -28
- package/docs/roadmap.md +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.2.0-beta.0 - Unreleased
|
|
4
|
+
|
|
5
|
+
Breaking prerelease change:
|
|
6
|
+
|
|
7
|
+
- Firevault now uses `.firevault/config.json` and a dedicated `.firevault` recovery workspace.
|
|
8
|
+
- Firevault backup history now lives in the `.firevault` Git repository instead of the parent app repo.
|
|
9
|
+
- Config-relative paths now resolve from `.firevault/`.
|
|
10
|
+
- `firevault init` no longer creates root `firevault.config.json`.
|
|
11
|
+
- `firestore-backups/` is no longer ignored inside `.firevault/` by default.
|
|
12
|
+
|
|
13
|
+
## 0.1.1-beta.1
|
|
4
14
|
|
|
5
15
|
- Added guided `firevault init` setup with prompts for project ID, service account path, output directory, and collections.
|
|
6
16
|
- Added init Git safety checks, `--force`, and `--yes`.
|
package/README.md
CHANGED
|
@@ -25,10 +25,12 @@ Current scope:
|
|
|
25
25
|
Current export shape:
|
|
26
26
|
|
|
27
27
|
```txt
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
.firevault/
|
|
29
|
+
config.json
|
|
30
|
+
firestore-backups/
|
|
31
|
+
users/
|
|
32
|
+
abc123.json
|
|
33
|
+
def456.json
|
|
32
34
|
```
|
|
33
35
|
|
|
34
36
|
The immediate priority is trustworthy document-level recovery: clear previews, explicit confirmation, and no broad destructive restore flows.
|
|
@@ -36,10 +38,11 @@ The immediate priority is trustworthy document-level recovery: clear previews, e
|
|
|
36
38
|
## Quick Start
|
|
37
39
|
|
|
38
40
|
```bash
|
|
39
|
-
npm install -g firevault
|
|
41
|
+
npm install -g firevault@next
|
|
42
|
+
cd my-app
|
|
40
43
|
```
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
Firevault 0.2 uses `.firevault/config.json` and a dedicated `.firevault` recovery workspace. The app repo stays focused on application source code; `.firevault/` contains Firevault config, backup JSON, credentials, and its own Git history.
|
|
43
46
|
|
|
44
47
|
Run guided setup:
|
|
45
48
|
|
|
@@ -47,7 +50,11 @@ Run guided setup:
|
|
|
47
50
|
firevault init
|
|
48
51
|
```
|
|
49
52
|
|
|
50
|
-
`firevault init` asks for your Firebase project ID, service account path, output directory, and collections. It
|
|
53
|
+
`firevault init` asks for your Firebase project ID, service account path, output directory, and collections. It creates `.firevault/`, writes `.firevault/config.json`, writes `.firevault/.gitignore`, can initialize Git inside `.firevault/`, and adds `.firevault/` to the parent app repo `.gitignore` when the parent is a Git repo.
|
|
54
|
+
|
|
55
|
+
During setup, Firevault looks for likely Firebase project IDs in local files such as `.env.local`, `.env.development`, `firebase.json`, and common Firebase config files. Detection is best-effort and transparent: if Firevault finds candidates, it shows where they came from and lets you accept one or enter a value manually.
|
|
56
|
+
|
|
57
|
+
Firevault also looks for likely local service account files such as `serviceAccountKey.json`, `service-account.json`, `firebase-service-account.json`, and `credentials/firebase.json`. It never prints private key contents. If you select a service account path, Firevault adds that path to `.gitignore`.
|
|
51
58
|
|
|
52
59
|
After you enter a project ID, Firevault prints the direct Firebase Console URL for that project's Admin SDK service account page:
|
|
53
60
|
|
|
@@ -58,12 +65,14 @@ https://console.firebase.google.com/project/your-project-id/settings/serviceacco
|
|
|
58
65
|
|
|
59
66
|
Download the JSON key and save it as:
|
|
60
67
|
|
|
61
|
-
|
|
68
|
+
.firevault/serviceAccountKey.json
|
|
62
69
|
```
|
|
63
70
|
|
|
64
71
|
Firevault does not create service accounts, open a browser, run `gcloud`, or authenticate against Firebase during setup.
|
|
65
72
|
|
|
66
|
-
|
|
73
|
+
If the selected service account file already exists, Firevault can optionally connect to Firestore and list top-level collections so you can choose which ones to back up. If the file is missing or Firebase access fails, init continues and you can enter collections manually.
|
|
74
|
+
|
|
75
|
+
Generated `.firevault/config.json`:
|
|
67
76
|
|
|
68
77
|
```json
|
|
69
78
|
{
|
|
@@ -80,6 +89,8 @@ Take a snapshot:
|
|
|
80
89
|
firevault snapshot
|
|
81
90
|
```
|
|
82
91
|
|
|
92
|
+
Operational commands discover the nearest `.firevault/config.json`, so they work from the app root or from inside `.firevault/`.
|
|
93
|
+
|
|
83
94
|
Example output:
|
|
84
95
|
|
|
85
96
|
```txt
|
|
@@ -173,6 +184,12 @@ firevault snapshot
|
|
|
173
184
|
|
|
174
185
|
Firevault operates against an existing Firebase project using a service account.
|
|
175
186
|
|
|
187
|
+
Expected config path:
|
|
188
|
+
|
|
189
|
+
```txt
|
|
190
|
+
.firevault/config.json
|
|
191
|
+
```
|
|
192
|
+
|
|
176
193
|
Expected config shape:
|
|
177
194
|
|
|
178
195
|
```json
|
|
@@ -186,19 +203,28 @@ Expected config shape:
|
|
|
186
203
|
|
|
187
204
|
Notes:
|
|
188
205
|
|
|
189
|
-
-
|
|
190
|
-
- `
|
|
206
|
+
- paths are relative to `.firevault/`,
|
|
207
|
+
- `serviceAccountPath` points to a local Firebase service account JSON file,
|
|
208
|
+
- `outputDir` is where Firestore documents are written inside `.firevault/`,
|
|
191
209
|
- `collections` controls which top-level Firestore collections are exported.
|
|
192
210
|
- Service account files must not be committed.
|
|
193
211
|
|
|
194
|
-
|
|
212
|
+
Parent app repo `.gitignore`:
|
|
213
|
+
|
|
214
|
+
```gitignore
|
|
215
|
+
.firevault/
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`.firevault/.gitignore`:
|
|
195
219
|
|
|
196
220
|
```gitignore
|
|
197
221
|
serviceAccountKey.json
|
|
198
|
-
firestore-
|
|
222
|
+
firestore-debug.log
|
|
223
|
+
.env
|
|
224
|
+
.env.*
|
|
199
225
|
```
|
|
200
226
|
|
|
201
|
-
`firevault init` adds these safety entries automatically. `firestore-backups/` is
|
|
227
|
+
`firevault init` adds these safety entries automatically. In the 0.2 workspace model, `firestore-backups/` is not ignored inside `.firevault/` because the `.firevault` Git repo exists to track backup history.
|
|
202
228
|
|
|
203
229
|
## Commands
|
|
204
230
|
|
|
@@ -256,7 +282,7 @@ JSON output is stable:
|
|
|
256
282
|
|
|
257
283
|
`firevault backup` exports configured Firestore collections to deterministic local JSON files. It does not stage or commit anything.
|
|
258
284
|
|
|
259
|
-
`firevault commit`
|
|
285
|
+
`firevault commit` commits inside the `.firevault` Git repository.
|
|
260
286
|
|
|
261
287
|
Behavior:
|
|
262
288
|
|
|
@@ -264,6 +290,7 @@ Behavior:
|
|
|
264
290
|
- exits successfully if no backup changes exist,
|
|
265
291
|
- stages only the configured `outputDir`,
|
|
266
292
|
- creates a local commit with message `backup: <ISO timestamp>`,
|
|
293
|
+
- never stages app source files from the parent repo,
|
|
267
294
|
- never pushes.
|
|
268
295
|
|
|
269
296
|
Keep `serviceAccountKey.json` ignored so credentials cannot be committed by this workflow or by manual Git usage.
|
package/dist/commands/backup.js
CHANGED
|
@@ -4,7 +4,7 @@ import { exportCollection } from "../firestore/exportFirestore.js";
|
|
|
4
4
|
export async function runBackup() {
|
|
5
5
|
const config = loadConfig();
|
|
6
6
|
for (const collection of config.collections) {
|
|
7
|
-
await exportCollection(config.
|
|
7
|
+
await exportCollection(config.outputDirPath, collection);
|
|
8
8
|
}
|
|
9
9
|
console.log("Backup complete.");
|
|
10
10
|
}
|
package/dist/commands/changes.js
CHANGED
|
@@ -31,10 +31,10 @@ function normalizeLastWindow(last) {
|
|
|
31
31
|
}
|
|
32
32
|
export function runChanges(last) {
|
|
33
33
|
const config = loadConfig();
|
|
34
|
-
assertInsideGitRepository();
|
|
34
|
+
assertInsideGitRepository(config.workspaceRoot);
|
|
35
35
|
const changes = last
|
|
36
|
-
? getHistoricalChanges(config.outputDir, normalizeLastWindow(last))
|
|
37
|
-
: getWorkingTreeChanges(config.outputDir);
|
|
36
|
+
? getHistoricalChanges(config.outputDir, normalizeLastWindow(last), config.workspaceRoot)
|
|
37
|
+
: getWorkingTreeChanges(config.outputDir, config.workspaceRoot);
|
|
38
38
|
printChanges(changes);
|
|
39
39
|
}
|
|
40
40
|
export const changesCommand = new Command("changes")
|
package/dist/commands/commit.js
CHANGED
|
@@ -3,14 +3,14 @@ import { ConfigError, loadConfig } from "../config/loadConfig.js";
|
|
|
3
3
|
import { GitError, assertInsideGitRepository, commitPath, hasChangesUnder, stagePath, } from "../git/git.js";
|
|
4
4
|
export function runCommit() {
|
|
5
5
|
const config = loadConfig();
|
|
6
|
-
assertInsideGitRepository();
|
|
7
|
-
if (!hasChangesUnder(config.outputDir)) {
|
|
6
|
+
assertInsideGitRepository(config.workspaceRoot);
|
|
7
|
+
if (!hasChangesUnder(config.outputDir, config.workspaceRoot)) {
|
|
8
8
|
console.log(`No changes found under ${config.outputDir}.`);
|
|
9
9
|
return;
|
|
10
10
|
}
|
|
11
|
-
stagePath(config.outputDir);
|
|
11
|
+
stagePath(config.outputDir, config.workspaceRoot);
|
|
12
12
|
const message = `backup: ${new Date().toISOString()}`;
|
|
13
|
-
commitPath(message, config.outputDir);
|
|
13
|
+
commitPath(message, config.outputDir, config.workspaceRoot);
|
|
14
14
|
console.log(`Created commit: ${message}`);
|
|
15
15
|
}
|
|
16
16
|
export const commitCommand = new Command("commit")
|
package/dist/commands/history.js
CHANGED
|
@@ -15,8 +15,8 @@ function printHistory(entries) {
|
|
|
15
15
|
export function runHistory(inputPath) {
|
|
16
16
|
const config = loadConfig();
|
|
17
17
|
const normalizedPath = normalizeHistoryPath(inputPath, config.outputDir);
|
|
18
|
-
assertInsideGitRepository();
|
|
19
|
-
const entries = getHistory(normalizedPath.path, normalizedPath.isCollection);
|
|
18
|
+
assertInsideGitRepository(config.workspaceRoot);
|
|
19
|
+
const entries = getHistory(normalizedPath.path, normalizedPath.isCollection, config.workspaceRoot);
|
|
20
20
|
if (entries.length === 0) {
|
|
21
21
|
console.log(`No history found for ${normalizedPath.path}.`);
|
|
22
22
|
return;
|
package/dist/commands/init.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
3
|
import { stdin as input, stdout as output } from "node:process";
|
|
4
|
-
import { appendFileSync, existsSync, readFileSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
5
6
|
import { GitError, hasWorkingTreeChanges, initGitRepository, isInsideGitRepository, } from "../git/git.js";
|
|
7
|
+
import { detectFirebaseProjectCandidates, uniqueProjectIds, } from "../init/detectFirebaseProject.js";
|
|
8
|
+
import { detectServiceAccountPaths } from "../init/detectServiceAccount.js";
|
|
9
|
+
import { listFirestoreCollections } from "../init/listCollections.js";
|
|
6
10
|
const defaultConfig = {
|
|
7
11
|
projectId: "your-firebase-project-id",
|
|
8
12
|
serviceAccountPath: "./serviceAccountKey.json",
|
|
9
13
|
outputDir: "firestore-backups",
|
|
10
14
|
collections: ["users"],
|
|
11
15
|
};
|
|
16
|
+
const workspaceDirName = ".firevault";
|
|
17
|
+
const configFileName = "config.json";
|
|
12
18
|
function validateConfig(config) {
|
|
13
19
|
if (config.projectId.trim() === "") {
|
|
14
20
|
throw new Error("Firebase project ID is required.");
|
|
@@ -29,7 +35,8 @@ function validateConfig(config) {
|
|
|
29
35
|
function parseCollections(value) {
|
|
30
36
|
return value
|
|
31
37
|
.split(",")
|
|
32
|
-
.map((collection) => collection.trim())
|
|
38
|
+
.map((collection) => collection.trim())
|
|
39
|
+
.filter((collection) => collection !== "");
|
|
33
40
|
}
|
|
34
41
|
function getServiceAccountUrl(projectId) {
|
|
35
42
|
return `https://console.firebase.google.com/project/${encodeURIComponent(projectId)}/settings/serviceaccounts/adminsdk`;
|
|
@@ -45,29 +52,156 @@ function printServiceAccountGuidance(projectId, serviceAccountPath) {
|
|
|
45
52
|
console.log(serviceAccountPath);
|
|
46
53
|
console.log("");
|
|
47
54
|
}
|
|
48
|
-
function printMissingServiceAccountInfo(serviceAccountPath) {
|
|
55
|
+
function printMissingServiceAccountInfo(serviceAccountPath, serviceAccountDisplayPath) {
|
|
49
56
|
if (existsSync(serviceAccountPath)) {
|
|
50
57
|
return;
|
|
51
58
|
}
|
|
52
|
-
console.log(`Service account file does not exist yet: ${
|
|
59
|
+
console.log(`Service account file does not exist yet: ${serviceAccountDisplayPath}`);
|
|
53
60
|
console.log("That is expected before downloading the Firebase Admin SDK key.");
|
|
54
|
-
console.log(`Save the downloaded JSON key at: ${
|
|
61
|
+
console.log(`Save the downloaded JSON key at: ${serviceAccountDisplayPath}`);
|
|
55
62
|
console.log("");
|
|
56
63
|
}
|
|
57
|
-
|
|
64
|
+
function gitignorePathFor(filePath) {
|
|
65
|
+
return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
66
|
+
}
|
|
67
|
+
function printDetectedProjectCandidates(candidates) {
|
|
68
|
+
if (candidates.length === 0) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
console.log("Detected Firebase project IDs:");
|
|
72
|
+
console.log("");
|
|
73
|
+
candidates.forEach((candidate, index) => {
|
|
74
|
+
console.log(`${index + 1}. ${candidate.projectId} from ${candidate.source}`);
|
|
75
|
+
});
|
|
76
|
+
console.log("");
|
|
77
|
+
}
|
|
78
|
+
async function promptForProjectId(rl, candidates) {
|
|
79
|
+
const projectIds = uniqueProjectIds(candidates);
|
|
80
|
+
if (projectIds.length === 0) {
|
|
81
|
+
return (await rl.question("Firebase project ID: ")).trim();
|
|
82
|
+
}
|
|
83
|
+
printDetectedProjectCandidates(candidates);
|
|
84
|
+
if (projectIds.length === 1) {
|
|
85
|
+
return (await rl.question(`Firebase project ID (${projectIds[0]}): `)).trim() || projectIds[0];
|
|
86
|
+
}
|
|
87
|
+
const answer = (await rl.question("Select Firebase project ID by number or enter one manually: ")).trim();
|
|
88
|
+
const selection = Number(answer);
|
|
89
|
+
if (Number.isInteger(selection) && selection >= 1 && selection <= candidates.length) {
|
|
90
|
+
return candidates[selection - 1].projectId;
|
|
91
|
+
}
|
|
92
|
+
return answer;
|
|
93
|
+
}
|
|
94
|
+
function suggestedServiceAccountPath(detectedPaths) {
|
|
95
|
+
return detectedPaths[0] ?? defaultConfig.serviceAccountPath;
|
|
96
|
+
}
|
|
97
|
+
function printDetectedServiceAccounts(detectedPaths) {
|
|
98
|
+
if (detectedPaths.length === 0) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
console.log("Detected possible service account files:");
|
|
102
|
+
console.log("");
|
|
103
|
+
detectedPaths.forEach((filePath, index) => {
|
|
104
|
+
console.log(`${index + 1}. ${filePath}`);
|
|
105
|
+
});
|
|
106
|
+
console.log("");
|
|
107
|
+
}
|
|
108
|
+
async function promptForServiceAccountPath(rl, detectedPaths) {
|
|
109
|
+
printDetectedServiceAccounts(detectedPaths);
|
|
110
|
+
const suggestedPath = suggestedServiceAccountPath(detectedPaths);
|
|
111
|
+
const answer = (await rl.question(`Service account path (${suggestedPath}): `)).trim();
|
|
112
|
+
const selection = Number(answer);
|
|
113
|
+
if (detectedPaths.length > 0 &&
|
|
114
|
+
Number.isInteger(selection) &&
|
|
115
|
+
selection >= 1 &&
|
|
116
|
+
selection <= detectedPaths.length) {
|
|
117
|
+
return detectedPaths[selection - 1];
|
|
118
|
+
}
|
|
119
|
+
return answer || suggestedPath;
|
|
120
|
+
}
|
|
121
|
+
function parseSelectedCollections(inputValue, detectedCollections) {
|
|
122
|
+
const selectedCollections = [];
|
|
123
|
+
for (const item of inputValue.split(",")) {
|
|
124
|
+
const value = item.trim();
|
|
125
|
+
if (value === "") {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const selection = Number(value);
|
|
129
|
+
if (Number.isInteger(selection) &&
|
|
130
|
+
selection >= 1 &&
|
|
131
|
+
selection <= detectedCollections.length) {
|
|
132
|
+
selectedCollections.push(detectedCollections[selection - 1]);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
selectedCollections.push(value);
|
|
136
|
+
}
|
|
137
|
+
return [...new Set(selectedCollections)];
|
|
138
|
+
}
|
|
139
|
+
async function promptForCollectionListing(rl, projectId, serviceAccountPath, serviceAccountDisplayPath) {
|
|
140
|
+
if (!existsSync(serviceAccountPath)) {
|
|
141
|
+
console.log(`Service account file is not present, so collection detection is skipped: ${serviceAccountDisplayPath}`);
|
|
142
|
+
console.log("");
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
const answer = (await rl.question("Try to list Firestore collections with this service account? (y/N): "))
|
|
146
|
+
.trim()
|
|
147
|
+
.toLowerCase();
|
|
148
|
+
if (answer !== "y" && answer !== "yes") {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
console.log("Connecting to Firestore to list top-level collections...");
|
|
152
|
+
let detectedCollections;
|
|
153
|
+
try {
|
|
154
|
+
detectedCollections = await listFirestoreCollections(projectId, serviceAccountPath);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
158
|
+
console.log(`Could not list Firestore collections: ${message}`);
|
|
159
|
+
console.log("You can enter collection names manually.");
|
|
160
|
+
console.log("");
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
if (detectedCollections.length === 0) {
|
|
164
|
+
console.log("No top-level Firestore collections were detected.");
|
|
165
|
+
console.log("");
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
console.log("");
|
|
169
|
+
console.log("Detected Firestore collections:");
|
|
170
|
+
console.log("");
|
|
171
|
+
detectedCollections.forEach((collection, index) => {
|
|
172
|
+
console.log(`${index + 1}. ${collection}`);
|
|
173
|
+
});
|
|
174
|
+
console.log("");
|
|
175
|
+
const selected = (await rl.question("Collections to back up, comma-separated numbers or names: ")).trim();
|
|
176
|
+
if (selected === "") {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
return parseSelectedCollections(selected, detectedCollections);
|
|
180
|
+
}
|
|
181
|
+
async function promptForConfig(options, workspaceRoot, rl) {
|
|
182
|
+
const projectCandidates = detectFirebaseProjectCandidates();
|
|
183
|
+
const serviceAccountPaths = detectServiceAccountPaths(process.cwd(), workspaceRoot);
|
|
58
184
|
if (options.yes) {
|
|
59
|
-
return
|
|
185
|
+
return {
|
|
186
|
+
...defaultConfig,
|
|
187
|
+
projectId: uniqueProjectIds(projectCandidates)[0] ?? defaultConfig.projectId,
|
|
188
|
+
};
|
|
60
189
|
}
|
|
61
190
|
if (!rl) {
|
|
62
191
|
throw new Error("Prompt interface is required for interactive init.");
|
|
63
192
|
}
|
|
64
|
-
const projectId =
|
|
193
|
+
const projectId = await promptForProjectId(rl, projectCandidates);
|
|
65
194
|
if (projectId !== "") {
|
|
66
|
-
printServiceAccountGuidance(projectId, defaultConfig.serviceAccountPath);
|
|
195
|
+
printServiceAccountGuidance(projectId, path.join(workspaceDirName, defaultConfig.serviceAccountPath.replace(/^\.\//, "")));
|
|
67
196
|
}
|
|
68
|
-
const serviceAccountPath =
|
|
197
|
+
const serviceAccountPath = await promptForServiceAccountPath(rl, serviceAccountPaths);
|
|
69
198
|
const outputDir = (await rl.question(`Output directory (${defaultConfig.outputDir}): `)).trim() || defaultConfig.outputDir;
|
|
70
|
-
const
|
|
199
|
+
const serviceAccountAbsolutePath = path.resolve(workspaceRoot, serviceAccountPath);
|
|
200
|
+
const serviceAccountDisplayPath = path.relative(process.cwd(), serviceAccountAbsolutePath);
|
|
201
|
+
const detectedCollections = await promptForCollectionListing(rl, projectId, serviceAccountAbsolutePath, serviceAccountDisplayPath);
|
|
202
|
+
const collectionsInput = detectedCollections
|
|
203
|
+
? detectedCollections.join(",")
|
|
204
|
+
: (await rl.question("Collections, comma-separated: ")).trim();
|
|
71
205
|
return {
|
|
72
206
|
projectId,
|
|
73
207
|
serviceAccountPath,
|
|
@@ -82,13 +216,12 @@ async function promptForGitInit(options, rl) {
|
|
|
82
216
|
if (!rl) {
|
|
83
217
|
throw new Error("Prompt interface is required for interactive init.");
|
|
84
218
|
}
|
|
85
|
-
const answer = (await rl.question("
|
|
219
|
+
const answer = (await rl.question("Initialize Git inside .firevault? (Y/n): "))
|
|
86
220
|
.trim()
|
|
87
221
|
.toLowerCase();
|
|
88
222
|
return answer === "" || answer === "y" || answer === "yes";
|
|
89
223
|
}
|
|
90
|
-
function ensureGitignoreEntries(entries) {
|
|
91
|
-
const gitignorePath = ".gitignore";
|
|
224
|
+
function ensureGitignoreEntries(gitignorePath, entries) {
|
|
92
225
|
const existing = existsSync(gitignorePath)
|
|
93
226
|
? readFileSync(gitignorePath, "utf-8")
|
|
94
227
|
: "";
|
|
@@ -96,7 +229,7 @@ function ensureGitignoreEntries(entries) {
|
|
|
96
229
|
.split("\n")
|
|
97
230
|
.map((line) => line.trim())
|
|
98
231
|
.filter((line) => line !== ""));
|
|
99
|
-
const missingEntries = entries.filter((entry) => !existingLines.has(entry));
|
|
232
|
+
const missingEntries = [...new Set(entries)].filter((entry) => !existingLines.has(entry));
|
|
100
233
|
if (missingEntries.length === 0) {
|
|
101
234
|
return;
|
|
102
235
|
}
|
|
@@ -107,44 +240,61 @@ export async function runInit(options) {
|
|
|
107
240
|
console.log("Firevault");
|
|
108
241
|
console.log("Undo button for Firestore.");
|
|
109
242
|
console.log("");
|
|
110
|
-
const
|
|
111
|
-
const
|
|
243
|
+
const appRoot = process.cwd();
|
|
244
|
+
const workspaceRoot = path.join(appRoot, workspaceDirName);
|
|
245
|
+
const configPath = path.join(workspaceRoot, configFileName);
|
|
246
|
+
const parentIsGitRepository = isInsideGitRepository(appRoot);
|
|
247
|
+
const workspaceIsGitRepository = isInsideGitRepository(workspaceRoot);
|
|
112
248
|
const rl = options.yes ? undefined : createInterface({ input, output });
|
|
113
249
|
try {
|
|
114
|
-
if (
|
|
250
|
+
if (parentIsGitRepository && hasWorkingTreeChanges(appRoot) && !options.force) {
|
|
115
251
|
throw new Error("Git working tree has changes. Commit, stash, or rerun with --force before init writes files.");
|
|
116
252
|
}
|
|
117
253
|
if (existsSync(configPath) && !options.force) {
|
|
118
|
-
throw new Error("firevault
|
|
254
|
+
throw new Error(".firevault/config.json already exists. Rerun with --force to overwrite it.");
|
|
119
255
|
}
|
|
120
|
-
const shouldInitGit =
|
|
256
|
+
const shouldInitGit = workspaceIsGitRepository
|
|
121
257
|
? false
|
|
122
258
|
: await promptForGitInit(options, rl);
|
|
123
|
-
const config = await promptForConfig(options, rl);
|
|
259
|
+
const config = await promptForConfig(options, workspaceRoot, rl);
|
|
124
260
|
config.collections = config.collections.map((collection) => collection.trim());
|
|
125
261
|
validateConfig(config);
|
|
262
|
+
const serviceAccountAbsolutePath = path.resolve(workspaceRoot, config.serviceAccountPath);
|
|
263
|
+
const serviceAccountDisplayPath = path.relative(appRoot, serviceAccountAbsolutePath);
|
|
126
264
|
if (options.yes) {
|
|
127
|
-
printServiceAccountGuidance(config.projectId,
|
|
265
|
+
printServiceAccountGuidance(config.projectId, serviceAccountDisplayPath);
|
|
128
266
|
}
|
|
129
|
-
printMissingServiceAccountInfo(
|
|
267
|
+
printMissingServiceAccountInfo(serviceAccountAbsolutePath, serviceAccountDisplayPath);
|
|
268
|
+
mkdirSync(workspaceRoot, { recursive: true });
|
|
130
269
|
if (shouldInitGit) {
|
|
131
|
-
initGitRepository();
|
|
132
|
-
console.log("Initialized Git repository.");
|
|
270
|
+
initGitRepository(workspaceRoot);
|
|
271
|
+
console.log("Initialized Git repository in .firevault.");
|
|
133
272
|
}
|
|
134
273
|
if (existsSync(configPath) && options.force) {
|
|
135
|
-
console.log("Warning: overwriting existing firevault
|
|
274
|
+
console.log("Warning: overwriting existing .firevault/config.json.");
|
|
136
275
|
}
|
|
137
276
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
138
|
-
ensureGitignoreEntries([
|
|
139
|
-
|
|
140
|
-
"firestore-backups/",
|
|
277
|
+
ensureGitignoreEntries(path.join(workspaceRoot, ".gitignore"), [
|
|
278
|
+
gitignorePathFor(config.serviceAccountPath),
|
|
141
279
|
"firestore-debug.log",
|
|
280
|
+
".env",
|
|
281
|
+
".env.*",
|
|
142
282
|
]);
|
|
143
|
-
|
|
144
|
-
|
|
283
|
+
if (parentIsGitRepository) {
|
|
284
|
+
const parentIgnoreEntries = [".firevault/"];
|
|
285
|
+
const serviceAccountInAppRepo = path.relative(appRoot, serviceAccountAbsolutePath);
|
|
286
|
+
if (!serviceAccountInAppRepo.startsWith("..") &&
|
|
287
|
+
!gitignorePathFor(serviceAccountInAppRepo).startsWith(".firevault/")) {
|
|
288
|
+
parentIgnoreEntries.push(gitignorePathFor(serviceAccountInAppRepo));
|
|
289
|
+
}
|
|
290
|
+
ensureGitignoreEntries(path.join(appRoot, ".gitignore"), parentIgnoreEntries);
|
|
291
|
+
console.log("Updated parent .gitignore safety entries.");
|
|
292
|
+
}
|
|
293
|
+
console.log("Created .firevault/config.json.");
|
|
294
|
+
console.log("Updated .firevault/.gitignore safety entries.");
|
|
145
295
|
console.log("");
|
|
146
296
|
console.log("Next steps:");
|
|
147
|
-
console.log(`1. Save your service account key at ${
|
|
297
|
+
console.log(`1. Save your service account key at ${serviceAccountDisplayPath}`);
|
|
148
298
|
console.log("2. Run `firevault snapshot`");
|
|
149
299
|
console.log("3. Run `firevault changes`");
|
|
150
300
|
console.log("4. Run `firevault restore-preview <path> --from <commit>` before a real restore");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
-
import { ConfigError, loadConfig } from "../config/loadConfig.js";
|
|
4
|
+
import { ConfigError, loadConfig, resolveWorkspacePath } from "../config/loadConfig.js";
|
|
5
5
|
import { FirestoreError, writeDocument } from "../firestore/writeDocument.js";
|
|
6
6
|
import { GitError, assertInsideGitRepository, showFileAtCommit, } from "../git/git.js";
|
|
7
7
|
import { normalizeDocumentPath, normalizeSlashes, } from "../paths/backupPaths.js";
|
|
@@ -65,12 +65,13 @@ export async function runRestoreFirestore(inputPath, options) {
|
|
|
65
65
|
}
|
|
66
66
|
const config = loadConfig();
|
|
67
67
|
const target = getFirestoreTarget(inputPath, config.outputDir);
|
|
68
|
-
assertInsideGitRepository();
|
|
69
|
-
const restoredContent = showFileAtCommit(options.from, target.backupPath);
|
|
68
|
+
assertInsideGitRepository(config.workspaceRoot);
|
|
69
|
+
const restoredContent = showFileAtCommit(options.from, target.backupPath, config.workspaceRoot);
|
|
70
70
|
const restoredData = parseRestoreJson(restoredContent, target.backupPath);
|
|
71
|
-
const
|
|
71
|
+
const targetFilePath = resolveWorkspacePath(config.workspaceRoot, target.backupPath);
|
|
72
|
+
const currentExists = existsSync(targetFilePath);
|
|
72
73
|
const currentContent = currentExists
|
|
73
|
-
? readFileSync(
|
|
74
|
+
? readFileSync(targetFilePath, "utf-8")
|
|
74
75
|
: undefined;
|
|
75
76
|
const diff = buildLineDiff(currentContent, restoredContent);
|
|
76
77
|
printFirestorePreview(target, options.from, currentExists, diff);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
-
import { ConfigError, loadConfig } from "../config/loadConfig.js";
|
|
4
|
+
import { ConfigError, loadConfig, resolveWorkspacePath } from "../config/loadConfig.js";
|
|
5
5
|
import { GitError, assertInsideGitRepository, showFileAtCommit, } from "../git/git.js";
|
|
6
6
|
import { normalizeDocumentPath } from "../paths/backupPaths.js";
|
|
7
7
|
import { buildLineDiff, printRestorePreview } from "./restorePreview.js";
|
|
@@ -14,14 +14,15 @@ export function runRestoreLocal(inputPath, options) {
|
|
|
14
14
|
}
|
|
15
15
|
const config = loadConfig();
|
|
16
16
|
const targetPath = normalizeDocumentPath(inputPath, config.outputDir);
|
|
17
|
-
assertInsideGitRepository();
|
|
18
|
-
const restoredContent = showFileAtCommit(options.from, targetPath);
|
|
19
|
-
const
|
|
20
|
-
const
|
|
17
|
+
assertInsideGitRepository(config.workspaceRoot);
|
|
18
|
+
const restoredContent = showFileAtCommit(options.from, targetPath, config.workspaceRoot);
|
|
19
|
+
const targetFilePath = resolveWorkspacePath(config.workspaceRoot, targetPath);
|
|
20
|
+
const currentExists = existsSync(targetFilePath);
|
|
21
|
+
const currentContent = currentExists ? readFileSync(targetFilePath, "utf-8") : undefined;
|
|
21
22
|
const diff = buildLineDiff(currentContent, restoredContent);
|
|
22
23
|
printRestorePreview(targetPath, options.from, currentExists, diff);
|
|
23
|
-
mkdirSync(path.dirname(
|
|
24
|
-
writeFileSync(
|
|
24
|
+
mkdirSync(path.dirname(targetFilePath), { recursive: true });
|
|
25
|
+
writeFileSync(targetFilePath, restoredContent);
|
|
25
26
|
console.log("");
|
|
26
27
|
console.log(`Restored local backup file: ${targetPath}`);
|
|
27
28
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import { ConfigError, loadConfig } from "../config/loadConfig.js";
|
|
3
|
+
import { ConfigError, loadConfig, resolveWorkspacePath } from "../config/loadConfig.js";
|
|
4
4
|
import { GitError, assertInsideGitRepository, showFileAtCommit, } from "../git/git.js";
|
|
5
5
|
import { normalizeDocumentPath } from "../paths/backupPaths.js";
|
|
6
6
|
function normalizeJson(content) {
|
|
@@ -76,10 +76,11 @@ export function runRestorePreview(inputPath, options) {
|
|
|
76
76
|
}
|
|
77
77
|
const config = loadConfig();
|
|
78
78
|
const targetPath = normalizeDocumentPath(inputPath, config.outputDir);
|
|
79
|
-
assertInsideGitRepository();
|
|
80
|
-
const restoredContent = showFileAtCommit(options.from, targetPath);
|
|
81
|
-
const
|
|
82
|
-
const
|
|
79
|
+
assertInsideGitRepository(config.workspaceRoot);
|
|
80
|
+
const restoredContent = showFileAtCommit(options.from, targetPath, config.workspaceRoot);
|
|
81
|
+
const targetFilePath = resolveWorkspacePath(config.workspaceRoot, targetPath);
|
|
82
|
+
const currentExists = existsSync(targetFilePath);
|
|
83
|
+
const currentContent = currentExists ? readFileSync(targetFilePath, "utf-8") : undefined;
|
|
83
84
|
const diff = buildLineDiff(currentContent, restoredContent);
|
|
84
85
|
printRestorePreview(targetPath, options.from, currentExists, diff);
|
|
85
86
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
export class ConfigError extends Error {
|
|
3
4
|
constructor(message) {
|
|
4
5
|
super(message);
|
|
@@ -11,7 +12,7 @@ function isRecord(value) {
|
|
|
11
12
|
function requireString(config, field) {
|
|
12
13
|
const value = config[field];
|
|
13
14
|
if (typeof value !== "string" || value.trim() === "") {
|
|
14
|
-
throw new ConfigError(`Invalid firevault
|
|
15
|
+
throw new ConfigError(`Invalid .firevault/config.json: "${field}" is required and must be a string.`);
|
|
15
16
|
}
|
|
16
17
|
return value;
|
|
17
18
|
}
|
|
@@ -20,15 +21,36 @@ function requireStringArray(config, field) {
|
|
|
20
21
|
if (!Array.isArray(value) ||
|
|
21
22
|
value.length === 0 ||
|
|
22
23
|
value.some((item) => typeof item !== "string" || item.trim() === "")) {
|
|
23
|
-
throw new ConfigError(`Invalid firevault
|
|
24
|
+
throw new ConfigError(`Invalid .firevault/config.json: "${field}" is required and must include at least one collection name.`);
|
|
24
25
|
}
|
|
25
26
|
return value;
|
|
26
27
|
}
|
|
28
|
+
export function findWorkspaceRoot(startDir = process.cwd()) {
|
|
29
|
+
let currentDir = path.resolve(startDir);
|
|
30
|
+
while (true) {
|
|
31
|
+
const candidate = path.join(currentDir, ".firevault", "config.json");
|
|
32
|
+
if (existsSync(candidate)) {
|
|
33
|
+
return path.join(currentDir, ".firevault");
|
|
34
|
+
}
|
|
35
|
+
const parent = path.dirname(currentDir);
|
|
36
|
+
if (parent === currentDir) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
currentDir = parent;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function normalizeConfigPath(value) {
|
|
43
|
+
return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
44
|
+
}
|
|
45
|
+
export function resolveWorkspacePath(workspaceRoot, configPath) {
|
|
46
|
+
return path.resolve(workspaceRoot, configPath);
|
|
47
|
+
}
|
|
27
48
|
export function loadConfig() {
|
|
28
|
-
const
|
|
29
|
-
if (!
|
|
30
|
-
throw new ConfigError("Missing firevault
|
|
49
|
+
const workspaceRoot = findWorkspaceRoot();
|
|
50
|
+
if (!workspaceRoot) {
|
|
51
|
+
throw new ConfigError("Missing .firevault/config.json. Run `firevault init` first.");
|
|
31
52
|
}
|
|
53
|
+
const configPath = path.join(workspaceRoot, "config.json");
|
|
32
54
|
let parsed;
|
|
33
55
|
try {
|
|
34
56
|
const raw = readFileSync(configPath, "utf-8");
|
|
@@ -36,18 +58,24 @@ export function loadConfig() {
|
|
|
36
58
|
}
|
|
37
59
|
catch (error) {
|
|
38
60
|
if (error instanceof SyntaxError) {
|
|
39
|
-
throw new ConfigError("Invalid firevault
|
|
61
|
+
throw new ConfigError("Invalid .firevault/config.json: file is not valid JSON.");
|
|
40
62
|
}
|
|
41
63
|
throw error;
|
|
42
64
|
}
|
|
43
65
|
if (!isRecord(parsed)) {
|
|
44
|
-
throw new ConfigError("Invalid firevault
|
|
66
|
+
throw new ConfigError("Invalid .firevault/config.json: expected a JSON object.");
|
|
45
67
|
}
|
|
46
68
|
const config = {
|
|
47
69
|
projectId: requireString(parsed, "projectId"),
|
|
48
|
-
serviceAccountPath: requireString(parsed, "serviceAccountPath"),
|
|
49
|
-
outputDir: requireString(parsed, "outputDir"),
|
|
70
|
+
serviceAccountPath: normalizeConfigPath(requireString(parsed, "serviceAccountPath")),
|
|
71
|
+
outputDir: normalizeConfigPath(requireString(parsed, "outputDir")),
|
|
50
72
|
collections: requireStringArray(parsed, "collections"),
|
|
73
|
+
workspaceRoot,
|
|
74
|
+
configPath,
|
|
75
|
+
serviceAccountPathAbsolute: "",
|
|
76
|
+
outputDirPath: "",
|
|
51
77
|
};
|
|
78
|
+
config.serviceAccountPathAbsolute = resolveWorkspacePath(workspaceRoot, config.serviceAccountPath);
|
|
79
|
+
config.outputDirPath = resolveWorkspacePath(workspaceRoot, config.outputDir);
|
|
52
80
|
return config;
|
|
53
81
|
}
|
|
@@ -15,10 +15,10 @@ export function getFirestore() {
|
|
|
15
15
|
initialized = true;
|
|
16
16
|
return admin.firestore();
|
|
17
17
|
}
|
|
18
|
-
if (!existsSync(config.
|
|
18
|
+
if (!existsSync(config.serviceAccountPathAbsolute)) {
|
|
19
19
|
throw new ConfigError(`Service account file not found: ${config.serviceAccountPath}`);
|
|
20
20
|
}
|
|
21
|
-
serviceAccount = JSON.parse(readFileSync(config.
|
|
21
|
+
serviceAccount = JSON.parse(readFileSync(config.serviceAccountPathAbsolute, "utf-8"));
|
|
22
22
|
admin.initializeApp({
|
|
23
23
|
credential: admin.credential.cert(serviceAccount),
|
|
24
24
|
projectId: config.projectId,
|
package/dist/git/git.js
CHANGED
|
@@ -5,9 +5,10 @@ export class GitError extends Error {
|
|
|
5
5
|
this.name = "GitError";
|
|
6
6
|
}
|
|
7
7
|
}
|
|
8
|
-
function runGit(args) {
|
|
8
|
+
function runGit(args, cwd = process.cwd()) {
|
|
9
9
|
try {
|
|
10
10
|
return execFileSync("git", args, {
|
|
11
|
+
cwd,
|
|
11
12
|
encoding: "utf-8",
|
|
12
13
|
stdio: ["ignore", "pipe", "pipe"],
|
|
13
14
|
});
|
|
@@ -23,27 +24,27 @@ function runGit(args) {
|
|
|
23
24
|
throw new GitError("Git command failed.");
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
|
-
export function isInsideGitRepository() {
|
|
27
|
+
export function isInsideGitRepository(cwd = process.cwd()) {
|
|
27
28
|
try {
|
|
28
|
-
return runGit(["rev-parse", "--is-inside-work-tree"]).trim() === "true";
|
|
29
|
+
return runGit(["rev-parse", "--is-inside-work-tree"], cwd).trim() === "true";
|
|
29
30
|
}
|
|
30
31
|
catch {
|
|
31
32
|
return false;
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
|
-
export function assertInsideGitRepository() {
|
|
35
|
-
if (!isInsideGitRepository()) {
|
|
35
|
+
export function assertInsideGitRepository(cwd = process.cwd()) {
|
|
36
|
+
if (!isInsideGitRepository(cwd)) {
|
|
36
37
|
throw new GitError("Current directory is not inside a Git repository.");
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
|
-
export function initGitRepository() {
|
|
40
|
-
runGit(["init"]);
|
|
40
|
+
export function initGitRepository(cwd = process.cwd()) {
|
|
41
|
+
runGit(["init"], cwd);
|
|
41
42
|
}
|
|
42
|
-
export function hasWorkingTreeChanges() {
|
|
43
|
-
return runGit(["status", "--porcelain"]).trim() !== "";
|
|
43
|
+
export function hasWorkingTreeChanges(cwd = process.cwd()) {
|
|
44
|
+
return runGit(["status", "--porcelain"], cwd).trim() !== "";
|
|
44
45
|
}
|
|
45
|
-
export function hasChangesUnder(path) {
|
|
46
|
-
return runGit(["status", "--porcelain", "--ignored", "--", path]).trim() !== "";
|
|
46
|
+
export function hasChangesUnder(path, cwd = process.cwd()) {
|
|
47
|
+
return runGit(["status", "--porcelain", "--ignored", "--", path], cwd).trim() !== "";
|
|
47
48
|
}
|
|
48
49
|
function emptyFileChanges() {
|
|
49
50
|
return {
|
|
@@ -76,8 +77,8 @@ function normalizeFileChanges(changes) {
|
|
|
76
77
|
deleted: dedupeSorted([...deleted]),
|
|
77
78
|
};
|
|
78
79
|
}
|
|
79
|
-
export function getWorkingTreeChanges(path) {
|
|
80
|
-
const output = runGit(["status", "--porcelain", "--", path]);
|
|
80
|
+
export function getWorkingTreeChanges(path, cwd = process.cwd()) {
|
|
81
|
+
const output = runGit(["status", "--porcelain", "--", path], cwd);
|
|
81
82
|
const changes = emptyFileChanges();
|
|
82
83
|
for (const line of output.split("\n")) {
|
|
83
84
|
if (line.trim() === "") {
|
|
@@ -89,7 +90,7 @@ export function getWorkingTreeChanges(path) {
|
|
|
89
90
|
}
|
|
90
91
|
return normalizeFileChanges(changes);
|
|
91
92
|
}
|
|
92
|
-
export function getHistoricalChanges(path, since) {
|
|
93
|
+
export function getHistoricalChanges(path, since, cwd = process.cwd()) {
|
|
93
94
|
const output = runGit([
|
|
94
95
|
"log",
|
|
95
96
|
`--since=${since}`,
|
|
@@ -97,7 +98,7 @@ export function getHistoricalChanges(path, since) {
|
|
|
97
98
|
"--format=",
|
|
98
99
|
"--",
|
|
99
100
|
path,
|
|
100
|
-
]);
|
|
101
|
+
], cwd);
|
|
101
102
|
const changes = emptyFileChanges();
|
|
102
103
|
for (const line of output.split("\n")) {
|
|
103
104
|
if (line.trim() === "") {
|
|
@@ -111,9 +112,9 @@ export function getHistoricalChanges(path, since) {
|
|
|
111
112
|
}
|
|
112
113
|
return normalizeFileChanges(changes);
|
|
113
114
|
}
|
|
114
|
-
export function getHistory(path, includeChangedFileCount) {
|
|
115
|
+
export function getHistory(path, includeChangedFileCount, cwd = process.cwd()) {
|
|
115
116
|
const format = "%h%x09%cs%x09%s";
|
|
116
|
-
const output = runGit(["log", `--format=${format}`, "--name-only", "--", path]);
|
|
117
|
+
const output = runGit(["log", `--format=${format}`, "--name-only", "--", path], cwd);
|
|
117
118
|
const entries = [];
|
|
118
119
|
let current;
|
|
119
120
|
let changedFiles = new Set();
|
|
@@ -146,17 +147,17 @@ export function getHistory(path, includeChangedFileCount) {
|
|
|
146
147
|
finishCurrent();
|
|
147
148
|
return entries;
|
|
148
149
|
}
|
|
149
|
-
export function showFileAtCommit(commit, path) {
|
|
150
|
+
export function showFileAtCommit(commit, path, cwd = process.cwd()) {
|
|
150
151
|
try {
|
|
151
|
-
return runGit(["show", `${commit}:${path}`]);
|
|
152
|
+
return runGit(["show", `${commit}:${path}`], cwd);
|
|
152
153
|
}
|
|
153
154
|
catch {
|
|
154
155
|
throw new GitError(`File not found at ${commit}: ${path}`);
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
|
-
export function stagePath(path) {
|
|
158
|
-
runGit(["add", "-f", "--", path]);
|
|
158
|
+
export function stagePath(path, cwd = process.cwd()) {
|
|
159
|
+
runGit(["add", "-f", "--", path], cwd);
|
|
159
160
|
}
|
|
160
|
-
export function commitPath(message, path) {
|
|
161
|
-
runGit(["commit", "-m", message, "--", path]);
|
|
161
|
+
export function commitPath(message, path, cwd = process.cwd()) {
|
|
162
|
+
runGit(["commit", "-m", message, "--", path], cwd);
|
|
162
163
|
}
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ const program = new Command();
|
|
|
13
13
|
program
|
|
14
14
|
.name("firevault")
|
|
15
15
|
.description("Undo button for Firestore.")
|
|
16
|
-
.version("0.
|
|
16
|
+
.version("0.2.0-beta.0");
|
|
17
17
|
program.addCommand(initCommand);
|
|
18
18
|
program.addCommand(backupCommand);
|
|
19
19
|
program.addCommand(commitCommand);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
const projectConfigFiles = [
|
|
3
|
+
".env",
|
|
4
|
+
".env.local",
|
|
5
|
+
".env.development",
|
|
6
|
+
".env.production",
|
|
7
|
+
"firebase.json",
|
|
8
|
+
"src/firebase.ts",
|
|
9
|
+
"src/firebase.js",
|
|
10
|
+
"firebase.ts",
|
|
11
|
+
"firebase.js",
|
|
12
|
+
"src/lib/firebase.ts",
|
|
13
|
+
"src/lib/firebase.js",
|
|
14
|
+
"app/firebase.ts",
|
|
15
|
+
"app/firebase.js",
|
|
16
|
+
];
|
|
17
|
+
const projectKeys = [
|
|
18
|
+
"VITE_FIREBASE_PROJECT_ID",
|
|
19
|
+
"NEXT_PUBLIC_FIREBASE_PROJECT_ID",
|
|
20
|
+
"REACT_APP_FIREBASE_PROJECT_ID",
|
|
21
|
+
"FIREBASE_PROJECT_ID",
|
|
22
|
+
"GCLOUD_PROJECT",
|
|
23
|
+
"projectId",
|
|
24
|
+
"project_id",
|
|
25
|
+
];
|
|
26
|
+
function stripQuotes(value) {
|
|
27
|
+
return value.trim().replace(/^["']|["']$/g, "");
|
|
28
|
+
}
|
|
29
|
+
function findKeyValue(content, key) {
|
|
30
|
+
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
31
|
+
const patterns = [
|
|
32
|
+
new RegExp(`(?:^|\\n)\\s*${escapedKey}\\s*=\\s*["']?([^"'\\n#]+)["']?`, "g"),
|
|
33
|
+
new RegExp(`${escapedKey}\\s*[:=]\\s*["']([^"']+)["']`, "g"),
|
|
34
|
+
];
|
|
35
|
+
const values = [];
|
|
36
|
+
for (const pattern of patterns) {
|
|
37
|
+
for (const match of content.matchAll(pattern)) {
|
|
38
|
+
const value = stripQuotes(match[1] ?? "");
|
|
39
|
+
if (value !== "") {
|
|
40
|
+
values.push(value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return values;
|
|
45
|
+
}
|
|
46
|
+
export function detectFirebaseProjectCandidates() {
|
|
47
|
+
const candidates = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
for (const filePath of projectConfigFiles) {
|
|
50
|
+
if (!existsSync(filePath)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
let content;
|
|
54
|
+
try {
|
|
55
|
+
content = readFileSync(filePath, "utf-8");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (const key of projectKeys) {
|
|
61
|
+
for (const projectId of findKeyValue(content, key)) {
|
|
62
|
+
const dedupeKey = `${projectId}\0${filePath}\0${key}`;
|
|
63
|
+
if (seen.has(dedupeKey)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
seen.add(dedupeKey);
|
|
67
|
+
candidates.push({
|
|
68
|
+
projectId,
|
|
69
|
+
source: filePath,
|
|
70
|
+
key,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return candidates;
|
|
76
|
+
}
|
|
77
|
+
export function uniqueProjectIds(candidates) {
|
|
78
|
+
return [...new Set(candidates.map((candidate) => candidate.projectId))];
|
|
79
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const likelyServiceAccountPaths = [
|
|
4
|
+
"./serviceAccountKey.json",
|
|
5
|
+
"./service-account.json",
|
|
6
|
+
"./firebase-service-account.json",
|
|
7
|
+
"./credentials/firebase.json",
|
|
8
|
+
];
|
|
9
|
+
function normalizeConfigRelativePath(filePath) {
|
|
10
|
+
const normalized = filePath.replaceAll("\\", "/");
|
|
11
|
+
if (normalized.startsWith("../")) {
|
|
12
|
+
return normalized;
|
|
13
|
+
}
|
|
14
|
+
return normalized.startsWith("./") ? normalized : `./${normalized}`;
|
|
15
|
+
}
|
|
16
|
+
export function detectServiceAccountPaths(appRoot = process.cwd(), workspaceRoot = path.join(appRoot, ".firevault")) {
|
|
17
|
+
const candidates = [];
|
|
18
|
+
for (const filePath of likelyServiceAccountPaths) {
|
|
19
|
+
const appPath = path.resolve(appRoot, filePath);
|
|
20
|
+
const workspacePath = path.resolve(workspaceRoot, filePath);
|
|
21
|
+
if (existsSync(workspacePath)) {
|
|
22
|
+
candidates.push(normalizeConfigRelativePath(filePath));
|
|
23
|
+
}
|
|
24
|
+
if (existsSync(appPath)) {
|
|
25
|
+
candidates.push(normalizeConfigRelativePath(path.relative(workspaceRoot, appPath)));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return [...new Set(candidates)];
|
|
29
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import admin from "firebase-admin";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
export async function listFirestoreCollections(projectId, serviceAccountPath) {
|
|
4
|
+
if (!existsSync(serviceAccountPath)) {
|
|
5
|
+
throw new Error(`Service account file not found: ${serviceAccountPath}`);
|
|
6
|
+
}
|
|
7
|
+
const emulatorHost = process.env.FIRESTORE_EMULATOR_HOST;
|
|
8
|
+
let appOptions;
|
|
9
|
+
if (emulatorHost) {
|
|
10
|
+
appOptions = { projectId };
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
let serviceAccount;
|
|
14
|
+
try {
|
|
15
|
+
serviceAccount = JSON.parse(readFileSync(serviceAccountPath, "utf-8"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error(`Invalid service account file: ${serviceAccountPath}`);
|
|
19
|
+
}
|
|
20
|
+
appOptions = {
|
|
21
|
+
credential: admin.credential.cert(serviceAccount),
|
|
22
|
+
projectId,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const app = admin.initializeApp(appOptions, `firevault-init-${Date.now()}`);
|
|
26
|
+
try {
|
|
27
|
+
const collections = await app.firestore().listCollections();
|
|
28
|
+
return collections.map((collection) => collection.id).sort();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await app.delete();
|
|
32
|
+
}
|
|
33
|
+
}
|
package/docs/architecture.md
CHANGED
|
@@ -27,21 +27,16 @@ The architecture should optimize for:
|
|
|
27
27
|
## Current Project Structure
|
|
28
28
|
|
|
29
29
|
```txt
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
git/
|
|
41
|
-
index.ts
|
|
42
|
-
firevault.config.json
|
|
43
|
-
package.json
|
|
44
|
-
tsconfig.json
|
|
30
|
+
my-app/
|
|
31
|
+
src/
|
|
32
|
+
firebase.ts
|
|
33
|
+
.env.local
|
|
34
|
+
.gitignore
|
|
35
|
+
.firevault/
|
|
36
|
+
config.json
|
|
37
|
+
firestore-backups/
|
|
38
|
+
.git/
|
|
39
|
+
.gitignore
|
|
45
40
|
```
|
|
46
41
|
|
|
47
42
|
## Command Layer
|
|
@@ -52,19 +47,25 @@ Current commands:
|
|
|
52
47
|
|
|
53
48
|
- `firevault init`
|
|
54
49
|
- `firevault backup`
|
|
50
|
+
- `firevault commit`
|
|
51
|
+
- `firevault snapshot`
|
|
52
|
+
- `firevault changes`
|
|
53
|
+
- `firevault history`
|
|
54
|
+
- `firevault restore-preview`
|
|
55
|
+
- `firevault restore-local`
|
|
56
|
+
- `firevault restore-firestore`
|
|
55
57
|
|
|
56
58
|
Expected future commands:
|
|
57
59
|
|
|
58
60
|
```bash
|
|
59
|
-
firevault backup
|
|
60
|
-
firevault changes --last 24h
|
|
61
61
|
firevault diff users/abc123
|
|
62
|
-
firevault restore users/abc123 --from HEAD~3
|
|
63
62
|
```
|
|
64
63
|
|
|
65
64
|
## Configuration
|
|
66
65
|
|
|
67
|
-
Configuration is loaded from
|
|
66
|
+
Configuration is loaded from `.firevault/config.json`.
|
|
67
|
+
|
|
68
|
+
Firevault 0.2 uses `.firevault/config.json` and a dedicated `.firevault` recovery workspace. There is no backward compatibility with the old root-based `firevault.config.json` prerelease model.
|
|
68
69
|
|
|
69
70
|
Intended shape:
|
|
70
71
|
|
|
@@ -79,11 +80,13 @@ Intended shape:
|
|
|
79
80
|
|
|
80
81
|
Current implementation notes:
|
|
81
82
|
|
|
82
|
-
- `loadConfig`
|
|
83
|
-
-
|
|
84
|
-
-
|
|
83
|
+
- `loadConfig` walks upward from the current directory, finds the nearest `.firevault/config.json`, and treats that `.firevault` directory as the workspace root.
|
|
84
|
+
- Config-relative paths resolve from the workspace root.
|
|
85
|
+
- Firebase initialization expects `serviceAccountPath` relative to `.firevault/`.
|
|
86
|
+
- `firevault init` guides setup, validates required fields, checks Git state before writing, creates `.firevault/`, and updates `.gitignore` files without overwriting existing entries.
|
|
87
|
+
- `firevault init` can suggest project IDs from local Firebase config files and likely service account paths from local filenames.
|
|
85
88
|
- `firevault init --force` allows dirty Git state and config overwrite with a warning.
|
|
86
|
-
- `firevault init --yes` provides a non-interactive path for tests and automation.
|
|
89
|
+
- `firevault init --yes` provides a deterministic non-interactive path for tests and automation, using a detected project ID if one is available and skipping Firebase collection listing.
|
|
87
90
|
|
|
88
91
|
## Init Safety
|
|
89
92
|
|
|
@@ -92,13 +95,18 @@ Current implementation notes:
|
|
|
92
95
|
Behavior:
|
|
93
96
|
|
|
94
97
|
- prints the Firevault identity before prompting,
|
|
95
|
-
- detects whether the
|
|
96
|
-
- offers
|
|
98
|
+
- detects whether the app directory is inside a Git repository,
|
|
99
|
+
- offers to initialize Git inside `.firevault/`,
|
|
100
|
+
- scans common local Firebase files for project ID candidates,
|
|
101
|
+
- shows detected project ID candidates with their source files,
|
|
102
|
+
- suggests likely service account paths without reading or printing private key contents,
|
|
97
103
|
- prints the Firebase Console Admin SDK service account URL for the entered project ID,
|
|
98
104
|
- explains where to save the manually downloaded service account key,
|
|
105
|
+
- optionally lists top-level Firestore collections only after telling the user and only when the selected service account file exists,
|
|
99
106
|
- refuses to run in a dirty Git working tree unless `--force` is provided,
|
|
100
|
-
- refuses to overwrite
|
|
101
|
-
-
|
|
107
|
+
- refuses to overwrite `.firevault/config.json` unless `--force` is provided,
|
|
108
|
+
- adds `.firevault/` to the parent app repo `.gitignore` when the parent is a Git repo,
|
|
109
|
+
- appends `.firevault/.gitignore` safety entries, including the selected service account path, without duplicating existing lines,
|
|
102
110
|
- never creates service accounts, opens browsers, runs `gcloud`, commits, pushes, creates GitHub repositories, contacts Firebase, or writes secrets.
|
|
103
111
|
|
|
104
112
|
## Firebase Access
|
|
@@ -115,6 +123,17 @@ Design constraints:
|
|
|
115
123
|
- do not introduce credential brokerage, hosted auth, or account systems,
|
|
116
124
|
- avoid committing service account files.
|
|
117
125
|
|
|
126
|
+
## Workspace Boundary
|
|
127
|
+
|
|
128
|
+
The app repo and Firevault recovery repo are separate:
|
|
129
|
+
|
|
130
|
+
- app repo: application source code and normal development history,
|
|
131
|
+
- `.firevault` repo: Firestore recovery config, backup JSON, and recovery history.
|
|
132
|
+
|
|
133
|
+
Operational commands work from the app root or from inside `.firevault/` by discovering the nearest `.firevault/config.json`. Git commands run with `.firevault/` as their working directory so `firevault commit`, `changes`, `history`, and restore previews cannot stage or inspect unrelated app source files.
|
|
134
|
+
|
|
135
|
+
`firestore-backups/` is not ignored inside `.firevault/` by default. The `.firevault` repository exists to commit backup data.
|
|
136
|
+
|
|
118
137
|
## Emulator Tests
|
|
119
138
|
|
|
120
139
|
Firestore emulator integration tests live under `test/integration/`.
|
|
@@ -148,7 +167,7 @@ Current backup flow:
|
|
|
148
167
|
1. Load config.
|
|
149
168
|
2. Iterate configured collections.
|
|
150
169
|
3. Fetch each collection through Firebase Admin SDK.
|
|
151
|
-
4. Write each document to
|
|
170
|
+
4. Write each document to `.firevault/<outputDir>/<collection>/<documentId>.json`.
|
|
152
171
|
5. Serialize document data using deterministic JSON.
|
|
153
172
|
|
|
154
173
|
The current exporter handles configured top-level collections. Subcollections, deletes, metadata, timestamps, references, and special Firestore value types need explicit design before production use.
|
|
@@ -170,6 +189,8 @@ Serialization rules should remain boring and predictable:
|
|
|
170
189
|
|
|
171
190
|
Git is the storage and history engine. Firevault should wrap Git workflows rather than reimplement versioning.
|
|
172
191
|
|
|
192
|
+
For Firevault 0.2, Git operations are scoped to the `.firevault` workspace repository, not the parent app repository.
|
|
193
|
+
|
|
173
194
|
Near-term Git integration should focus on:
|
|
174
195
|
|
|
175
196
|
- detecting working tree changes after backup,
|
package/docs/roadmap.md
CHANGED
|
@@ -26,7 +26,8 @@ Status:
|
|
|
26
26
|
- npm prerelease packaging is guarded by package file whitelisting and pack verification.
|
|
27
27
|
- Local npm prerelease publishing is guarded by a `gitversionjs`-based publish script with clean-tree, build, emulator-test, pack, and forbidden-path checks.
|
|
28
28
|
- Public GitHub release docs cover quick start, security, contributing, and issue triage.
|
|
29
|
-
-
|
|
29
|
+
- Firevault 0.2 uses `.firevault/config.json` and a dedicated `.firevault` recovery workspace as a breaking prerelease change.
|
|
30
|
+
- Guided `firevault init` validates setup input, checks Git state, suggests local Firebase project settings, optionally lists Firestore collections, creates `.firevault/`, and applies workspace `.gitignore` safety entries.
|
|
30
31
|
|
|
31
32
|
Next work:
|
|
32
33
|
|