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 CHANGED
@@ -1,6 +1,24 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1-beta.1 - 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
11
+
12
+ Breaking prerelease change:
13
+
14
+ - Firevault now uses `.firevault/config.json` and a dedicated `.firevault` recovery workspace.
15
+ - Firevault backup history now lives in the `.firevault` Git repository instead of the parent app repo.
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/`.
18
+ - `firevault init` no longer creates root `firevault.config.json`.
19
+ - `firestore-backups/` is no longer ignored inside `.firevault/` by default.
20
+
21
+ ## 0.1.1-beta.1
4
22
 
5
23
  - Added guided `firevault init` setup with prompts for project ID, service account path, output directory, and collections.
6
24
  - Added init Git safety checks, `--force`, and `--yes`.
package/README.md CHANGED
@@ -25,10 +25,12 @@ Current scope:
25
25
  Current export shape:
26
26
 
27
27
  ```txt
28
- firestore-backups/
29
- users/
30
- abc123.json
31
- def456.json
28
+ .firevault/
29
+ config.json
30
+ firestore-backups/
31
+ users/
32
+ abc123.json
33
+ def456.json
32
34
  ```
33
35
 
34
36
  The immediate priority is trustworthy document-level recovery: clear previews, explicit confirmation, and no broad destructive restore flows.
@@ -36,10 +38,11 @@ The immediate priority is trustworthy document-level recovery: clear previews, e
36
38
  ## Quick Start
37
39
 
38
40
  ```bash
39
- npm install -g firevault
41
+ npm install -g firevault@next
42
+ cd my-app
40
43
  ```
41
44
 
42
- Create a Firebase service account key for your Firestore project, save it as `serviceAccountKey.json`, and keep it out of Git.
45
+ Firevault 0.2 uses `.firevault/config.json` and a dedicated `.firevault` recovery workspace. The app repo stays focused on application source code; `.firevault/` contains Firevault config, backup JSON, credentials, and its own Git history.
43
46
 
44
47
  Run guided setup:
45
48
 
@@ -47,7 +50,7 @@ Run guided setup:
47
50
  firevault init
48
51
  ```
49
52
 
50
- `firevault init` asks for your Firebase project ID, service account path, output directory, and collections. It also checks Git state before writing files and appends safety entries to `.gitignore`.
53
+ `firevault init` asks for your Firebase project ID, service account path, output directory, and collections. It creates `.firevault/`, writes `.firevault/config.json`, writes `.firevault/.gitignore`, can initialize Git inside `.firevault/`, and adds `.firevault/` to the parent app repo `.gitignore` when the parent is a Git repo.
51
54
 
52
55
  During setup, Firevault looks for likely Firebase project IDs in local files such as `.env.local`, `.env.development`, `firebase.json`, and common Firebase config files. Detection is best-effort and transparent: if Firevault finds candidates, it shows where they came from and lets you accept one or enter a value manually.
53
56
 
@@ -62,14 +65,14 @@ https://console.firebase.google.com/project/your-project-id/settings/serviceacco
62
65
 
63
66
  Download the JSON key and save it as:
64
67
 
65
- ./serviceAccountKey.json
68
+ .firevault/serviceAccountKey.json
66
69
  ```
67
70
 
68
71
  Firevault does not create service accounts, open a browser, run `gcloud`, or authenticate against Firebase during setup.
69
72
 
70
73
  If the selected service account file already exists, Firevault can optionally connect to Firestore and list top-level collections so you can choose which ones to back up. If the file is missing or Firebase access fails, init continues and you can enter collections manually.
71
74
 
72
- Generated `firevault.config.json`:
75
+ Generated `.firevault/config.json`:
73
76
 
74
77
  ```json
