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 CHANGED
@@ -1,6 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1-beta.1 - Unreleased
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
- firestore-backups/
29
- users/
30
- abc123.json
31
- def456.json
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
- Create a Firebase service account key for your Firestore project, save it as `serviceAccountKey.json`, and keep it out of Git.
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 also checks Git state before writing files and appends safety entries to `.gitignore`.
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
- ./serviceAccountKey.json
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
- Generated `firevault.config.json`:
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
- - `serviceAccountPath` points to a local Firebase service account JSON file.
190
- - `outputDir` is where Firestore documents are written.
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
- Recommended `.gitignore` entries for local development:
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-backups/
222
+ firestore-debug.log
223
+ .env
224
+ .env.*
199
225
  ```
200
226
 
201
- `firevault init` adds these safety entries automatically. `firestore-backups/` is ignored by default so exported Firestore data is not committed accidentally with normal Git commands. Firevault can still commit the configured backup directory explicitly through `firevault commit` or `firevault snapshot`, and it stages only that directory.
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` expects to run inside a Git repository.
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.
@@ -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.outputDir, collection);
7
+ await exportCollection(config.outputDirPath, collection);
8
8
  }
9
9
  console.log("Backup complete.");
10
10
  }
@@ -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")
@@ -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")
@@ -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;
@@ -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: ${serviceAccountPath}`);
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: ${serviceAccountPath}`);
61
+ console.log(`Save the downloaded JSON key at: ${serviceAccountDisplayPath}`);
55
62
  console.log("");
56
63
  }
57
- async function promptForConfig(options, rl) {
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 defaultConfig;
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 = (await rl.question("Firebase project ID: ")).trim();
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 = (await rl.question(`Service account path (${defaultConfig.serviceAccountPath}): `)).trim() || defaultConfig.serviceAccountPath;
197
+ const serviceAccountPath = await promptForServiceAccountPath(rl, serviceAccountPaths);
69
198
  const outputDir = (await rl.question(`Output directory (${defaultConfig.outputDir}): `)).trim() || defaultConfig.outputDir;
70
- const collectionsInput = (await rl.question("Collections, comma-separated: ")).trim();
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("This directory is not a Git repository. Run git init? (Y/n): "))
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 configPath = "firevault.config.json";
111
- const alreadyInGitRepository = isInsideGitRepository();
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 (alreadyInGitRepository && hasWorkingTreeChanges() && !options.force) {
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.config.json already exists. Rerun with --force to overwrite it.");
254
+ throw new Error(".firevault/config.json already exists. Rerun with --force to overwrite it.");
119
255
  }
120
- const shouldInitGit = alreadyInGitRepository
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, config.serviceAccountPath);
265
+ printServiceAccountGuidance(config.projectId, serviceAccountDisplayPath);
128
266
  }
129
- printMissingServiceAccountInfo(config.serviceAccountPath);
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.config.json.");
274
+ console.log("Warning: overwriting existing .firevault/config.json.");
136
275
  }
137
276
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
138
- ensureGitignoreEntries([
139
- "serviceAccountKey.json",
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
- console.log("Created firevault.config.json.");
144
- console.log("Updated .gitignore safety entries.");
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 ${config.serviceAccountPath}`);
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 currentExists = existsSync(target.backupPath);
71
+ const targetFilePath = resolveWorkspacePath(config.workspaceRoot, target.backupPath);
72
+ const currentExists = existsSync(targetFilePath);
72
73
  const currentContent = currentExists
73
- ? readFileSync(target.backupPath, "utf-8")
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 currentExists = existsSync(targetPath);
20
- const currentContent = currentExists ? readFileSync(targetPath, "utf-8") : undefined;
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(targetPath), { recursive: true });
24
- writeFileSync(targetPath, restoredContent);
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 currentExists = existsSync(targetPath);
82
- const currentContent = currentExists ? readFileSync(targetPath, "utf-8") : undefined;
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.config.json: "${field}" is required and must be a string.`);
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.config.json: "${field}" is required and must include at least one collection name.`);
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 configPath = "firevault.config.json";
29
- if (!existsSync(configPath)) {
30
- throw new ConfigError("Missing firevault.config.json. Run `firevault init` first.");
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.config.json: file is not valid JSON.");
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.config.json: expected a JSON object.");
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.serviceAccountPath)) {
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.serviceAccountPath, "utf-8"));
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.1.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
+ }
@@ -27,21 +27,16 @@ The architecture should optimize for:
27
27
  ## Current Project Structure
28
28
 
29
29
  ```txt
30
- src/
31
- commands/
32
- init.ts
33
- backup.ts
34
- config/
35
- loadConfig.ts
36
- firestore/
37
- exportFirestore.ts
38
- firebase.ts
39
- stableStringify.ts
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 `firevault.config.json`.
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` reads and parses `firevault.config.json`.
83
- - Firebase initialization expects `serviceAccountPath`.
84
- - `firevault init` guides setup, validates required fields, checks Git state before writing, and updates `.gitignore` without overwriting existing entries.
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 current directory is inside a Git repository,
96
- - offers `git init` when no repository exists,
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 `firevault.config.json` unless `--force` is provided,
101
- - appends `.gitignore` safety entries without duplicating existing lines,
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 `<outputDir>/<collection>/<documentId>.json`.
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
- - Guided `firevault init` validates setup input, checks Git state, and applies `.gitignore` safety entries.
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firevault",
3
- "version": "0.1.1-beta.2",
3
+ "version": "0.2.0-beta.0",
4
4
  "description": "Undo button for Firestore. Git-style history, rollback, and recovery for Firestore projects.",
5
5
  "keywords": [
6
6
  "firebase",