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 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,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 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.
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
- ./serviceAccountKey.json
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 `firevault.config.json`:
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
- - `serviceAccountPath` points to a local Firebase service account JSON file.
196
- - `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/`,
197
209
  - `collections` controls which top-level Firestore collections are exported.
198
210
  - Service account files must not be committed.
199
211
 
200
- Recommended `.gitignore` entries for local development:
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-backups/
222
+ firestore-debug.log
223
+ .env
224
+ .env.*
205
225
  ```
206
226
 
207
- `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.
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` expects to run inside a Git repository.
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.
@@ -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,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: ${serviceAccountPath}`);
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: ${serviceAccountPath}`);
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: ${serviceAccountPath}`);
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 detectedCollections = await promptForCollectionListing(rl, projectId, serviceAccountPath);
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("This directory is not a Git repository. Run git init? (Y/n): "))
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 configPath = "firevault.config.json";
240
- 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);
241
248
  const rl = options.yes ? undefined : createInterface({ input, output });
242
249
  try {
243
- if (alreadyInGitRepository && hasWorkingTreeChanges() && !options.force) {
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.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.");
248
255
  }
249
- const shouldInitGit = alreadyInGitRepository
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, config.serviceAccountPath);
265
+ printServiceAccountGuidance(config.projectId, serviceAccountDisplayPath);
257
266
  }
258
- printMissingServiceAccountInfo(config.serviceAccountPath);
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.config.json.");
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
- console.log("Created firevault.config.json.");
274
- 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.");
275
295
  console.log("");
276
296
  console.log("Next steps:");
277
- console.log(`1. Save your service account key at ${config.serviceAccountPath}`);
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 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);
@@ -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
- export function detectServiceAccountPaths() {
9
- return likelyServiceAccountPaths.filter((filePath) => existsSync(filePath));
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
  }
@@ -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,9 +80,10 @@ 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.
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 current directory is inside a Git repository,
97
- - 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/`,
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 `firevault.config.json` unless `--force` is provided,
106
- - appends `.gitignore` safety entries, including the selected service account path, 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,
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 `<outputDir>/<collection>/<documentId>.json`.
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
- - Guided `firevault init` validates setup input, checks Git state, suggests local Firebase project settings, optionally lists Firestore collections, 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.3",
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",