firevault 0.1.1-beta.1 → 0.1.1-beta.3
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 +1 -0
- package/README.md +49 -8
- package/dist/commands/init.js +219 -59
- package/dist/init/detectFirebaseProject.js +79 -0
- package/dist/init/detectServiceAccount.js +10 -0
- package/dist/init/listCollections.js +33 -0
- package/docs/architecture.md +18 -4
- package/docs/roadmap.md +2 -1
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
- Added init Git safety checks, `--force`, and `--yes`.
|
|
7
7
|
- Added safe `.gitignore` updates for service account keys, backup output, and emulator logs.
|
|
8
8
|
- Ensured Firevault can still explicitly commit the configured backup directory even when it is ignored by default.
|
|
9
|
+
- Added a guarded local npm prerelease publish workflow using `gitversionjs`, pack verification, and forbidden-path checks.
|
|
9
10
|
|
|
10
11
|
## 0.1.0
|
|
11
12
|
|
package/README.md
CHANGED
|
@@ -49,6 +49,26 @@ firevault init
|
|
|
49
49
|
|
|
50
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`.
|
|
51
51
|
|
|
52
|
+
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
|
+
|
|
54
|
+
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`.
|
|
55
|
+
|
|
56
|
+
After you enter a project ID, Firevault prints the direct Firebase Console URL for that project's Admin SDK service account page:
|
|
57
|
+
|
|
58
|
+
```txt
|
|
59
|
+
Create a Firebase service account key here:
|
|
60
|
+
|
|
61
|
+
https://console.firebase.google.com/project/your-project-id/settings/serviceaccounts/adminsdk
|
|
62
|
+
|
|
63
|
+
Download the JSON key and save it as:
|
|
64
|
+
|
|
65
|
+
./serviceAccountKey.json
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Firevault does not create service accounts, open a browser, run `gcloud`, or authenticate against Firebase during setup.
|
|
69
|
+
|
|
70
|
+
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
|
+
|
|
52
72
|
Generated `firevault.config.json`:
|
|
53
73
|
|
|
54
74
|
```json
|
|
@@ -330,15 +350,36 @@ Covered emulator flows:
|
|
|
330
350
|
|
|
331
351
|
## Publishing
|
|
332
352
|
|
|
333
|
-
|
|
353
|
+
Firevault uses a local publish script for early prereleases. npm auth must already be configured before publishing; `npm whoami` should succeed for the intended npm account.
|
|
354
|
+
|
|
355
|
+
Calculate the Git-derived prerelease version:
|
|
356
|
+
|
|
357
|
+
```bash
|
|
358
|
+
npm run version:calculate
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Verify the package without publishing:
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
npm run publish:dry-run
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Publish the prerelease with the `next` dist-tag:
|
|
368
|
+
|
|
369
|
+
```bash
|
|
370
|
+
npm run publish:next
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Use `npm run publish:next -- --yes` only when you intentionally want to skip the final confirmation prompt.
|
|
374
|
+
|
|
375
|
+
The publish script:
|
|
334
376
|
|
|
335
|
-
-
|
|
336
|
-
-
|
|
337
|
-
-
|
|
338
|
-
-
|
|
339
|
-
-
|
|
340
|
-
-
|
|
341
|
-
- verify logs such as `firestore-debug.log` are not included.
|
|
377
|
+
- requires a clean Git working tree before real publishing,
|
|
378
|
+
- calculates the npm prerelease version with `gitversionjs`,
|
|
379
|
+
- runs clean, build, and emulator tests,
|
|
380
|
+
- runs `npm pack --dry-run --cache /private/tmp/firevault-npm-cache`,
|
|
381
|
+
- rejects forbidden package contents such as `serviceAccountKey.json`, `firestore-backups/`, `firestore-debug.log`, `src/`, `test/`, `firebase.json`, `firestore.rules`, and `.env` files,
|
|
382
|
+
- publishes with `npm publish --access public --tag next --cache /private/tmp/firevault-npm-cache`.
|
|
342
383
|
|
|
343
384
|
The package `bin` points to `./dist/index.js`, so a published or linked package must include compiled output. `prepublishOnly` currently runs clean, build, and emulator tests.
|
|
344
385
|
|
package/dist/commands/init.js
CHANGED
|
@@ -3,6 +3,9 @@ import { createInterface } from "node:readline/promises";
|
|
|
3
3
|
import { stdin as input, stdout as output } from "node:process";
|
|
4
4
|
import { appendFileSync, existsSync, readFileSync, writeFileSync, } from "node:fs";
|
|
5
5
|
import { GitError, hasWorkingTreeChanges, initGitRepository, isInsideGitRepository, } from "../git/git.js";
|
|
6
|
+
import { detectFirebaseProjectCandidates, uniqueProjectIds, } from "../init/detectFirebaseProject.js";
|
|
7
|
+
import { detectServiceAccountPaths } from "../init/detectServiceAccount.js";
|
|
8
|
+
import { listFirestoreCollections } from "../init/listCollections.js";
|
|
6
9
|
const defaultConfig = {
|
|
7
10
|
projectId: "your-firebase-project-id",
|
|
8
11
|
serviceAccountPath: "./serviceAccountKey.json",
|
|
@@ -29,43 +32,189 @@ function validateConfig(config) {
|
|
|
29
32
|
function parseCollections(value) {
|
|
30
33
|
return value
|
|
31
34
|
.split(",")
|
|
32
|
-
.map((collection) => collection.trim())
|
|
35
|
+
.map((collection) => collection.trim())
|
|
36
|
+
.filter((collection) => collection !== "");
|
|
33
37
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
function getServiceAccountUrl(projectId) {
|
|
39
|
+
return `https://console.firebase.google.com/project/${encodeURIComponent(projectId)}/settings/serviceaccounts/adminsdk`;
|
|
40
|
+
}
|
|
41
|
+
function printServiceAccountGuidance(projectId, serviceAccountPath) {
|
|
42
|
+
console.log("");
|
|
43
|
+
console.log("Create a Firebase service account key here:");
|
|
44
|
+
console.log("");
|
|
45
|
+
console.log(getServiceAccountUrl(projectId));
|
|
46
|
+
console.log("");
|
|
47
|
+
console.log("Download the JSON key and save it as:");
|
|
48
|
+
console.log("");
|
|
49
|
+
console.log(serviceAccountPath);
|
|
50
|
+
console.log("");
|
|
51
|
+
}
|
|
52
|
+
function printMissingServiceAccountInfo(serviceAccountPath) {
|
|
53
|
+
if (existsSync(serviceAccountPath)) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
console.log(`Service account file does not exist yet: ${serviceAccountPath}`);
|
|
57
|
+
console.log("That is expected before downloading the Firebase Admin SDK key.");
|
|
58
|
+
console.log(`Save the downloaded JSON key at: ${serviceAccountPath}`);
|
|
59
|
+
console.log("");
|
|
60
|
+
}
|
|
61
|
+
function gitignorePathFor(filePath) {
|
|
62
|
+
return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
63
|
+
}
|
|
64
|
+
function printDetectedProjectCandidates(candidates) {
|
|
65
|
+
if (candidates.length === 0) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
console.log("Detected Firebase project IDs:");
|
|
69
|
+
console.log("");
|
|
70
|
+
candidates.forEach((candidate, index) => {
|
|
71
|
+
console.log(`${index + 1}. ${candidate.projectId} from ${candidate.source}`);
|
|
72
|
+
});
|
|
73
|
+
console.log("");
|
|
74
|
+
}
|
|
75
|
+
async function promptForProjectId(rl, candidates) {
|
|
76
|
+
const projectIds = uniqueProjectIds(candidates);
|
|
77
|
+
if (projectIds.length === 0) {
|
|
78
|
+
return (await rl.question("Firebase project ID: ")).trim();
|
|
79
|
+
}
|
|
80
|
+
printDetectedProjectCandidates(candidates);
|
|
81
|
+
if (projectIds.length === 1) {
|
|
82
|
+
return (await rl.question(`Firebase project ID (${projectIds[0]}): `)).trim() || projectIds[0];
|
|
83
|
+
}
|
|
84
|
+
const answer = (await rl.question("Select Firebase project ID by number or enter one manually: ")).trim();
|
|
85
|
+
const selection = Number(answer);
|
|
86
|
+
if (Number.isInteger(selection) && selection >= 1 && selection <= candidates.length) {
|
|
87
|
+
return candidates[selection - 1].projectId;
|
|
88
|
+
}
|
|
89
|
+
return answer;
|
|
90
|
+
}
|
|
91
|
+
function suggestedServiceAccountPath(detectedPaths) {
|
|
92
|
+
return detectedPaths[0] ?? defaultConfig.serviceAccountPath;
|
|
93
|
+
}
|
|
94
|
+
function printDetectedServiceAccounts(detectedPaths) {
|
|
95
|
+
if (detectedPaths.length === 0) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
console.log("Detected possible service account files:");
|
|
99
|
+
console.log("");
|
|
100
|
+
detectedPaths.forEach((filePath, index) => {
|
|
101
|
+
console.log(`${index + 1}. ${filePath}`);
|
|
102
|
+
});
|
|
103
|
+
console.log("");
|
|
104
|
+
}
|
|
105
|
+
async function promptForServiceAccountPath(rl, detectedPaths) {
|
|
106
|
+
printDetectedServiceAccounts(detectedPaths);
|
|
107
|
+
const suggestedPath = suggestedServiceAccountPath(detectedPaths);
|
|
108
|
+
const answer = (await rl.question(`Service account path (${suggestedPath}): `)).trim();
|
|
109
|
+
const selection = Number(answer);
|
|
110
|
+
if (detectedPaths.length > 0 &&
|
|
111
|
+
Number.isInteger(selection) &&
|
|
112
|
+
selection >= 1 &&
|
|
113
|
+
selection <= detectedPaths.length) {
|
|
114
|
+
return detectedPaths[selection - 1];
|
|
115
|
+
}
|
|
116
|
+
return answer || suggestedPath;
|
|
117
|
+
}
|
|
118
|
+
function parseSelectedCollections(inputValue, detectedCollections) {
|
|
119
|
+
const selectedCollections = [];
|
|
120
|
+
for (const item of inputValue.split(",")) {
|
|
121
|
+
const value = item.trim();
|
|
122
|
+
if (value === "") {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const selection = Number(value);
|
|
126
|
+
if (Number.isInteger(selection) &&
|
|
127
|
+
selection >= 1 &&
|
|
128
|
+
selection <= detectedCollections.length) {
|
|
129
|
+
selectedCollections.push(detectedCollections[selection - 1]);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
selectedCollections.push(value);
|
|
133
|
+
}
|
|
134
|
+
return [...new Set(selectedCollections)];
|
|
135
|
+
}
|
|
136
|
+
async function promptForCollectionListing(rl, projectId, serviceAccountPath) {
|
|
137
|
+
if (!existsSync(serviceAccountPath)) {
|
|
138
|
+
console.log(`Service account file is not present, so collection detection is skipped: ${serviceAccountPath}`);
|
|
139
|
+
console.log("");
|
|
140
|
+
return undefined;
|
|
37
141
|
}
|
|
38
|
-
const
|
|
142
|
+
const answer = (await rl.question("Try to list Firestore collections with this service account? (y/N): "))
|
|
143
|
+
.trim()
|
|
144
|
+
.toLowerCase();
|
|
145
|
+
if (answer !== "y" && answer !== "yes") {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
console.log("Connecting to Firestore to list top-level collections...");
|
|
149
|
+
let detectedCollections;
|
|
39
150
|
try {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
151
|
+
detectedCollections = await listFirestoreCollections(projectId, serviceAccountPath);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
155
|
+
console.log(`Could not list Firestore collections: ${message}`);
|
|
156
|
+
console.log("You can enter collection names manually.");
|
|
157
|
+
console.log("");
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
if (detectedCollections.length === 0) {
|
|
161
|
+
console.log("No top-level Firestore collections were detected.");
|
|
162
|
+
console.log("");
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
console.log("");
|
|
166
|
+
console.log("Detected Firestore collections:");
|
|
167
|
+
console.log("");
|
|
168
|
+
detectedCollections.forEach((collection, index) => {
|
|
169
|
+
console.log(`${index + 1}. ${collection}`);
|
|
170
|
+
});
|
|
171
|
+
console.log("");
|
|
172
|
+
const selected = (await rl.question("Collections to back up, comma-separated numbers or names: ")).trim();
|
|
173
|
+
if (selected === "") {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
return parseSelectedCollections(selected, detectedCollections);
|
|
177
|
+
}
|
|
178
|
+
async function promptForConfig(options, rl) {
|
|
179
|
+
const projectCandidates = detectFirebaseProjectCandidates();
|
|
180
|
+
const serviceAccountPaths = detectServiceAccountPaths();
|
|
181
|
+
if (options.yes) {
|
|
44
182
|
return {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
outputDir,
|
|
48
|
-
collections: parseCollections(collectionsInput),
|
|
183
|
+
...defaultConfig,
|
|
184
|
+
projectId: uniqueProjectIds(projectCandidates)[0] ?? defaultConfig.projectId,
|
|
49
185
|
};
|
|
50
186
|
}
|
|
51
|
-
|
|
52
|
-
|
|
187
|
+
if (!rl) {
|
|
188
|
+
throw new Error("Prompt interface is required for interactive init.");
|
|
189
|
+
}
|
|
190
|
+
const projectId = await promptForProjectId(rl, projectCandidates);
|
|
191
|
+
if (projectId !== "") {
|
|
192
|
+
printServiceAccountGuidance(projectId, defaultConfig.serviceAccountPath);
|
|
53
193
|
}
|
|
194
|
+
const serviceAccountPath = await promptForServiceAccountPath(rl, serviceAccountPaths);
|
|
195
|
+
const outputDir = (await rl.question(`Output directory (${defaultConfig.outputDir}): `)).trim() || defaultConfig.outputDir;
|
|
196
|
+
const detectedCollections = await promptForCollectionListing(rl, projectId, serviceAccountPath);
|
|
197
|
+
const collectionsInput = detectedCollections
|
|
198
|
+
? detectedCollections.join(",")
|
|
199
|
+
: (await rl.question("Collections, comma-separated: ")).trim();
|
|
200
|
+
return {
|
|
201
|
+
projectId,
|
|
202
|
+
serviceAccountPath,
|
|
203
|
+
outputDir,
|
|
204
|
+
collections: parseCollections(collectionsInput),
|
|
205
|
+
};
|
|
54
206
|
}
|
|
55
|
-
async function promptForGitInit(options) {
|
|
207
|
+
async function promptForGitInit(options, rl) {
|
|
56
208
|
if (options.yes) {
|
|
57
209
|
return true;
|
|
58
210
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const answer = (await rl.question("This directory is not a Git repository. Run git init? (Y/n): "))
|
|
62
|
-
.trim()
|
|
63
|
-
.toLowerCase();
|
|
64
|
-
return answer === "" || answer === "y" || answer === "yes";
|
|
65
|
-
}
|
|
66
|
-
finally {
|
|
67
|
-
rl.close();
|
|
211
|
+
if (!rl) {
|
|
212
|
+
throw new Error("Prompt interface is required for interactive init.");
|
|
68
213
|
}
|
|
214
|
+
const answer = (await rl.question("This directory is not a Git repository. Run git init? (Y/n): "))
|
|
215
|
+
.trim()
|
|
216
|
+
.toLowerCase();
|
|
217
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
69
218
|
}
|
|
70
219
|
function ensureGitignoreEntries(entries) {
|
|
71
220
|
const gitignorePath = ".gitignore";
|
|
@@ -76,7 +225,7 @@ function ensureGitignoreEntries(entries) {
|
|
|
76
225
|
.split("\n")
|
|
77
226
|
.map((line) => line.trim())
|
|
78
227
|
.filter((line) => line !== ""));
|
|
79
|
-
const missingEntries = entries.filter((entry) => !existingLines.has(entry));
|
|
228
|
+
const missingEntries = [...new Set(entries)].filter((entry) => !existingLines.has(entry));
|
|
80
229
|
if (missingEntries.length === 0) {
|
|
81
230
|
return;
|
|
82
231
|
}
|
|
@@ -89,39 +238,50 @@ export async function runInit(options) {
|
|
|
89
238
|
console.log("");
|
|
90
239
|
const configPath = "firevault.config.json";
|
|
91
240
|
const alreadyInGitRepository = isInsideGitRepository();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
241
|
+
const rl = options.yes ? undefined : createInterface({ input, output });
|
|
242
|
+
try {
|
|
243
|
+
if (alreadyInGitRepository && hasWorkingTreeChanges() && !options.force) {
|
|
244
|
+
throw new Error("Git working tree has changes. Commit, stash, or rerun with --force before init writes files.");
|
|
245
|
+
}
|
|
246
|
+
if (existsSync(configPath) && !options.force) {
|
|
247
|
+
throw new Error("firevault.config.json already exists. Rerun with --force to overwrite it.");
|
|
248
|
+
}
|
|
249
|
+
const shouldInitGit = alreadyInGitRepository
|
|
250
|
+
? false
|
|
251
|
+
: await promptForGitInit(options, rl);
|
|
252
|
+
const config = await promptForConfig(options, rl);
|
|
253
|
+
config.collections = config.collections.map((collection) => collection.trim());
|
|
254
|
+
validateConfig(config);
|
|
255
|
+
if (options.yes) {
|
|
256
|
+
printServiceAccountGuidance(config.projectId, config.serviceAccountPath);
|
|
257
|
+
}
|
|
258
|
+
printMissingServiceAccountInfo(config.serviceAccountPath);
|
|
259
|
+
if (shouldInitGit) {
|
|
260
|
+
initGitRepository();
|
|
261
|
+
console.log("Initialized Git repository.");
|
|
262
|
+
}
|
|
263
|
+
if (existsSync(configPath) && options.force) {
|
|
264
|
+
console.log("Warning: overwriting existing firevault.config.json.");
|
|
265
|
+
}
|
|
266
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
267
|
+
ensureGitignoreEntries([
|
|
268
|
+
"serviceAccountKey.json",
|
|
269
|
+
gitignorePathFor(config.serviceAccountPath),
|
|
270
|
+
"firestore-backups/",
|
|
271
|
+
"firestore-debug.log",
|
|
272
|
+
]);
|
|
273
|
+
console.log("Created firevault.config.json.");
|
|
274
|
+
console.log("Updated .gitignore safety entries.");
|
|
275
|
+
console.log("");
|
|
276
|
+
console.log("Next steps:");
|
|
277
|
+
console.log(`1. Save your service account key at ${config.serviceAccountPath}`);
|
|
278
|
+
console.log("2. Run `firevault snapshot`");
|
|
279
|
+
console.log("3. Run `firevault changes`");
|
|
280
|
+
console.log("4. Run `firevault restore-preview <path> --from <commit>` before a real restore");
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
rl?.close();
|
|
284
|
+
}
|
|
125
285
|
}
|
|
126
286
|
export const initCommand = new Command("init")
|
|
127
287
|
.description("Create a guided Firevault configuration")
|
|
@@ -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,10 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
const likelyServiceAccountPaths = [
|
|
3
|
+
"./serviceAccountKey.json",
|
|
4
|
+
"./service-account.json",
|
|
5
|
+
"./firebase-service-account.json",
|
|
6
|
+
"./credentials/firebase.json",
|
|
7
|
+
];
|
|
8
|
+
export function detectServiceAccountPaths() {
|
|
9
|
+
return likelyServiceAccountPaths.filter((filePath) => existsSync(filePath));
|
|
10
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import admin from "firebase-admin";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
export async function listFirestoreCollections(projectId, serviceAccountPath) {
|
|
4
|
+
if (!existsSync(serviceAccountPath)) {
|
|
5
|
+
throw new Error(`Service account file not found: ${serviceAccountPath}`);
|
|
6
|
+
}
|
|
7
|
+
const emulatorHost = process.env.FIRESTORE_EMULATOR_HOST;
|
|
8
|
+
let appOptions;
|
|
9
|
+
if (emulatorHost) {
|
|
10
|
+
appOptions = { projectId };
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
let serviceAccount;
|
|
14
|
+
try {
|
|
15
|
+
serviceAccount = JSON.parse(readFileSync(serviceAccountPath, "utf-8"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error(`Invalid service account file: ${serviceAccountPath}`);
|
|
19
|
+
}
|
|
20
|
+
appOptions = {
|
|
21
|
+
credential: admin.credential.cert(serviceAccount),
|
|
22
|
+
projectId,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const app = admin.initializeApp(appOptions, `firevault-init-${Date.now()}`);
|
|
26
|
+
try {
|
|
27
|
+
const collections = await app.firestore().listCollections();
|
|
28
|
+
return collections.map((collection) => collection.id).sort();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await app.delete();
|
|
32
|
+
}
|
|
33
|
+
}
|
package/docs/architecture.md
CHANGED
|
@@ -82,8 +82,9 @@ Current implementation notes:
|
|
|
82
82
|
- `loadConfig` reads and parses `firevault.config.json`.
|
|
83
83
|
- Firebase initialization expects `serviceAccountPath`.
|
|
84
84
|
- `firevault init` guides setup, validates required fields, checks Git state before writing, and updates `.gitignore` without overwriting existing entries.
|
|
85
|
+
- `firevault init` can suggest project IDs from local Firebase config files and likely service account paths from local filenames.
|
|
85
86
|
- `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.
|
|
87
|
+
- `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
88
|
|
|
88
89
|
## Init Safety
|
|
89
90
|
|
|
@@ -94,10 +95,16 @@ Behavior:
|
|
|
94
95
|
- prints the Firevault identity before prompting,
|
|
95
96
|
- detects whether the current directory is inside a Git repository,
|
|
96
97
|
- offers `git init` when no repository exists,
|
|
98
|
+
- scans common local Firebase files for project ID candidates,
|
|
99
|
+
- shows detected project ID candidates with their source files,
|
|
100
|
+
- suggests likely service account paths without reading or printing private key contents,
|
|
101
|
+
- prints the Firebase Console Admin SDK service account URL for the entered project ID,
|
|
102
|
+
- explains where to save the manually downloaded service account key,
|
|
103
|
+
- optionally lists top-level Firestore collections only after telling the user and only when the selected service account file exists,
|
|
97
104
|
- refuses to run in a dirty Git working tree unless `--force` is provided,
|
|
98
105
|
- refuses to overwrite `firevault.config.json` unless `--force` is provided,
|
|
99
|
-
- appends `.gitignore` safety entries without duplicating existing lines,
|
|
100
|
-
- never commits, pushes, creates GitHub repositories, contacts Firebase, or writes secrets.
|
|
106
|
+
- appends `.gitignore` safety entries, including the selected service account path, without duplicating existing lines,
|
|
107
|
+
- never creates service accounts, opens browsers, runs `gcloud`, commits, pushes, creates GitHub repositories, contacts Firebase, or writes secrets.
|
|
101
108
|
|
|
102
109
|
## Firebase Access
|
|
103
110
|
|
|
@@ -109,7 +116,8 @@ Design constraints:
|
|
|
109
116
|
|
|
110
117
|
- operate only on existing Firestore projects,
|
|
111
118
|
- keep credentials local,
|
|
112
|
-
-
|
|
119
|
+
- use manually managed service account credentials,
|
|
120
|
+
- do not introduce credential brokerage, hosted auth, or account systems,
|
|
113
121
|
- avoid committing service account files.
|
|
114
122
|
|
|
115
123
|
## Emulator Tests
|
|
@@ -132,6 +140,12 @@ Current emulator coverage:
|
|
|
132
140
|
- collection path rejection,
|
|
133
141
|
- `--confirm` enforcement.
|
|
134
142
|
|
|
143
|
+
## Publishing
|
|
144
|
+
|
|
145
|
+
Prerelease publishing is intentionally local-script based for now. `scripts/publish.ts` calculates an npm-safe prerelease version from Git state with `gitversionjs`, verifies a clean working tree for real publishes, runs clean/build/emulator tests, inspects `npm pack --dry-run` contents, rejects known unsafe paths, and publishes with the `next` npm dist-tag only after explicit confirmation.
|
|
146
|
+
|
|
147
|
+
There is no GitHub Actions publishing, trusted publishing, or release automation beyond this local guard script yet.
|
|
148
|
+
|
|
135
149
|
## Export Pipeline
|
|
136
150
|
|
|
137
151
|
Current backup flow:
|
package/docs/roadmap.md
CHANGED
|
@@ -24,8 +24,9 @@ Status:
|
|
|
24
24
|
- Firestore emulator integration tests cover backup and document restore safety paths.
|
|
25
25
|
- CLI packaging runs from compiled `dist/index.js`.
|
|
26
26
|
- npm prerelease packaging is guarded by package file whitelisting and pack verification.
|
|
27
|
+
- Local npm prerelease publishing is guarded by a `gitversionjs`-based publish script with clean-tree, build, emulator-test, pack, and forbidden-path checks.
|
|
27
28
|
- Public GitHub release docs cover quick start, security, contributing, and issue triage.
|
|
28
|
-
- Guided `firevault init` validates setup input, checks Git state, and applies `.gitignore` safety entries.
|
|
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
30
|
|
|
30
31
|
Next work:
|
|
31
32
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firevault",
|
|
3
|
-
"version": "0.1.1-beta.
|
|
3
|
+
"version": "0.1.1-beta.3",
|
|
4
4
|
"description": "Undo button for Firestore. Git-style history, rollback, and recovery for Firestore projects.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"firebase",
|
|
@@ -33,6 +33,9 @@
|
|
|
33
33
|
"clean": "rm -rf dist",
|
|
34
34
|
"build": "tsc && chmod +x dist/index.js",
|
|
35
35
|
"prepublishOnly": "npm run clean && npm run build && npm run test:emulator",
|
|
36
|
+
"version:calculate": "tsx scripts/publish.ts --version-only",
|
|
37
|
+
"publish:dry-run": "tsx scripts/publish.ts --dry-run --allow-dirty",
|
|
38
|
+
"publish:next": "tsx scripts/publish.ts",
|
|
36
39
|
"test:emulator": "tsx test/integration/run-firestore-emulator.ts",
|
|
37
40
|
"backup": "tsx src/index.ts backup",
|
|
38
41
|
"commit": "tsx src/index.ts commit",
|
|
@@ -51,6 +54,7 @@
|
|
|
51
54
|
"devDependencies": {
|
|
52
55
|
"@types/node": "^24.0.0",
|
|
53
56
|
"firebase-tools": "^15.18.0",
|
|
57
|
+
"gitversionjs": "^1.0.15",
|
|
54
58
|
"tsx": "^4.20.0",
|
|
55
59
|
"typescript": "^5.8.0"
|
|
56
60
|
}
|