firevault 0.1.1-beta.3 → 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 +36 -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 +50 -30
- 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/detectServiceAccount.js +21 -2
- package/docs/architecture.md +43 -27
- 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,7 @@ 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.
|
|
51
54
|
|
|
52
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.
|
|
53
56
|
|
|
@@ -62,14 +65,14 @@ https://console.firebase.google.com/project/your-project-id/settings/serviceacco
|
|
|
62
65
|
|
|
63
66
|
Download the JSON key and save it as:
|
|
64
67
|
|
|
65
|
-
|
|
68
|
+
.firevault/serviceAccountKey.json
|
|
66
69
|
```
|
|
67
70
|
|
|
68
71
|
Firevault does not create service accounts, open a browser, run `gcloud`, or authenticate against Firebase during setup.
|
|
69
72
|
|
|
70
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.
|
|
71
74
|
|
|
72
|
-
Generated
|
|
75
|
+
Generated `.firevault/config.json`:
|
|
73
76
|
|
|
74
77
|
```json
|
|
75
78
|
{
|
|
@@ -86,6 +89,8 @@ Take a snapshot:
|
|
|
86
89
|
firevault snapshot
|
|
87
90
|
```
|
|
88
91
|
|
|
92
|
+
Operational commands discover the nearest `.firevault/config.json`, so they work from the app root or from inside `.firevault/`.
|
|
93
|
+
|
|
89
94
|
Example output:
|
|
90
95
|
|
|
91
96
|
```txt
|
|
@@ -179,6 +184,12 @@ firevault snapshot
|
|
|
179
184
|
|
|
180
185
|
Firevault operates against an existing Firebase project using a service account.
|
|
181
186
|
|
|
187
|
+
Expected config path:
|
|
188
|
+
|
|
189
|
+
```txt
|
|
190
|
+
.firevault/config.json
|
|
191
|
+
```
|
|
192
|
+
|
|
182
193
|
Expected config shape:
|
|
183
194
|
|
|
184
195
|
```json
|
|
@@ -192,19 +203,28 @@ Expected config shape:
|
|
|
192
203
|
|
|
193
204
|
Notes:
|
|
194
205
|
|
|
195
|
-
-
|
|
196
|
-
- `
|
|
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/`,
|
|
197
209
|
- `collections` controls which top-level Firestore collections are exported.
|
|
198
210
|
- Service account files must not be committed.
|
|
199
211
|
|
|
200
|
-
|
|
212
|
+
Parent app repo `.gitignore`:
|
|
213
|
+
|
|
214
|
+
```gitignore
|
|
215
|
+
.firevault/
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`.firevault/.gitignore`:
|
|
201
219
|
|
|
202
220
|
```gitignore
|
|
203
221
|
serviceAccountKey.json
|
|
204
|
-
firestore-
|
|
222
|
+
firestore-debug.log
|
|
223
|
+
.env
|
|
224
|
+
.env.*
|
|
205
225
|
```
|
|
206
226
|
|
|
207
|
-
`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.
|
|
208
228
|
|
|
209
229
|
## Commands
|
|
210
230
|
|
|
@@ -262,7 +282,7 @@ JSON output is stable:
|
|
|
262
282
|
|
|
263
283
|
`firevault backup` exports configured Firestore collections to deterministic local JSON files. It does not stage or commit anything.
|
|
264
284
|
|
|
265
|
-
`firevault commit`
|
|
285
|
+
`firevault commit` commits inside the `.firevault` Git repository.
|
|
266
286
|
|
|
267
287
|
Behavior:
|
|
268
288
|
|
|
@@ -270,6 +290,7 @@ Behavior:
|
|
|
270
290
|
- exits successfully if no backup changes exist,
|
|
271
291
|
- stages only the configured `outputDir`,
|
|
272
292
|
- creates a local commit with message `backup: <ISO timestamp>`,
|
|
293
|
+
- never stages app source files from the parent repo,
|
|
273
294
|
- never pushes.
|
|
274
295
|
|
|
275
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,7 +1,8 @@
|
|
|
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";
|
|
6
7
|
import { detectFirebaseProjectCandidates, uniqueProjectIds, } from "../init/detectFirebaseProject.js";
|
|
7
8
|
import { detectServiceAccountPaths } from "../init/detectServiceAccount.js";
|
|
@@ -12,6 +13,8 @@ const defaultConfig = {
|
|
|
12
13
|
outputDir: "firestore-backups",
|
|
13
14
|
collections: ["users"],
|
|
14
15
|
};
|
|
16
|
+
const workspaceDirName = ".firevault";
|
|
17
|
+
const configFileName = "config.json";
|
|
15
18
|
function validateConfig(config) {
|
|
16
19
|
if (config.projectId.trim() === "") {
|
|
17
20
|
throw new Error("Firebase project ID is required.");
|
|
@@ -49,13 +52,13 @@ function printServiceAccountGuidance(projectId, serviceAccountPath) {
|
|
|
49
52
|
console.log(serviceAccountPath);
|
|
50
53
|
console.log("");
|
|
51
54
|
}
|
|
52
|
-
function printMissingServiceAccountInfo(serviceAccountPath) {
|
|
55
|
+
function printMissingServiceAccountInfo(serviceAccountPath, serviceAccountDisplayPath) {
|
|
53
56
|
if (existsSync(serviceAccountPath)) {
|
|
54
57
|
return;
|
|
55
58
|
}
|
|
56
|
-
console.log(`Service account file does not exist yet: ${
|
|
59
|
+
console.log(`Service account file does not exist yet: ${serviceAccountDisplayPath}`);
|
|
57
60
|
console.log("That is expected before downloading the Firebase Admin SDK key.");
|
|
58
|
-
console.log(`Save the downloaded JSON key at: ${
|
|
61
|
+
console.log(`Save the downloaded JSON key at: ${serviceAccountDisplayPath}`);
|
|
59
62
|
console.log("");
|
|
60
63
|
}
|
|
61
64
|
function gitignorePathFor(filePath) {
|
|
@@ -133,9 +136,9 @@ function parseSelectedCollections(inputValue, detectedCollections) {
|
|
|
133
136
|
}
|
|
134
137
|
return [...new Set(selectedCollections)];
|
|
135
138
|
}
|
|
136
|
-
async function promptForCollectionListing(rl, projectId, serviceAccountPath) {
|
|
139
|
+
async function promptForCollectionListing(rl, projectId, serviceAccountPath, serviceAccountDisplayPath) {
|
|
137
140
|
if (!existsSync(serviceAccountPath)) {
|
|
138
|
-
console.log(`Service account file is not present, so collection detection is skipped: ${
|
|
141
|
+
console.log(`Service account file is not present, so collection detection is skipped: ${serviceAccountDisplayPath}`);
|
|
139
142
|
console.log("");
|
|
140
143
|
return undefined;
|
|
141
144
|
}
|
|
@@ -175,9 +178,9 @@ async function promptForCollectionListing(rl, projectId, serviceAccountPath) {
|
|
|
175
178
|
}
|
|
176
179
|
return parseSelectedCollections(selected, detectedCollections);
|
|
177
180
|
}
|
|
178
|
-
async function promptForConfig(options, rl) {
|
|
181
|
+
async function promptForConfig(options, workspaceRoot, rl) {
|
|
179
182
|
const projectCandidates = detectFirebaseProjectCandidates();
|
|
180
|
-
const serviceAccountPaths = detectServiceAccountPaths();
|
|
183
|
+
const serviceAccountPaths = detectServiceAccountPaths(process.cwd(), workspaceRoot);
|
|
181
184
|
if (options.yes) {
|
|
182
185
|
return {
|
|
183
186
|
...defaultConfig,
|
|
@@ -189,11 +192,13 @@ async function promptForConfig(options, rl) {
|
|
|
189
192
|
}
|
|
190
193
|
const projectId = await promptForProjectId(rl, projectCandidates);
|
|
191
194
|
if (projectId !== "") {
|
|
192
|
-
printServiceAccountGuidance(projectId, defaultConfig.serviceAccountPath);
|
|
195
|
+
printServiceAccountGuidance(projectId, path.join(workspaceDirName, defaultConfig.serviceAccountPath.replace(/^\.\//, "")));
|
|
193
196
|
}
|
|
194
197
|
const serviceAccountPath = await promptForServiceAccountPath(rl, serviceAccountPaths);
|
|
195
198
|
const outputDir = (await rl.question(`Output directory (${defaultConfig.outputDir}): `)).trim() || defaultConfig.outputDir;
|
|
196
|
-
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);
|
|
197
202
|
const collectionsInput = detectedCollections
|
|
198
203
|
? detectedCollections.join(",")
|
|
199
204
|
: (await rl.question("Collections, comma-separated: ")).trim();
|
|
@@ -211,13 +216,12 @@ async function promptForGitInit(options, rl) {
|
|
|
211
216
|
if (!rl) {
|
|
212
217
|
throw new Error("Prompt interface is required for interactive init.");
|
|
213
218
|
}
|
|
214
|
-
const answer = (await rl.question("
|
|
219
|
+
const answer = (await rl.question("Initialize Git inside .firevault? (Y/n): "))
|
|
215
220
|
.trim()
|
|
216
221
|
.toLowerCase();
|
|
217
222
|
return answer === "" || answer === "y" || answer === "yes";
|
|
218
223
|
}
|
|
219
|
-
function ensureGitignoreEntries(entries) {
|
|
220
|
-
const gitignorePath = ".gitignore";
|
|
224
|
+
function ensureGitignoreEntries(gitignorePath, entries) {
|
|
221
225
|
const existing = existsSync(gitignorePath)
|
|
222
226
|
? readFileSync(gitignorePath, "utf-8")
|
|
223
227
|
: "";
|
|
@@ -236,45 +240,61 @@ export async function runInit(options) {
|
|
|
236
240
|
console.log("Firevault");
|
|
237
241
|
console.log("Undo button for Firestore.");
|
|
238
242
|
console.log("");
|
|
239
|
-
const
|
|
240
|
-
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);
|
|
241
248
|
const rl = options.yes ? undefined : createInterface({ input, output });
|
|
242
249
|
try {
|
|
243
|
-
if (
|
|
250
|
+
if (parentIsGitRepository && hasWorkingTreeChanges(appRoot) && !options.force) {
|
|
244
251
|
throw new Error("Git working tree has changes. Commit, stash, or rerun with --force before init writes files.");
|
|
245
252
|
}
|
|
246
253
|
if (existsSync(configPath) && !options.force) {
|
|
247
|
-
throw new Error("firevault
|
|
254
|
+
throw new Error(".firevault/config.json already exists. Rerun with --force to overwrite it.");
|
|
248
255
|
}
|
|
249
|
-
const shouldInitGit =
|
|
256
|
+
const shouldInitGit = workspaceIsGitRepository
|
|
250
257
|
? false
|
|
251
258
|
: await promptForGitInit(options, rl);
|
|
252
|
-
const config = await promptForConfig(options, rl);
|
|
259
|
+
const config = await promptForConfig(options, workspaceRoot, rl);
|
|
253
260
|
config.collections = config.collections.map((collection) => collection.trim());
|
|
254
261
|
validateConfig(config);
|
|
262
|
+
const serviceAccountAbsolutePath = path.resolve(workspaceRoot, config.serviceAccountPath);
|
|
263
|
+
const serviceAccountDisplayPath = path.relative(appRoot, serviceAccountAbsolutePath);
|
|
255
264
|
if (options.yes) {
|
|
256
|
-
printServiceAccountGuidance(config.projectId,
|
|
265
|
+
printServiceAccountGuidance(config.projectId, serviceAccountDisplayPath);
|
|
257
266
|
}
|
|
258
|
-
printMissingServiceAccountInfo(
|
|
267
|
+
printMissingServiceAccountInfo(serviceAccountAbsolutePath, serviceAccountDisplayPath);
|
|
268
|
+
mkdirSync(workspaceRoot, { recursive: true });
|
|
259
269
|
if (shouldInitGit) {
|
|
260
|
-
initGitRepository();
|
|
261
|
-
console.log("Initialized Git repository.");
|
|
270
|
+
initGitRepository(workspaceRoot);
|
|
271
|
+
console.log("Initialized Git repository in .firevault.");
|
|
262
272
|
}
|
|
263
273
|
if (existsSync(configPath) && options.force) {
|
|
264
|
-
console.log("Warning: overwriting existing firevault
|
|
274
|
+
console.log("Warning: overwriting existing .firevault/config.json.");
|
|
265
275
|
}
|
|
266
276
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
267
|
-
ensureGitignoreEntries([
|
|
268
|
-
"serviceAccountKey.json",
|
|
277
|
+
ensureGitignoreEntries(path.join(workspaceRoot, ".gitignore"), [
|
|
269
278
|
gitignorePathFor(config.serviceAccountPath),
|
|
270
|
-
"firestore-backups/",
|
|
271
279
|
"firestore-debug.log",
|
|
280
|
+
".env",
|
|
281
|
+
".env.*",
|
|
272
282
|
]);
|
|
273
|
-
|
|
274
|
-
|
|
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.");
|
|
275
295
|
console.log("");
|
|
276
296
|
console.log("Next steps:");
|
|
277
|
-
console.log(`1. Save your service account key at ${
|
|
297
|
+
console.log(`1. Save your service account key at ${serviceAccountDisplayPath}`);
|
|
278
298
|
console.log("2. Run `firevault snapshot`");
|
|
279
299
|
console.log("3. Run `firevault changes`");
|
|
280
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);
|
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
const likelyServiceAccountPaths = [
|
|
3
4
|
"./serviceAccountKey.json",
|
|
4
5
|
"./service-account.json",
|
|
5
6
|
"./firebase-service-account.json",
|
|
6
7
|
"./credentials/firebase.json",
|
|
7
8
|
];
|
|
8
|
-
|
|
9
|
-
|
|
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)];
|
|
10
29
|
}
|
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,9 +80,10 @@ 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.
|
|
85
87
|
- `firevault init` can suggest project IDs from local Firebase config files and likely service account paths from local filenames.
|
|
86
88
|
- `firevault init --force` allows dirty Git state and config overwrite with a warning.
|
|
87
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.
|
|
@@ -93,8 +95,8 @@ Current implementation notes:
|
|
|
93
95
|
Behavior:
|
|
94
96
|
|
|
95
97
|
- prints the Firevault identity before prompting,
|
|
96
|
-
- detects whether the
|
|
97
|
-
- offers
|
|
98
|
+
- detects whether the app directory is inside a Git repository,
|
|
99
|
+
- offers to initialize Git inside `.firevault/`,
|
|
98
100
|
- scans common local Firebase files for project ID candidates,
|
|
99
101
|
- shows detected project ID candidates with their source files,
|
|
100
102
|
- suggests likely service account paths without reading or printing private key contents,
|
|
@@ -102,8 +104,9 @@ Behavior:
|
|
|
102
104
|
- explains where to save the manually downloaded service account key,
|
|
103
105
|
- optionally lists top-level Firestore collections only after telling the user and only when the selected service account file exists,
|
|
104
106
|
- refuses to run in a dirty Git working tree unless `--force` is provided,
|
|
105
|
-
- refuses to overwrite
|
|
106
|
-
-
|
|
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,
|
|
107
110
|
- never creates service accounts, opens browsers, runs `gcloud`, commits, pushes, creates GitHub repositories, contacts Firebase, or writes secrets.
|
|
108
111
|
|
|
109
112
|
## Firebase Access
|
|
@@ -120,6 +123,17 @@ Design constraints:
|
|
|
120
123
|
- do not introduce credential brokerage, hosted auth, or account systems,
|
|
121
124
|
- avoid committing service account files.
|
|
122
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
|
+
|
|
123
137
|
## Emulator Tests
|
|
124
138
|
|
|
125
139
|
Firestore emulator integration tests live under `test/integration/`.
|
|
@@ -153,7 +167,7 @@ Current backup flow:
|
|
|
153
167
|
1. Load config.
|
|
154
168
|
2. Iterate configured collections.
|
|
155
169
|
3. Fetch each collection through Firebase Admin SDK.
|
|
156
|
-
4. Write each document to
|
|
170
|
+
4. Write each document to `.firevault/<outputDir>/<collection>/<documentId>.json`.
|
|
157
171
|
5. Serialize document data using deterministic JSON.
|
|
158
172
|
|
|
159
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.
|
|
@@ -175,6 +189,8 @@ Serialization rules should remain boring and predictable:
|
|
|
175
189
|
|
|
176
190
|
Git is the storage and history engine. Firevault should wrap Git workflows rather than reimplement versioning.
|
|
177
191
|
|
|
192
|
+
For Firevault 0.2, Git operations are scoped to the `.firevault` workspace repository, not the parent app repository.
|
|
193
|
+
|
|
178
194
|
Near-term Git integration should focus on:
|
|
179
195
|
|
|
180
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
|
|