firevault 0.2.0-beta.0 → 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 CHANGED
@@ -1,12 +1,20 @@
1
1
  # Changelog
2
2
 
3
- ## 0.2.0-beta.0 - Unreleased
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,6 +6,9 @@ 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";
@@ -20,6 +23,9 @@ program.addCommand(commitCommand);
20
23
  program.addCommand(snapshotCommand);
21
24
  program.addCommand(changesCommand);
22
25
  program.addCommand(historyCommand);
26
+ program.addCommand(statusCommand);
27
+ program.addCommand(doctorCommand);
28
+ program.addCommand(setupGithubActionCommand);
23
29
  program.addCommand(restorePreviewCommand);
24
30
  program.addCommand(restoreLocalCommand);
25
31
  program.addCommand(restoreFirestoreCommand);
@@ -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/`.