75
78
  {
@@ -86,6 +89,8 @@ Take a snapshot:
86
89
  firevault snapshot
87
90
  ```
88
91
 
92
+ Operational commands discover the nearest `.firevault/config.json`, so they work from the app root or from inside `.firevault/`.
93
+
89
94
  Example output:
90
95
 
91
96
  ```txt
@@ -179,6 +184,12 @@ firevault snapshot
179
184
 
180
185
  Firevault operates against an existing Firebase project using a service account.
181
186
 
187
+ Expected config path:
188
+
189
+ ```txt
190
+ .firevault/config.json
191
+ ```
192
+
182
193
  Expected config shape:
183
194
 
184
195
  ```json
@@ -192,19 +203,28 @@ Expected config shape:
192
203
 
193
204
  Notes:
194
205
 
195
- - `serviceAccountPath` points to a local Firebase service account JSON file.
196
- - `outputDir` is where Firestore documents are written.
206
+ - paths are relative to `.firevault/`,
207
+ - `serviceAccountPath` points to a local Firebase service account JSON file,
208
+ - `outputDir` is where Firestore documents are written inside `.firevault/`,
197
209
  - `collections` controls which top-level Firestore collections are exported.
198
210
  - Service account files must not be committed.
199
211
 
200
- Recommended `.gitignore` entries for local development:
212
+ Parent app repo `.gitignore`:
213
+
214
+ ```gitignore
215
+ .firevault/
216
+ ```
217
+
218
+ `.firevault/.gitignore`:
201
219
 
202
220
  ```gitignore
203
221
  serviceAccountKey.json
204
- firestore-backups/
222
+ firestore-debug.log
223
+ .env
224
+ .env.*
205
225
  ```
206
226
 
207
- `firevault init` adds these safety entries automatically. `firestore-backups/` is ignored by default so exported Firestore data is not committed accidentally with normal Git commands. Firevault can still commit the configured backup directory explicitly through `firevault commit` or `firevault snapshot`, and it stages only that directory.
227
+ `firevault init` adds these safety entries automatically. In the 0.2 workspace model, `firestore-backups/` is not ignored inside `.firevault/` because the `.firevault` Git repo exists to track backup history.
208
228
 
209
229
  ## Commands
210
230
 
@@ -213,6 +233,9 @@ firevault init
213
233
  firevault backup
214
234
  firevault commit
215
235
  firevault snapshot
236
+ firevault status
237
+ firevault doctor
238
+ firevault setup-github-action
216
239
  firevault changes
217
240
  firevault changes --last 24h
218
241
  firevault history users/abc123
@@ -262,7 +285,7 @@ JSON output is stable:
262
285
 
263
286
  `firevault backup` exports configured Firestore collections to deterministic local JSON files. It does not stage or commit anything.
264
287
 
265
- `firevault commit` expects to run inside a Git repository.
288
+ `firevault commit` commits inside the `.firevault` Git repository.
266
289
 
267
290
  Behavior:
268
291
 
@@ -270,6 +293,7 @@ Behavior:
270
293
  - exits successfully if no backup changes exist,
271
294
  - stages only the configured `outputDir`,
272
295
  - creates a local commit with message `backup: <ISO timestamp>`,
296
+ - never stages app source files from the parent repo,
273
297
  - never pushes.
274
298
 
275
299
  Keep `serviceAccountKey.json` ignored so credentials cannot be committed by this workflow or by manual Git usage.
@@ -282,6 +306,56 @@ Keep `serviceAccountKey.json` ignored so credentials cannot be committed by this
282
306
  - exits successfully when backup succeeds but no Git changes exist,
283
307
  - never pushes.
284
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
+
285
359
  `firevault changes` shows a file-level Git summary for the configured `outputDir` only:
286
360
 
287
361
  ```txt
@@ -4,7 +4,7 @@ import { exportCollection } from "../firestore/exportFirestore.js";
4
4
  export async function runBackup() {
5
5
  const config = loadConfig();
6
6
  for (const collection of config.collections) {
7
- await exportCollection(config.outputDir, collection);
7
+ await exportCollection(config.outputDirPath, collection);
8
8
  }
9
9
  console.log("Backup complete.");
10
10
  }
@@ -31,10 +31,10 @@ function normalizeLastWindow(last) {
31
31
  }
32
32
  export function runChanges(last) {
33
33
  const config = loadConfig();
34
- assertInsideGitRepository();
34
+ assertInsideGitRepository(config.workspaceRoot);
35
35
  const changes = last
36
- ? getHistoricalChanges(config.outputDir, normalizeLastWindow(last))
37
- : getWorkingTreeChanges(config.outputDir);
36
+ ? getHistoricalChanges(config.outputDir, normalizeLastWindow(last), config.workspaceRoot)
37
+ : getWorkingTreeChanges(config.outputDir, config.workspaceRoot);
38
38
  printChanges(changes);
39
39
  }
40
40
  export const changesCommand = new Command("changes")
@@ -3,14 +3,14 @@ import { ConfigError, loadConfig } from "../config/loadConfig.js";
3
3
  import { GitError, assertInsideGitRepository, commitPath, hasChangesUnder, stagePath, } from "../git/git.js";
4
4
  export function runCommit() {
5
5
  const config = loadConfig();
6
- assertInsideGitRepository();
7
- if (!hasChangesUnder(config.outputDir)) {
6
+ assertInsideGitRepository(config.workspaceRoot);
7
+ if (!hasChangesUnder(config.outputDir, config.workspaceRoot)) {
8
8
  console.log(`No changes found under ${config.outputDir}.`);
9
9
  return;
10
10
  }
11
- stagePath(config.outputDir);
11
+ stagePath(config.outputDir, config.workspaceRoot);
12
12
  const message = `backup: ${new Date().toISOString()}`;
13
- commitPath(message, config.outputDir);
13
+ commitPath(message, config.outputDir, config.workspaceRoot);
14
14
  console.log(`Created commit: ${message}`);
15
15
  }
16
16
  export const commitCommand = new Command("commit")
@@ -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
+ });
@@ -15,8 +15,8 @@ function printHistory(entries) {
15
15
  export function runHistory(inputPath) {
16
16
  const config = loadConfig();
17
17
  const normalizedPath = normalizeHistoryPath(inputPath, config.outputDir);
18
- assertInsideGitRepository();
19
- const entries = getHistory(normalizedPath.path, normalizedPath.isCollection);
18
+ assertInsideGitRepository(config.workspaceRoot);
19
+ const entries = getHistory(normalizedPath.path, normalizedPath.isCollection, config.workspaceRoot);
20
20
  if (entries.length === 0) {
21
21
  console.log(`No history found for ${normalizedPath.path}.`);
22
22
  return;