firevault 0.2.0-beta.0 → 0.2.0-beta.2
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 +9 -1
- package/README.md +53 -0
- package/dist/commands/doctor.js +225 -0
- package/dist/commands/setupGithubAction.js +120 -0
- package/dist/commands/status.js +124 -0
- package/dist/git/git.js +65 -0
- package/dist/index.js +8 -1
- package/docs/architecture.md +8 -0
- package/docs/doctor-design.md +418 -0
- package/docs/github-actions-design.md +317 -0
- package/docs/roadmap.md +6 -0
- package/docs/status-design.md +218 -0
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.2.0-beta.
|
|
3
|
+
## 0.2.0-beta.1 - Unreleased
|
|
4
|
+
|
|
5
|
+
- Added `firevault status` for compact local recovery health checks.
|
|
6
|
+
- Added `firevault doctor` for actionable local setup validation.
|
|
7
|
+
- Added `firevault setup-github-action` to generate a scheduled GitHub Actions workflow for offsite snapshots.
|
|
8
|
+
- Added local workflow detection for generated GitHub Actions snapshot automation.
|
|
9
|
+
|
|
10
|
+
## 0.2.0-beta.0
|
|
4
11
|
|
|
5
12
|
Breaking prerelease change:
|
|
6
13
|
|
|
7
14
|
- Firevault now uses `.firevault/config.json` and a dedicated `.firevault` recovery workspace.
|
|
8
15
|
- Firevault backup history now lives in the `.firevault` Git repository instead of the parent app repo.
|
|
9
16
|
- Config-relative paths now resolve from `.firevault/`.
|
|
17
|
+
- Operational commands discover the nearest `.firevault/config.json` from the app root or from inside `.firevault/`.
|
|
10
18
|
- `firevault init` no longer creates root `firevault.config.json`.
|
|
11
19
|
- `firestore-backups/` is no longer ignored inside `.firevault/` by default.
|
|
12
20
|
|
package/README.md
CHANGED
|
@@ -233,6 +233,9 @@ firevault init
|
|
|
233
233
|
firevault backup
|
|
234
234
|
firevault commit
|
|
235
235
|
firevault snapshot
|
|
236
|
+
firevault status
|
|
237
|
+
firevault doctor
|
|
238
|
+
firevault setup-github-action
|
|
236
239
|
firevault changes
|
|
237
240
|
firevault changes --last 24h
|
|
238
241
|
firevault history users/abc123
|
|
@@ -303,6 +306,56 @@ Keep `serviceAccountKey.json` ignored so credentials cannot be committed by this
|
|
|
303
306
|
- exits successfully when backup succeeds but no Git changes exist,
|
|
304
307
|
- never pushes.
|
|
305
308
|
|
|
309
|
+
`firevault status` shows a compact local recovery health overview. It does not contact Firebase, call GitHub APIs, fetch from remotes, write files, stage, commit, or push.
|
|
310
|
+
|
|
311
|
+
Example output:
|
|
312
|
+
|
|
313
|
+
```txt
|
|
314
|
+
Firevault status
|
|
315
|
+
|
|
316
|
+
Workspace:
|
|
317
|
+
Path: .firevault
|
|
318
|
+
Config: OK
|
|
319
|
+
|
|
320
|
+
Firestore:
|
|
321
|
+
Project: my-project
|
|
322
|
+
Collections configured: 4
|
|
323
|
+
|
|
324
|
+
Backups:
|
|
325
|
+
Output directory: firestore-backups
|
|
326
|
+
Output exists: yes
|
|
327
|
+
Last snapshot: 2026-05-17T14:22:10Z
|
|
328
|
+
Uncommitted backup changes: none
|
|
329
|
+
|
|
330
|
+
Git:
|
|
331
|
+
Repository: OK
|
|
332
|
+
Branch: main
|
|
333
|
+
Working tree: clean
|
|
334
|
+
Remote origin: configured
|
|
335
|
+
Remote sync: unknown
|
|
336
|
+
|
|
337
|
+
Automation:
|
|
338
|
+
GitHub Actions workflow: not configured
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
`firevault doctor` validates the local Firevault setup and prints actionable fixes. It checks workspace discovery, config validity, service account file presence, backup output state, `.firevault` Git setup, remote origin, GitHub Actions workflow contents, `.gitignore` safety, tracked secret-looking files, backup directory trackability, and working tree state.
|
|
342
|
+
|
|
343
|
+
Doctor is local-only. It does not contact Firebase, call GitHub APIs, write files, stage, commit, push, or print secrets.
|
|
344
|
+
|
|
345
|
+
Exit codes:
|
|
346
|
+
|
|
347
|
+
- `0`: all checks OK,
|
|
348
|
+
- `1`: warnings only,
|
|
349
|
+
- `2`: one or more failures.
|
|
350
|
+
|
|
351
|
+
`firevault setup-github-action` creates a local scheduled workflow at `.firevault/.github/workflows/firevault-snapshot.yml`.
|
|
352
|
+
|
|
353
|
+
The workflow is intended for a private GitHub repository containing the `.firevault` recovery workspace. It runs daily by default, supports manual dispatch, installs `firevault@next`, writes the Firebase service account JSON from the GitHub secret `FIREVAULT_SERVICE_ACCOUNT_JSON`, runs `firevault snapshot`, and pushes only when a backup commit was created.
|
|
354
|
+
|
|
355
|
+
This command only writes the workflow file. It does not create GitHub repositories, call GitHub APIs, create secrets, push, stage, commit, store credentials, or install GitHub CLI dependencies.
|
|
356
|
+
|
|
357
|
+
After generation, push the `.firevault` repo to GitHub, create the `FIREVAULT_SERVICE_ACCOUNT_JSON` repository secret with the full service account JSON, review the workflow, and commit it yourself.
|
|
358
|
+
|
|
306
359
|
`firevault changes` shows a file-level Git summary for the configured `outputDir` only:
|
|
307
360
|
|
|
308
361
|
```txt
|
|
@@ -0,0 +1,225 @@
|
|
|
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 { getRemoteUrl, getTrackedFiles, hasChangesUnder, hasWorkingTreeChanges, isInsideGitRepository, isPathIgnored, } from "../git/git.js";
|
|
6
|
+
function findNearestWorkspaceDir(startDir = process.cwd()) {
|
|
7
|
+
let currentDir = path.resolve(startDir);
|
|
8
|
+
while (true) {
|
|
9
|
+
const candidate = path.join(currentDir, ".firevault");
|
|
10
|
+
if (existsSync(candidate)) {
|
|
11
|
+
return candidate;
|
|
12
|
+
}
|
|
13
|
+
const parent = path.dirname(currentDir);
|
|
14
|
+
if (parent === currentDir) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
currentDir = parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function addCheck(checks, severity, label, fix) {
|
|
21
|
+
checks.push({ severity, label, fix });
|
|
22
|
+
}
|
|
23
|
+
function displayPathFromApp(workspaceRoot, targetPath) {
|
|
24
|
+
return path.relative(path.dirname(workspaceRoot), targetPath).replaceAll("\\", "/");
|
|
25
|
+
}
|
|
26
|
+
function isInside(parent, child) {
|
|
27
|
+
const relativePath = path.relative(parent, child);
|
|
28
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
29
|
+
}
|
|
30
|
+
function gitignoreContains(workspaceRoot, value) {
|
|
31
|
+
const gitignorePath = path.join(workspaceRoot, ".gitignore");
|
|
32
|
+
if (!existsSync(gitignorePath)) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
const normalizedValue = value.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
36
|
+
const basename = path.posix.basename(normalizedValue);
|
|
37
|
+
const lines = readFileSync(gitignorePath, "utf-8")
|
|
38
|
+
.split("\n")
|
|
39
|
+
.map((line) => line.trim().replace(/\/+$/, ""))
|
|
40
|
+
.filter((line) => line !== "" && !line.startsWith("#"));
|
|
41
|
+
return lines.includes(normalizedValue) || lines.includes(`./${normalizedValue}`) || lines.includes(basename);
|
|
42
|
+
}
|
|
43
|
+
function workflowChecks(checks, workspaceRoot) {
|
|
44
|
+
const workflowPath = path.join(workspaceRoot, ".github", "workflows", "firevault-snapshot.yml");
|
|
45
|
+
if (!existsSync(workflowPath)) {
|
|
46
|
+
addCheck(checks, "WARN", "GitHub Actions workflow missing", "Run `firevault setup-github-action`");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const workflow = readFileSync(workflowPath, "utf-8");
|
|
50
|
+
const missing = [];
|
|
51
|
+
if (!workflow.includes("schedule:") || !workflow.includes("cron:")) {
|
|
52
|
+
missing.push("schedule trigger");
|
|
53
|
+
}
|
|
54
|
+
if (!workflow.includes("workflow_dispatch:")) {
|
|
55
|
+
missing.push("workflow_dispatch");
|
|
56
|
+
}
|
|
57
|
+
if (!workflow.includes("FIREVAULT_SERVICE_ACCOUNT_JSON")) {
|
|
58
|
+
missing.push("FIREVAULT_SERVICE_ACCOUNT_JSON");
|
|
59
|
+
}
|
|
60
|
+
if (!workflow.includes("firevault snapshot")) {
|
|
61
|
+
missing.push("firevault snapshot");
|
|
62
|
+
}
|
|
63
|
+
if (missing.length === 0) {
|
|
64
|
+
addCheck(checks, "OK", "GitHub Actions workflow configured");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
addCheck(checks, "WARN", `GitHub Actions workflow incomplete: missing ${missing.join(", ")}`, "Review .firevault/.github/workflows/firevault-snapshot.yml or rerun `firevault setup-github-action --force`");
|
|
68
|
+
}
|
|
69
|
+
function isObviousSecretPath(filePath, serviceAccountPath) {
|
|
70
|
+
const normalized = filePath.replaceAll("\\", "/");
|
|
71
|
+
const basename = path.posix.basename(normalized);
|
|
72
|
+
return (normalized === serviceAccountPath ||
|
|
73
|
+
basename === "serviceAccountKey.json" ||
|
|
74
|
+
basename === "service-account.json" ||
|
|
75
|
+
basename === "firebase-service-account.json" ||
|
|
76
|
+
normalized === "credentials/firebase.json" ||
|
|
77
|
+
basename === ".env" ||
|
|
78
|
+
basename.startsWith(".env.") ||
|
|
79
|
+
basename.endsWith(".pem") ||
|
|
80
|
+
basename.endsWith(".key"));
|
|
81
|
+
}
|
|
82
|
+
function printChecks(checks) {
|
|
83
|
+
console.log("Firevault doctor");
|
|
84
|
+
console.log("");
|
|
85
|
+
for (const check of checks) {
|
|
86
|
+
console.log(`${check.severity.padEnd(5)} ${check.label}`);
|
|
87
|
+
}
|
|
88
|
+
const fixes = checks
|
|
89
|
+
.map((check) => check.fix)
|
|
90
|
+
.filter((fix) => Boolean(fix));
|
|
91
|
+
const uniqueFixes = [...new Set(fixes)];
|
|
92
|
+
if (uniqueFixes.length > 0) {
|
|
93
|
+
console.log("");
|
|
94
|
+
console.log("Next fixes:");
|
|
95
|
+
uniqueFixes.forEach((fix, index) => {
|
|
96
|
+
const [firstLine, ...rest] = fix.split("\n");
|
|
97
|
+
console.log(`${index + 1}. ${firstLine}`);
|
|
98
|
+
for (const line of rest) {
|
|
99
|
+
console.log(` ${line}`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function runDoctor() {
|
|
105
|
+
const checks = [];
|
|
106
|
+
const workspaceRoot = findWorkspaceRoot() ?? findNearestWorkspaceDir();
|
|
107
|
+
if (!workspaceRoot) {
|
|
108
|
+
addCheck(checks, "FAIL", "Workspace not found", "Run `firevault init`");
|
|
109
|
+
addCheck(checks, "FAIL", "Config missing", "Run `firevault init`");
|
|
110
|
+
printChecks(checks);
|
|
111
|
+
process.exitCode = 2;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
addCheck(checks, "OK", "Workspace found");
|
|
115
|
+
const configPath = path.join(workspaceRoot, "config.json");
|
|
116
|
+
if (!existsSync(configPath)) {
|
|
117
|
+
addCheck(checks, "FAIL", "Config missing", "Run `firevault init` or create .firevault/config.json");
|
|
118
|
+
printChecks(checks);
|
|
119
|
+
process.exitCode = 2;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
let config;
|
|
123
|
+
try {
|
|
124
|
+
config = loadConfig();
|
|
125
|
+
addCheck(checks, "OK", "Config valid");
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
const message = error instanceof ConfigError ? error.message : "Config invalid";
|
|
129
|
+
addCheck(checks, "FAIL", message, "Edit .firevault/config.json or rerun `firevault init --force`");
|
|
130
|
+
printChecks(checks);
|
|
131
|
+
process.exitCode = 2;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const serviceAccountDisplayPath = displayPathFromApp(config.workspaceRoot, config.serviceAccountPathAbsolute);
|
|
135
|
+
if (!isInside(config.workspaceRoot, config.serviceAccountPathAbsolute)) {
|
|
136
|
+
addCheck(checks, "FAIL", "Service account path is outside .firevault", "Set serviceAccountPath to a path inside .firevault, such as ./serviceAccountKey.json");
|
|
137
|
+
}
|
|
138
|
+
else if (existsSync(config.serviceAccountPathAbsolute)) {
|
|
139
|
+
addCheck(checks, "OK", "Service account file present");
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
addCheck(checks, "FAIL", "Service account file missing", `Save your Firebase service account JSON to:\n${serviceAccountDisplayPath}`);
|
|
143
|
+
}
|
|
144
|
+
if (!isInside(config.workspaceRoot, config.outputDirPath)) {
|
|
145
|
+
addCheck(checks, "FAIL", "Backup output path is outside .firevault", "Set outputDir to a path inside .firevault, such as firestore-backups");
|
|
146
|
+
}
|
|
147
|
+
else if (existsSync(config.outputDirPath)) {
|
|
148
|
+
addCheck(checks, "OK", "Backup output directory exists");
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
addCheck(checks, "WARN", "Backup output directory has not been created yet", "Run `firevault snapshot`");
|
|
152
|
+
}
|
|
153
|
+
const workspaceIsGitRepo = isInsideGitRepository(config.workspaceRoot);
|
|
154
|
+
if (workspaceIsGitRepo) {
|
|
155
|
+
addCheck(checks, "OK", ".firevault Git repository found");
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
addCheck(checks, "FAIL", ".firevault is not a Git repository", "git -C .firevault init");
|
|
159
|
+
}
|
|
160
|
+
if (workspaceIsGitRepo && getRemoteUrl("origin", config.workspaceRoot)) {
|
|
161
|
+
addCheck(checks, "OK", "Git remote origin configured");
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
addCheck(checks, "WARN", "No Git remote origin configured", "git -C .firevault remote add origin <private-repo-url>");
|
|
165
|
+
}
|
|
166
|
+
workflowChecks(checks, config.workspaceRoot);
|
|
167
|
+
const serviceAccountIgnored = workspaceIsGitRepo
|
|
168
|
+
? isPathIgnored(config.serviceAccountPath, config.workspaceRoot)
|
|
169
|
+
: undefined;
|
|
170
|
+
if (serviceAccountIgnored === true || gitignoreContains(config.workspaceRoot, config.serviceAccountPath)) {
|
|
171
|
+
addCheck(checks, "OK", "Service account file ignored");
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
addCheck(checks, "FAIL", "Service account file is not ignored", `Add ${config.serviceAccountPath} to .firevault/.gitignore`);
|
|
175
|
+
}
|
|
176
|
+
const appRoot = path.dirname(config.workspaceRoot);
|
|
177
|
+
if (!isInsideGitRepository(appRoot)) {
|
|
178
|
+
addCheck(checks, "WARN", "Parent app directory is not a Git repository");
|
|
179
|
+
}
|
|
180
|
+
else if (isPathIgnored(".firevault", appRoot)) {
|
|
181
|
+
addCheck(checks, "OK", "Parent app repo ignores .firevault/");
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
addCheck(checks, "FAIL", "Parent app repo does not ignore .firevault/", "Add .firevault/ to .gitignore");
|
|
185
|
+
}
|
|
186
|
+
if (workspaceIsGitRepo) {
|
|
187
|
+
const trackedFiles = getTrackedFiles(config.workspaceRoot) ?? [];
|
|
188
|
+
const trackedSecretFiles = trackedFiles.filter((filePath) => isObviousSecretPath(filePath, config.serviceAccountPath));
|
|
189
|
+
if (trackedSecretFiles.length === 0) {
|
|
190
|
+
addCheck(checks, "OK", "No obvious secret files tracked");
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
addCheck(checks, "FAIL", `Possible secret files tracked: ${trackedSecretFiles.join(", ")}`, "Remove tracked secret files from Git history and rotate exposed credentials.");
|
|
194
|
+
}
|
|
195
|
+
if (isPathIgnored(config.outputDir, config.workspaceRoot) ||
|
|
196
|
+
gitignoreContains(config.workspaceRoot, config.outputDir)) {
|
|
197
|
+
addCheck(checks, "FAIL", "Backup directory is ignored", `Remove ${config.outputDir}/ from .firevault/.gitignore`);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
addCheck(checks, "OK", "Backup directory is trackable");
|
|
201
|
+
}
|
|
202
|
+
if (hasWorkingTreeChanges(config.workspaceRoot)) {
|
|
203
|
+
addCheck(checks, "WARN", "Working tree has uncommitted changes", "Review changes, then run `firevault commit` when appropriate");
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
addCheck(checks, "OK", "Working tree clean");
|
|
207
|
+
}
|
|
208
|
+
if (hasChangesUnder(config.outputDir, config.workspaceRoot)) {
|
|
209
|
+
addCheck(checks, "WARN", "Backup output has uncommitted changes", "Run `firevault commit` after reviewing changes");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
printChecks(checks);
|
|
213
|
+
if (checks.some((check) => check.severity === "FAIL")) {
|
|
214
|
+
process.exitCode = 2;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (checks.some((check) => check.severity === "WARN")) {
|
|
218
|
+
process.exitCode = 1;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
export const doctorCommand = new Command("doctor")
|
|
222
|
+
.description("Validate local Firevault recovery setup")
|
|
223
|
+
.action(() => {
|
|
224
|
+
runDoctor();
|
|
225
|
+
});
|
|
@@ -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
|
+
});
|
package/dist/git/git.js
CHANGED
|
@@ -24,6 +24,14 @@ function runGit(args, cwd = process.cwd()) {
|
|
|
24
24
|
throw new GitError("Git command failed.");
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
+
function tryRunGit(args, cwd = process.cwd()) {
|
|
28
|
+
try {
|
|
29
|
+
return runGit(args, cwd);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
27
35
|
export function isInsideGitRepository(cwd = process.cwd()) {
|
|
28
36
|
try {
|
|
29
37
|
return runGit(["rev-parse", "--is-inside-work-tree"], cwd).trim() === "true";
|
|
@@ -46,6 +54,63 @@ export function hasWorkingTreeChanges(cwd = process.cwd()) {
|
|
|
46
54
|
export function hasChangesUnder(path, cwd = process.cwd()) {
|
|
47
55
|
return runGit(["status", "--porcelain", "--ignored", "--", path], cwd).trim() !== "";
|
|
48
56
|
}
|
|
57
|
+
export function getCurrentBranch(cwd = process.cwd()) {
|
|
58
|
+
const branch = tryRunGit(["branch", "--show-current"], cwd)?.trim();
|
|
59
|
+
if (branch) {
|
|
60
|
+
return branch;
|
|
61
|
+
}
|
|
62
|
+
const shortSha = tryRunGit(["rev-parse", "--short", "HEAD"], cwd)?.trim();
|
|
63
|
+
return shortSha ? `detached at ${shortSha}` : undefined;
|
|
64
|
+
}
|
|
65
|
+
export function getRemoteUrl(name, cwd = process.cwd()) {
|
|
66
|
+
return tryRunGit(["config", "--get", `remote.${name}.url`], cwd)?.trim() || undefined;
|
|
67
|
+
}
|
|
68
|
+
export function isPathIgnored(path, cwd = process.cwd()) {
|
|
69
|
+
const output = tryRunGit(["check-ignore", "--quiet", "--", path], cwd);
|
|
70
|
+
if (output !== undefined) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
runGit(["check-ignore", "--quiet", "--", path], cwd);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error instanceof GitError) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function getTrackedFiles(cwd = process.cwd()) {
|
|
85
|
+
const output = tryRunGit(["ls-files"], cwd);
|
|
86
|
+
if (output === undefined) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
return output
|
|
90
|
+
.split("\n")
|
|
91
|
+
.map((line) => line.trim())
|
|
92
|
+
.filter((line) => line !== "");
|
|
93
|
+
}
|
|
94
|
+
export function getLatestCommitDate(path, cwd = process.cwd()) {
|
|
95
|
+
return tryRunGit(["log", "-1", "--format=%cI", "--", path], cwd)?.trim() || undefined;
|
|
96
|
+
}
|
|
97
|
+
export function getAheadBehind(cwd = process.cwd()) {
|
|
98
|
+
const upstream = tryRunGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], cwd)
|
|
99
|
+
?.trim();
|
|
100
|
+
if (!upstream) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
const output = tryRunGit(["rev-list", "--left-right", "--count", "HEAD...@{u}"], cwd)
|
|
104
|
+
?.trim();
|
|
105
|
+
if (!output) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
const [ahead, behind] = output.split(/\s+/).map((value) => Number(value));
|
|
109
|
+
if (!Number.isFinite(ahead) || !Number.isFinite(behind)) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
return { ahead, behind };
|
|
113
|
+
}
|
|
49
114
|
function emptyFileChanges() {
|
|
50
115
|
return {
|
|
51
116
|
added: [],
|
package/dist/index.js
CHANGED
|
@@ -6,20 +6,27 @@ import { commitCommand } from "./commands/commit.js";
|
|
|
6
6
|
import { snapshotCommand } from "./commands/snapshot.js";
|
|
7
7
|
import { changesCommand } from "./commands/changes.js";
|
|
8
8
|
import { historyCommand } from "./commands/history.js";
|
|
9
|
+
import { statusCommand } from "./commands/status.js";
|
|
10
|
+
import { doctorCommand } from "./commands/doctor.js";
|
|
11
|
+
import { setupGithubActionCommand } from "./commands/setupGithubAction.js";
|
|
9
12
|
import { restorePreviewCommand } from "./commands/restorePreview.js";
|
|
10
13
|
import { restoreLocalCommand } from "./commands/restoreLocal.js";
|
|
11
14
|
import { restoreFirestoreCommand } from "./commands/restoreFirestore.js";
|
|
15
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
12
16
|
const program = new Command();
|
|
13
17
|
program
|
|
14
18
|
.name("firevault")
|
|
15
19
|
.description("Undo button for Firestore.")
|
|
16
|
-
.version(
|
|
20
|
+
.version(packageJson.version);
|
|
17
21
|
program.addCommand(initCommand);
|
|
18
22
|
program.addCommand(backupCommand);
|
|
19
23
|
program.addCommand(commitCommand);
|
|
20
24
|
program.addCommand(snapshotCommand);
|
|
21
25
|
program.addCommand(changesCommand);
|
|
22
26
|
program.addCommand(historyCommand);
|
|
27
|
+
program.addCommand(statusCommand);
|
|
28
|
+
program.addCommand(doctorCommand);
|
|
29
|
+
program.addCommand(setupGithubActionCommand);
|
|
23
30
|
program.addCommand(restorePreviewCommand);
|
|
24
31
|
program.addCommand(restoreLocalCommand);
|
|
25
32
|
program.addCommand(restoreFirestoreCommand);
|
package/docs/architecture.md
CHANGED
|
@@ -134,6 +134,14 @@ Operational commands work from the app root or from inside `.firevault/` by disc
|
|
|
134
134
|
|
|
135
135
|
`firestore-backups/` is not ignored inside `.firevault/` by default. The `.firevault` repository exists to commit backup data.
|
|
136
136
|
|
|
137
|
+
## GitHub Actions Automation
|
|
138
|
+
|
|
139
|
+
`firevault setup-github-action` is a local workflow-file generator for scheduled offsite snapshots. It writes `.firevault/.github/workflows/firevault-snapshot.yml` and stops there.
|
|
140
|
+
|
|
141
|
+
The command does not create GitHub repositories, call GitHub APIs, create secrets, push, stage, commit, store credentials, or depend on the GitHub CLI. Users push the `.firevault` repository and create the `FIREVAULT_SERVICE_ACCOUNT_JSON` secret themselves.
|
|
142
|
+
|
|
143
|
+
The generated workflow checks out the recovery repository into a `.firevault` directory, installs `firevault@next`, writes the service account JSON from the GitHub secret to `.firevault/serviceAccountKey.json`, runs `firevault snapshot` from the parent workspace, and pushes only if a backup commit was created.
|
|
144
|
+
|
|
137
145
|
## Emulator Tests
|
|
138
146
|
|
|
139
147
|
Firestore emulator integration tests live under `test/integration/`.
|