firevault 0.1.1-beta.3 → 0.2.0-beta.1
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 +19 -1
- package/README.md +89 -15
- package/dist/commands/backup.js +1 -1
- package/dist/commands/changes.js +3 -3
- package/dist/commands/commit.js +4 -4
- package/dist/commands/doctor.js +225 -0
- package/dist/commands/history.js +2 -2
- package/dist/commands/init.js +50 -30
- package/dist/commands/restoreFirestore.js +6 -5
- package/dist/commands/restoreLocal.js +8 -7
- package/dist/commands/restorePreview.js +6 -5
- package/dist/commands/setupGithubAction.js +120 -0
- package/dist/commands/status.js +124 -0
- package/dist/config/loadConfig.js +37 -9
- package/dist/firestore/firebase.js +2 -2
- package/dist/git/git.js +89 -23
- package/dist/index.js +7 -1
- package/dist/init/detectServiceAccount.js +21 -2
- package/docs/architecture.md +51 -27
- package/docs/doctor-design.md +418 -0
- package/docs/github-actions-design.md +317 -0
- package/docs/roadmap.md +8 -1
- package/docs/status-design.md +218 -0
- package/package.json +4 -1
package/dist/commands/init.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
3
|
import { stdin as input, stdout as output } from "node:process";
|
|
4
|
-
import { appendFileSync, existsSync, readFileSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
5
6
|
import { GitError, hasWorkingTreeChanges, initGitRepository, isInsideGitRepository, } from "../git/git.js";
|
|
6
7
|
import { detectFirebaseProjectCandidates, uniqueProjectIds, } from "../init/detectFirebaseProject.js";
|
|
7
8
|
import { detectServiceAccountPaths } from "../init/detectServiceAccount.js";
|
|
@@ -12,6 +13,8 @@ const defaultConfig = {
|
|
|
12
13
|
outputDir: "firestore-backups",
|
|
13
14
|
collections: ["users"],
|
|
14
15
|
};
|
|
16
|
+
const workspaceDirName = ".firevault";
|
|
17
|
+
const configFileName = "config.json";
|
|
15
18
|
function validateConfig(config) {
|
|
16
19
|
if (config.projectId.trim() === "") {
|
|
17
20
|
throw new Error("Firebase project ID is required.");
|
|
@@ -49,13 +52,13 @@ function printServiceAccountGuidance(projectId, serviceAccountPath) {
|
|
|
49
52
|
console.log(serviceAccountPath);
|
|
50
53
|
console.log("");
|
|
51
54
|
}
|
|
52
|
-
function printMissingServiceAccountInfo(serviceAccountPath) {
|
|
55
|
+
function printMissingServiceAccountInfo(serviceAccountPath, serviceAccountDisplayPath) {
|
|
53
56
|
if (existsSync(serviceAccountPath)) {
|
|
54
57
|
return;
|
|
55
58
|
}
|
|
56
|
-
console.log(`Service account file does not exist yet: ${
|
|
59
|
+
console.log(`Service account file does not exist yet: ${serviceAccountDisplayPath}`);
|
|
57
60
|
console.log("That is expected before downloading the Firebase Admin SDK key.");
|
|
58
|
-
console.log(`Save the downloaded JSON key at: ${
|
|
61
|
+
console.log(`Save the downloaded JSON key at: ${serviceAccountDisplayPath}`);
|
|
59
62
|
console.log("");
|
|
60
63
|
}
|
|
61
64
|
function gitignorePathFor(filePath) {
|
|
@@ -133,9 +136,9 @@ function parseSelectedCollections(inputValue, detectedCollections) {
|
|
|
133
136
|
}
|
|
134
137
|
return [...new Set(selectedCollections)];
|
|
135
138
|
}
|
|
136
|
-
async function promptForCollectionListing(rl, projectId, serviceAccountPath) {
|
|
139
|
+
async function promptForCollectionListing(rl, projectId, serviceAccountPath, serviceAccountDisplayPath) {
|
|
137
140
|
if (!existsSync(serviceAccountPath)) {
|
|
138
|
-
console.log(`Service account file is not present, so collection detection is skipped: ${
|
|
141
|
+
console.log(`Service account file is not present, so collection detection is skipped: ${serviceAccountDisplayPath}`);
|
|
139
142
|
console.log("");
|
|
140
143
|
return undefined;
|
|
141
144
|
}
|
|
@@ -175,9 +178,9 @@ async function promptForCollectionListing(rl, projectId, serviceAccountPath) {
|
|
|
175
178
|
}
|
|
176
179
|
return parseSelectedCollections(selected, detectedCollections);
|
|
177
180
|
}
|
|
178
|
-
async function promptForConfig(options, rl) {
|
|
181
|
+
async function promptForConfig(options, workspaceRoot, rl) {
|
|
179
182
|
const projectCandidates = detectFirebaseProjectCandidates();
|
|
180
|
-
const serviceAccountPaths = detectServiceAccountPaths();
|
|
183
|
+
const serviceAccountPaths = detectServiceAccountPaths(process.cwd(), workspaceRoot);
|
|
181
184
|
if (options.yes) {
|
|
182
185
|
return {
|
|
183
186
|
...defaultConfig,
|
|
@@ -189,11 +192,13 @@ async function promptForConfig(options, rl) {
|
|
|
189
192
|
}
|
|
190
193
|
const projectId = await promptForProjectId(rl, projectCandidates);
|
|
191
194
|
if (projectId !== "") {
|
|
192
|
-
printServiceAccountGuidance(projectId, defaultConfig.serviceAccountPath);
|
|
195
|
+
printServiceAccountGuidance(projectId, path.join(workspaceDirName, defaultConfig.serviceAccountPath.replace(/^\.\//, "")));
|
|
193
196
|
}
|
|
194
197
|
const serviceAccountPath = await promptForServiceAccountPath(rl, serviceAccountPaths);
|
|
195
198
|
const outputDir = (await rl.question(`Output directory (${defaultConfig.outputDir}): `)).trim() || defaultConfig.outputDir;
|
|
196
|
-
const
|
|
199
|
+
const serviceAccountAbsolutePath = path.resolve(workspaceRoot, serviceAccountPath);
|
|
200
|
+
const serviceAccountDisplayPath = path.relative(process.cwd(), serviceAccountAbsolutePath);
|
|
201
|
+
const detectedCollections = await promptForCollectionListing(rl, projectId, serviceAccountAbsolutePath, serviceAccountDisplayPath);
|
|
197
202
|
const collectionsInput = detectedCollections
|
|
198
203
|
? detectedCollections.join(",")
|
|
199
204
|
: (await rl.question("Collections, comma-separated: ")).trim();
|
|
@@ -211,13 +216,12 @@ async function promptForGitInit(options, rl) {
|
|
|
211
216
|
if (!rl) {
|
|
212
217
|
throw new Error("Prompt interface is required for interactive init.");
|
|
213
218
|
}
|
|
214
|
-
const answer = (await rl.question("
|
|
219
|
+
const answer = (await rl.question("Initialize Git inside .firevault? (Y/n): "))
|
|
215
220
|
.trim()
|
|
216
221
|
.toLowerCase();
|
|
217
222
|
return answer === "" || answer === "y" || answer === "yes";
|
|
218
223
|
}
|
|
219
|
-
function ensureGitignoreEntries(entries) {
|
|
220
|
-
const gitignorePath = ".gitignore";
|
|
224
|
+
function ensureGitignoreEntries(gitignorePath, entries) {
|
|
221
225
|
const existing = existsSync(gitignorePath)
|
|
222
226
|
? readFileSync(gitignorePath, "utf-8")
|
|
223
227
|
: "";
|
|
@@ -236,45 +240,61 @@ export async function runInit(options) {
|
|
|
236
240
|
console.log("Firevault");
|
|
237
241
|
console.log("Undo button for Firestore.");
|
|
238
242
|
console.log("");
|
|
239
|
-
const
|
|
240
|
-
const
|
|
243
|
+
const appRoot = process.cwd();
|
|
244
|
+
const workspaceRoot = path.join(appRoot, workspaceDirName);
|
|
245
|
+
const configPath = path.join(workspaceRoot, configFileName);
|
|
246
|
+
const parentIsGitRepository = isInsideGitRepository(appRoot);
|
|
247
|
+
const workspaceIsGitRepository = isInsideGitRepository(workspaceRoot);
|
|
241
248
|
const rl = options.yes ? undefined : createInterface({ input, output });
|
|
242
249
|
try {
|
|
243
|
-
if (
|
|
250
|
+
if (parentIsGitRepository && hasWorkingTreeChanges(appRoot) && !options.force) {
|
|
244
251
|
throw new Error("Git working tree has changes. Commit, stash, or rerun with --force before init writes files.");
|
|
245
252
|
}
|
|
246
253
|
if (existsSync(configPath) && !options.force) {
|
|
247
|
-
throw new Error("firevault
|
|
254
|
+
throw new Error(".firevault/config.json already exists. Rerun with --force to overwrite it.");
|
|
248
255
|
}
|
|
249
|
-
const shouldInitGit =
|
|
256
|
+
const shouldInitGit = workspaceIsGitRepository
|
|
250
257
|
? false
|
|
251
258
|
: await promptForGitInit(options, rl);
|
|
252
|
-
const config = await promptForConfig(options, rl);
|
|
259
|
+
const config = await promptForConfig(options, workspaceRoot, rl);
|
|
253
260
|
config.collections = config.collections.map((collection) => collection.trim());
|
|
254
261
|
validateConfig(config);
|
|
262
|
+
const serviceAccountAbsolutePath = path.resolve(workspaceRoot, config.serviceAccountPath);
|
|
263
|
+
const serviceAccountDisplayPath = path.relative(appRoot, serviceAccountAbsolutePath);
|
|
255
264
|
if (options.yes) {
|
|
256
|
-
printServiceAccountGuidance(config.projectId,
|
|
265
|
+
printServiceAccountGuidance(config.projectId, serviceAccountDisplayPath);
|
|
257
266
|
}
|
|
258
|
-
printMissingServiceAccountInfo(
|
|
267
|
+
printMissingServiceAccountInfo(serviceAccountAbsolutePath, serviceAccountDisplayPath);
|
|
268
|
+
mkdirSync(workspaceRoot, { recursive: true });
|
|
259
269
|
if (shouldInitGit) {
|
|
260
|
-
initGitRepository();
|
|
261
|
-
console.log("Initialized Git repository.");
|
|
270
|
+
initGitRepository(workspaceRoot);
|
|
271
|
+
console.log("Initialized Git repository in .firevault.");
|
|
262
272
|
}
|
|
263
273
|
if (existsSync(configPath) && options.force) {
|
|
264
|
-
console.log("Warning: overwriting existing firevault
|
|
274
|
+
console.log("Warning: overwriting existing .firevault/config.json.");
|
|
265
275
|
}
|
|
266
276
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
267
|
-
ensureGitignoreEntries([
|
|
268
|
-
"serviceAccountKey.json",
|
|
277
|
+
ensureGitignoreEntries(path.join(workspaceRoot, ".gitignore"), [
|
|
269
278
|
gitignorePathFor(config.serviceAccountPath),
|
|
270
|
-
"firestore-backups/",
|
|
271
279
|
"firestore-debug.log",
|
|
280
|
+
".env",
|
|
281
|
+
".env.*",
|
|
272
282
|
]);
|
|
273
|
-
|
|
274
|
-
|
|
283
|
+
if (parentIsGitRepository) {
|
|
284
|
+
const parentIgnoreEntries = [".firevault/"];
|
|
285
|
+
const serviceAccountInAppRepo = path.relative(appRoot, serviceAccountAbsolutePath);
|
|
286
|
+
if (!serviceAccountInAppRepo.startsWith("..") &&
|
|
287
|
+
!gitignorePathFor(serviceAccountInAppRepo).startsWith(".firevault/")) {
|
|
288
|
+
parentIgnoreEntries.push(gitignorePathFor(serviceAccountInAppRepo));
|
|
289
|
+
}
|
|
290
|
+
ensureGitignoreEntries(path.join(appRoot, ".gitignore"), parentIgnoreEntries);
|
|
291
|
+
console.log("Updated parent .gitignore safety entries.");
|
|
292
|
+
}
|
|
293
|
+
console.log("Created .firevault/config.json.");
|
|
294
|
+
console.log("Updated .firevault/.gitignore safety entries.");
|
|
275
295
|
console.log("");
|
|
276
296
|
console.log("Next steps:");
|
|
277
|
-
console.log(`1. Save your service account key at ${
|
|
297
|
+
console.log(`1. Save your service account key at ${serviceAccountDisplayPath}`);
|
|
278
298
|
console.log("2. Run `firevault snapshot`");
|
|
279
299
|
console.log("3. Run `firevault changes`");
|
|
280
300
|
console.log("4. Run `firevault restore-preview <path> --from <commit>` before a real restore");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
-
import { ConfigError, loadConfig } from "../config/loadConfig.js";
|
|
4
|
+
import { ConfigError, loadConfig, resolveWorkspacePath } from "../config/loadConfig.js";
|
|
5
5
|
import { FirestoreError, writeDocument } from "../firestore/writeDocument.js";
|
|
6
6
|
import { GitError, assertInsideGitRepository, showFileAtCommit, } from "../git/git.js";
|
|
7
7
|
import { normalizeDocumentPath, normalizeSlashes, } from "../paths/backupPaths.js";
|
|
@@ -65,12 +65,13 @@ export async function runRestoreFirestore(inputPath, options) {
|
|
|
65
65
|
}
|
|
66
66
|
const config = loadConfig();
|
|
67
67
|
const target = getFirestoreTarget(inputPath, config.outputDir);
|
|
68
|
-
assertInsideGitRepository();
|
|
69
|
-
const restoredContent = showFileAtCommit(options.from, target.backupPath);
|
|
68
|
+
assertInsideGitRepository(config.workspaceRoot);
|
|
69
|
+
const restoredContent = showFileAtCommit(options.from, target.backupPath, config.workspaceRoot);
|
|
70
70
|
const restoredData = parseRestoreJson(restoredContent, target.backupPath);
|
|
71
|
-
const
|
|
71
|
+
const targetFilePath = resolveWorkspacePath(config.workspaceRoot, target.backupPath);
|
|
72
|
+
const currentExists = existsSync(targetFilePath);
|
|
72
73
|
const currentContent = currentExists
|
|
73
|
-
? readFileSync(
|
|
74
|
+
? readFileSync(targetFilePath, "utf-8")
|
|
74
75
|
: undefined;
|
|
75
76
|
const diff = buildLineDiff(currentContent, restoredContent);
|
|
76
77
|
printFirestorePreview(target, options.from, currentExists, diff);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
-
import { ConfigError, loadConfig } from "../config/loadConfig.js";
|
|
4
|
+
import { ConfigError, loadConfig, resolveWorkspacePath } from "../config/loadConfig.js";
|
|
5
5
|
import { GitError, assertInsideGitRepository, showFileAtCommit, } from "../git/git.js";
|
|
6
6
|
import { normalizeDocumentPath } from "../paths/backupPaths.js";
|
|
7
7
|
import { buildLineDiff, printRestorePreview } from "./restorePreview.js";
|
|
@@ -14,14 +14,15 @@ export function runRestoreLocal(inputPath, options) {
|
|
|
14
14
|
}
|
|
15
15
|
const config = loadConfig();
|
|
16
16
|
const targetPath = normalizeDocumentPath(inputPath, config.outputDir);
|
|
17
|
-
assertInsideGitRepository();
|
|
18
|
-
const restoredContent = showFileAtCommit(options.from, targetPath);
|
|
19
|
-
const
|
|
20
|
-
const
|
|
17
|
+
assertInsideGitRepository(config.workspaceRoot);
|
|
18
|
+
const restoredContent = showFileAtCommit(options.from, targetPath, config.workspaceRoot);
|
|
19
|
+
const targetFilePath = resolveWorkspacePath(config.workspaceRoot, targetPath);
|
|
20
|
+
const currentExists = existsSync(targetFilePath);
|
|
21
|
+
const currentContent = currentExists ? readFileSync(targetFilePath, "utf-8") : undefined;
|
|
21
22
|
const diff = buildLineDiff(currentContent, restoredContent);
|
|
22
23
|
printRestorePreview(targetPath, options.from, currentExists, diff);
|
|
23
|
-
mkdirSync(path.dirname(
|
|
24
|
-
writeFileSync(
|
|
24
|
+
mkdirSync(path.dirname(targetFilePath), { recursive: true });
|
|
25
|
+
writeFileSync(targetFilePath, restoredContent);
|
|
25
26
|
console.log("");
|
|
26
27
|
console.log(`Restored local backup file: ${targetPath}`);
|
|
27
28
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import { ConfigError, loadConfig } from "../config/loadConfig.js";
|
|
3
|
+
import { ConfigError, loadConfig, resolveWorkspacePath } from "../config/loadConfig.js";
|
|
4
4
|
import { GitError, assertInsideGitRepository, showFileAtCommit, } from "../git/git.js";
|
|
5
5
|
import { normalizeDocumentPath } from "../paths/backupPaths.js";
|
|
6
6
|
function normalizeJson(content) {
|
|
@@ -76,10 +76,11 @@ export function runRestorePreview(inputPath, options) {
|
|
|
76
76
|
}
|
|
77
77
|
const config = loadConfig();
|
|
78
78
|
const targetPath = normalizeDocumentPath(inputPath, config.outputDir);
|
|
79
|
-
assertInsideGitRepository();
|
|
80
|
-
const restoredContent = showFileAtCommit(options.from, targetPath);
|
|
81
|
-
const
|
|
82
|
-
const
|
|
79
|
+
assertInsideGitRepository(config.workspaceRoot);
|
|
80
|
+
const restoredContent = showFileAtCommit(options.from, targetPath, config.workspaceRoot);
|
|
81
|
+
const targetFilePath = resolveWorkspacePath(config.workspaceRoot, targetPath);
|
|
82
|
+
const currentExists = existsSync(targetFilePath);
|
|
83
|
+
const currentContent = currentExists ? readFileSync(targetFilePath, "utf-8") : undefined;
|
|
83
84
|
const diff = buildLineDiff(currentContent, restoredContent);
|
|
84
85
|
printRestorePreview(targetPath, options.from, currentExists, diff);
|
|
85
86
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { ConfigError, loadConfig } from "../config/loadConfig.js";
|
|
5
|
+
const workflowRelativePath = ".github/workflows/firevault-snapshot.yml";
|
|
6
|
+
const secretName = "FIREVAULT_SERVICE_ACCOUNT_JSON";
|
|
7
|
+
function normalizeWorkflowPath(configPath) {
|
|
8
|
+
const normalized = configPath.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
9
|
+
if (normalized === "" ||
|
|
10
|
+
normalized.startsWith("/") ||
|
|
11
|
+
normalized.startsWith("../") ||
|
|
12
|
+
normalized.includes("/../")) {
|
|
13
|
+
throw new ConfigError("Cannot generate GitHub Actions workflow: serviceAccountPath must stay inside .firevault.");
|
|
14
|
+
}
|
|
15
|
+
return normalized;
|
|
16
|
+
}
|
|
17
|
+
function workflowYaml(serviceAccountPath) {
|
|
18
|
+
const actionServiceAccountPath = `.firevault/${serviceAccountPath}`;
|
|
19
|
+
return `name: Firevault snapshot
|
|
20
|
+
|
|
21
|
+
on:
|
|
22
|
+
schedule:
|
|
23
|
+
- cron: "0 3 * * *"
|
|
24
|
+
workflow_dispatch:
|
|
25
|
+
|
|
26
|
+
permissions:
|
|
27
|
+
contents: write
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
snapshot:
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
|
|
33
|
+
steps:
|
|
34
|
+
- name: Check out recovery repository
|
|
35
|
+
uses: actions/checkout@v4
|
|
36
|
+
with:
|
|
37
|
+
path: .firevault
|
|
38
|
+
fetch-depth: 0
|
|
39
|
+
|
|
40
|
+
- name: Set up Node
|
|
41
|
+
uses: actions/setup-node@v4
|
|
42
|
+
with:
|
|
43
|
+
node-version: "22"
|
|
44
|
+
|
|
45
|
+
- name: Install Firevault
|
|
46
|
+
run: npm install -g firevault@next
|
|
47
|
+
|
|
48
|
+
- name: Write Firebase service account
|
|
49
|
+
env:
|
|
50
|
+
${secretName}: \${{ secrets.${secretName} }}
|
|
51
|
+
run: |
|
|
52
|
+
mkdir -p "$(dirname "${actionServiceAccountPath}")"
|
|
53
|
+
printf '%s' "$${secretName}" > "${actionServiceAccountPath}"
|
|
54
|
+
|
|
55
|
+
- name: Configure Git author
|
|
56
|
+
working-directory: .firevault
|
|
57
|
+
run: |
|
|
58
|
+
git config user.name "firevault"
|
|
59
|
+
git config user.email "firevault@users.noreply.github.com"
|
|
60
|
+
|
|
61
|
+
- name: Run snapshot
|
|
62
|
+
run: firevault snapshot
|
|
63
|
+
|
|
64
|
+
- name: Push backup commit
|
|
65
|
+
working-directory: .firevault
|
|
66
|
+
run: |
|
|
67
|
+
branch="$(git rev-parse --abbrev-ref HEAD)"
|
|
68
|
+
|
|
69
|
+
if git rev-parse --verify "origin/$branch" >/dev/null 2>&1; then
|
|
70
|
+
commits_to_push="$(git rev-list --count "origin/$branch..HEAD")"
|
|
71
|
+
|
|
72
|
+
if [ "$commits_to_push" = "0" ]; then
|
|
73
|
+
echo "No new backup commit to push."
|
|
74
|
+
exit 0
|
|
75
|
+
fi
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
git push origin "HEAD:$branch"
|
|
79
|
+
|
|
80
|
+
- name: Remove service account file
|
|
81
|
+
if: always()
|
|
82
|
+
run: rm -f "${actionServiceAccountPath}"
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
85
|
+
export function runSetupGithubAction(options) {
|
|
86
|
+
const config = loadConfig();
|
|
87
|
+
const serviceAccountPath = normalizeWorkflowPath(config.serviceAccountPath);
|
|
88
|
+
const workflowPath = path.join(config.workspaceRoot, workflowRelativePath);
|
|
89
|
+
if (existsSync(workflowPath) && !options.force) {
|
|
90
|
+
throw new ConfigError(`${path.join(".firevault", workflowRelativePath)} already exists. Rerun with --force to overwrite it.`);
|
|
91
|
+
}
|
|
92
|
+
mkdirSync(path.dirname(workflowPath), { recursive: true });
|
|
93
|
+
writeFileSync(workflowPath, workflowYaml(serviceAccountPath));
|
|
94
|
+
console.log("Created:");
|
|
95
|
+
console.log(path.join(".firevault", workflowRelativePath));
|
|
96
|
+
console.log("");
|
|
97
|
+
console.log("Next steps:");
|
|
98
|
+
console.log("1. Push the .firevault repo to GitHub");
|
|
99
|
+
console.log("2. Create GitHub secret:");
|
|
100
|
+
console.log(` ${secretName}`);
|
|
101
|
+
console.log("3. Add your Firebase service account JSON as the secret value");
|
|
102
|
+
console.log("4. Review and commit the workflow file yourself");
|
|
103
|
+
console.log("5. Run the workflow manually once before relying on the schedule");
|
|
104
|
+
}
|
|
105
|
+
export const setupGithubActionCommand = new Command("setup-github-action")
|
|
106
|
+
.description("Create a local GitHub Actions workflow for scheduled Firevault snapshots")
|
|
107
|
+
.option("--force", "Overwrite an existing Firevault snapshot workflow")
|
|
108
|
+
.action((options) => {
|
|
109
|
+
try {
|
|
110
|
+
runSetupGithubAction(options);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof ConfigError) {
|
|
114
|
+
console.error(error.message);
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { ConfigError, findWorkspaceRoot, loadConfig } from "../config/loadConfig.js";
|
|
5
|
+
import { getAheadBehind, getCurrentBranch, getLatestCommitDate, getRemoteUrl, hasChangesUnder, hasWorkingTreeChanges, isInsideGitRepository, } from "../git/git.js";
|
|
6
|
+
function relativeDisplayPath(targetPath) {
|
|
7
|
+
const relativePath = path.relative(process.cwd(), targetPath);
|
|
8
|
+
if (relativePath === "") {
|
|
9
|
+
return ".";
|
|
10
|
+
}
|
|
11
|
+
return relativePath.startsWith("..") ? targetPath : relativePath || ".";
|
|
12
|
+
}
|
|
13
|
+
function workflowStatus(workspaceRoot) {
|
|
14
|
+
const workflowPath = path.join(workspaceRoot, ".github", "workflows", "firevault-snapshot.yml");
|
|
15
|
+
if (!existsSync(workflowPath)) {
|
|
16
|
+
return "not configured";
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const workflow = readFileSync(workflowPath, "utf-8");
|
|
20
|
+
if (workflow.includes("schedule:") && workflow.includes("cron:")) {
|
|
21
|
+
return "configured";
|
|
22
|
+
}
|
|
23
|
+
return "present, schedule missing";
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return "present, unreadable";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function printMissingWorkspace() {
|
|
30
|
+
console.log("Firevault status");
|
|
31
|
+
console.log("");
|
|
32
|
+
console.log("Workspace:");
|
|
33
|
+
console.log(" Path: not found");
|
|
34
|
+
console.log(" Config: missing");
|
|
35
|
+
console.log("");
|
|
36
|
+
console.log("Next step:");
|
|
37
|
+
console.log(" Run `firevault init`");
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
}
|
|
40
|
+
function formatRemoteSync(sync) {
|
|
41
|
+
if (!sync) {
|
|
42
|
+
return "unknown";
|
|
43
|
+
}
|
|
44
|
+
return `ahead ${sync.ahead}, behind ${sync.behind}`;
|
|
45
|
+
}
|
|
46
|
+
export function runStatus() {
|
|
47
|
+
const workspaceRoot = findWorkspaceRoot();
|
|
48
|
+
if (!workspaceRoot) {
|
|
49
|
+
printMissingWorkspace();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
let config;
|
|
53
|
+
try {
|
|
54
|
+
config = loadConfig();
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error instanceof ConfigError) {
|
|
58
|
+
console.log("Firevault status");
|
|
59
|
+
console.log("");
|
|
60
|
+
console.log("Workspace:");
|
|
61
|
+
console.log(` Path: ${relativeDisplayPath(workspaceRoot)}`);
|
|
62
|
+
console.log(" Config: invalid");
|
|
63
|
+
console.log("");
|
|
64
|
+
console.log(`Error: ${error.message}`);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
const gitRepositoryExists = isInsideGitRepository(config.workspaceRoot);
|
|
71
|
+
const outputDirExists = existsSync(config.outputDirPath);
|
|
72
|
+
const workingTreeDirty = gitRepositoryExists
|
|
73
|
+
? hasWorkingTreeChanges(config.workspaceRoot)
|
|
74
|
+
: undefined;
|
|
75
|
+
const backupChanges = gitRepositoryExists
|
|
76
|
+
? hasChangesUnder(config.outputDir, config.workspaceRoot)
|
|
77
|
+
: undefined;
|
|
78
|
+
const latestSnapshot = gitRepositoryExists
|
|
79
|
+
? getLatestCommitDate(config.outputDir, config.workspaceRoot)
|
|
80
|
+
: undefined;
|
|
81
|
+
const branch = gitRepositoryExists
|
|
82
|
+
? getCurrentBranch(config.workspaceRoot)
|
|
83
|
+
: undefined;
|
|
84
|
+
const remoteOrigin = gitRepositoryExists
|
|
85
|
+
? getRemoteUrl("origin", config.workspaceRoot)
|
|
86
|
+
: undefined;
|
|
87
|
+
const remoteSync = gitRepositoryExists && remoteOrigin
|
|
88
|
+
? getAheadBehind(config.workspaceRoot)
|
|
89
|
+
: undefined;
|
|
90
|
+
console.log("Firevault status");
|
|
91
|
+
console.log("");
|
|
92
|
+
console.log("Workspace:");
|
|
93
|
+
console.log(` Path: ${relativeDisplayPath(config.workspaceRoot)}`);
|
|
94
|
+
console.log(" Config: OK");
|
|
95
|
+
console.log("");
|
|
96
|
+
console.log("Firestore:");
|
|
97
|
+
console.log(` Project: ${config.projectId}`);
|
|
98
|
+
console.log(` Collections configured: ${config.collections.length}`);
|
|
99
|
+
console.log("");
|
|
100
|
+
console.log("Backups:");
|
|
101
|
+
console.log(` Output directory: ${config.outputDir}`);
|
|
102
|
+
console.log(` Output exists: ${outputDirExists ? "yes" : "no"}`);
|
|
103
|
+
console.log(` Last snapshot: ${latestSnapshot ?? "none"}`);
|
|
104
|
+
console.log(` Uncommitted backup changes: ${backupChanges === undefined ? "unknown" : backupChanges ? "yes" : "none"}`);
|
|
105
|
+
console.log("");
|
|
106
|
+
console.log("Git:");
|
|
107
|
+
console.log(` Repository: ${gitRepositoryExists ? "OK" : "missing"}`);
|
|
108
|
+
if (gitRepositoryExists) {
|
|
109
|
+
console.log(` Branch: ${branch ?? "unknown"}`);
|
|
110
|
+
console.log(` Working tree: ${workingTreeDirty ? "dirty" : "clean"}`);
|
|
111
|
+
console.log(` Remote origin: ${remoteOrigin ? "configured" : "not configured"}`);
|
|
112
|
+
if (remoteOrigin) {
|
|
113
|
+
console.log(` Remote sync: ${formatRemoteSync(remoteSync)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
console.log("");
|
|
117
|
+
console.log("Automation:");
|
|
118
|
+
console.log(` GitHub Actions workflow: ${workflowStatus(config.workspaceRoot)}`);
|
|
119
|
+
}
|
|
120
|
+
export const statusCommand = new Command("status")
|
|
121
|
+
.description("Show local Firevault recovery health")
|
|
122
|
+
.action(() => {
|
|
123
|
+
runStatus();
|
|
124
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
export class ConfigError extends Error {
|
|
3
4
|
constructor(message) {
|
|
4
5
|
super(message);
|
|
@@ -11,7 +12,7 @@ function isRecord(value) {
|
|
|
11
12
|
function requireString(config, field) {
|
|
12
13
|
const value = config[field];
|
|
13
14
|
if (typeof value !== "string" || value.trim() === "") {
|
|
14
|
-
throw new ConfigError(`Invalid firevault
|
|
15
|
+
throw new ConfigError(`Invalid .firevault/config.json: "${field}" is required and must be a string.`);
|
|
15
16
|
}
|
|
16
17
|
return value;
|
|
17
18
|
}
|
|
@@ -20,15 +21,36 @@ function requireStringArray(config, field) {
|
|
|
20
21
|
if (!Array.isArray(value) ||
|
|
21
22
|
value.length === 0 ||
|
|
22
23
|
value.some((item) => typeof item !== "string" || item.trim() === "")) {
|
|
23
|
-
throw new ConfigError(`Invalid firevault
|
|
24
|
+
throw new ConfigError(`Invalid .firevault/config.json: "${field}" is required and must include at least one collection name.`);
|
|
24
25
|
}
|
|
25
26
|
return value;
|
|
26
27
|
}
|
|
28
|
+
export function findWorkspaceRoot(startDir = process.cwd()) {
|
|
29
|
+
let currentDir = path.resolve(startDir);
|
|
30
|
+
while (true) {
|
|
31
|
+
const candidate = path.join(currentDir, ".firevault", "config.json");
|
|
32
|
+
if (existsSync(candidate)) {
|
|
33
|
+
return path.join(currentDir, ".firevault");
|
|
34
|
+
}
|
|
35
|
+
const parent = path.dirname(currentDir);
|
|
36
|
+
if (parent === currentDir) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
currentDir = parent;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function normalizeConfigPath(value) {
|
|
43
|
+
return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
44
|
+
}
|
|
45
|
+
export function resolveWorkspacePath(workspaceRoot, configPath) {
|
|
46
|
+
return path.resolve(workspaceRoot, configPath);
|
|
47
|
+
}
|
|
27
48
|
export function loadConfig() {
|
|
28
|
-
const
|
|
29
|
-
if (!
|
|
30
|
-
throw new ConfigError("Missing firevault
|
|
49
|
+
const workspaceRoot = findWorkspaceRoot();
|
|
50
|
+
if (!workspaceRoot) {
|
|
51
|
+
throw new ConfigError("Missing .firevault/config.json. Run `firevault init` first.");
|
|
31
52
|
}
|
|
53
|
+
const configPath = path.join(workspaceRoot, "config.json");
|
|
32
54
|
let parsed;
|
|
33
55
|
try {
|
|
34
56
|
const raw = readFileSync(configPath, "utf-8");
|
|
@@ -36,18 +58,24 @@ export function loadConfig() {
|
|
|
36
58
|
}
|
|
37
59
|
catch (error) {
|
|
38
60
|
if (error instanceof SyntaxError) {
|
|
39
|
-
throw new ConfigError("Invalid firevault
|
|
61
|
+
throw new ConfigError("Invalid .firevault/config.json: file is not valid JSON.");
|
|
40
62
|
}
|
|
41
63
|
throw error;
|
|
42
64
|
}
|
|
43
65
|
if (!isRecord(parsed)) {
|
|
44
|
-
throw new ConfigError("Invalid firevault
|
|
66
|
+
throw new ConfigError("Invalid .firevault/config.json: expected a JSON object.");
|
|
45
67
|
}
|
|
46
68
|
const config = {
|
|
47
69
|
projectId: requireString(parsed, "projectId"),
|
|
48
|
-
serviceAccountPath: requireString(parsed, "serviceAccountPath"),
|
|
49
|
-
outputDir: requireString(parsed, "outputDir"),
|
|
70
|
+
serviceAccountPath: normalizeConfigPath(requireString(parsed, "serviceAccountPath")),
|
|
71
|
+
outputDir: normalizeConfigPath(requireString(parsed, "outputDir")),
|
|
50
72
|
collections: requireStringArray(parsed, "collections"),
|
|
73
|
+
workspaceRoot,
|
|
74
|
+
configPath,
|
|
75
|
+
serviceAccountPathAbsolute: "",
|
|
76
|
+
outputDirPath: "",
|
|
51
77
|
};
|
|
78
|
+
config.serviceAccountPathAbsolute = resolveWorkspacePath(workspaceRoot, config.serviceAccountPath);
|
|
79
|
+
config.outputDirPath = resolveWorkspacePath(workspaceRoot, config.outputDir);
|
|
52
80
|
return config;
|
|
53
81
|
}
|
|
@@ -15,10 +15,10 @@ export function getFirestore() {
|
|
|
15
15
|
initialized = true;
|
|
16
16
|
return admin.firestore();
|
|
17
17
|
}
|
|
18
|
-
if (!existsSync(config.
|
|
18
|
+
if (!existsSync(config.serviceAccountPathAbsolute)) {
|
|
19
19
|
throw new ConfigError(`Service account file not found: ${config.serviceAccountPath}`);
|
|
20
20
|
}
|
|
21
|
-
serviceAccount = JSON.parse(readFileSync(config.
|
|
21
|
+
serviceAccount = JSON.parse(readFileSync(config.serviceAccountPathAbsolute, "utf-8"));
|
|
22
22
|
admin.initializeApp({
|
|
23
23
|
credential: admin.credential.cert(serviceAccount),
|
|
24
24
|
projectId: config.projectId,
|