backdot 1.8.1 → 1.8.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/dist/cli.js +17 -4
- package/dist/commands/backup.js +5 -0
- package/dist/commands/init.js +2 -0
- package/dist/commands/restore.d.ts +3 -2
- package/dist/commands/restore.js +46 -24
- package/dist/commands/status.js +16 -4
- package/dist/config.d.ts +2 -2
- package/dist/config.js +2 -1
- package/dist/crypto/password.d.ts +3 -1
- package/dist/crypto/password.js +3 -4
- package/dist/launchd.js +5 -6
- package/dist/log.js +2 -5
- package/dist/paths.d.ts +6 -0
- package/dist/paths.js +8 -1
- package/dist/postRestoreHook.d.ts +7 -0
- package/dist/postRestoreHook.js +35 -0
- package/dist/repoVisibility.js +6 -1
- package/dist/staging.d.ts +15 -1
- package/dist/staging.js +50 -13
- package/package.json +15 -15
- package/README.md +0 -82
- package/dist/crypto.d.ts +0 -13
- package/dist/crypto.js +0 -129
- package/dist/encryption.d.ts +0 -2
- package/dist/encryption.js +0 -39
- package/dist/errorMessage.d.ts +0 -1
- package/dist/errorMessage.js +0 -3
- package/dist/password.d.ts +0 -11
- package/dist/password.js +0 -74
- package/dist/plist.d.ts +0 -3
- package/dist/plist.js +0 -112
- package/dist/resolve.d.ts +0 -6
- package/dist/resolve.js +0 -44
package/dist/cli.js
CHANGED
|
@@ -30,10 +30,17 @@ cli.command("backup", "Run a backup now").action(async () => {
|
|
|
30
30
|
});
|
|
31
31
|
cli
|
|
32
32
|
.command("restore [url]", "Restore files")
|
|
33
|
+
.option("--machine <name>", "Restore a specific machine")
|
|
33
34
|
.option("--commit <sha>", "Restore from a specific backup commit")
|
|
34
|
-
.option("-
|
|
35
|
+
.option("--no-overwrite", "Restore only new files; never overwrite existing ones (non-interactive)")
|
|
35
36
|
.action(async (url, options) => {
|
|
36
|
-
await restore({
|
|
37
|
+
await restore({
|
|
38
|
+
repoUrl: url,
|
|
39
|
+
commit: options.commit,
|
|
40
|
+
// cac defaults `overwrite` to true and `--no-overwrite` flips it to false.
|
|
41
|
+
skipExisting: options.overwrite === false,
|
|
42
|
+
machine: options.machine,
|
|
43
|
+
});
|
|
37
44
|
});
|
|
38
45
|
cli
|
|
39
46
|
.command("history [url]", "List and restore a previous backup")
|
|
@@ -51,8 +58,14 @@ cli.command("", "").action(() => {
|
|
|
51
58
|
console.log(` No config found. Run ${chalk.bold("backdot init")} to get started.\n`);
|
|
52
59
|
}
|
|
53
60
|
});
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
cli.help((sections) => sections.filter((section) => {
|
|
62
|
+
if (!section.body) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
const contentLines = section.body.split("\n").filter((line) => line.trim() !== "");
|
|
66
|
+
const listsOnlyHelpAndVersion = contentLines.every((line) => /--(help|version)/.test(line));
|
|
67
|
+
return !listsOnlyHelpAndVersion;
|
|
68
|
+
}));
|
|
56
69
|
cli.version(getVersion());
|
|
57
70
|
async function main() {
|
|
58
71
|
try {
|
package/dist/commands/backup.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import ora from "ora";
|
|
4
4
|
import { loadConfig, CONFIG_PATH } from "../config.js";
|
|
5
|
+
import { POST_RESTORE_HOOK_PATH } from "../paths.js";
|
|
5
6
|
import { resolveFiles } from "../resolveFiles.js";
|
|
6
7
|
import { cleanStaging, copyToStaging, writeRepoReadme, machineDir } from "../staging.js";
|
|
7
8
|
import { gitPull, gitCommitAndPush } from "../git.js";
|
|
@@ -61,6 +62,10 @@ export async function backup() {
|
|
|
61
62
|
return;
|
|
62
63
|
}
|
|
63
64
|
const files = [...userFiles, CONFIG_PATH];
|
|
65
|
+
// Automatically backup the "post-restore" hook if it exists
|
|
66
|
+
if (fs.existsSync(POST_RESTORE_HOOK_PATH)) {
|
|
67
|
+
files.push(POST_RESTORE_HOOK_PATH);
|
|
68
|
+
}
|
|
64
69
|
spinner.text = "Syncing with remote";
|
|
65
70
|
await gitPull(config.repository);
|
|
66
71
|
if (derivedKey) {
|
package/dist/commands/init.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import chalk from "chalk";
|
|
5
6
|
import { CONFIG_PATH } from "../config.js";
|
|
@@ -42,6 +43,7 @@ export function init() {
|
|
|
42
43
|
console.log(` ${chalk.yellow(`${CONFIG_PATH} already exists — skipping creation.`)}`);
|
|
43
44
|
}
|
|
44
45
|
else {
|
|
46
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
45
47
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
|
|
46
48
|
console.log(` Created ${CONFIG_PATH} with defaults.`);
|
|
47
49
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export declare function restore({ repoUrl, commit,
|
|
1
|
+
export declare function restore({ repoUrl, commit, skipExisting, machine: machineOverride, }?: {
|
|
2
2
|
repoUrl?: string;
|
|
3
3
|
commit?: string;
|
|
4
|
-
|
|
4
|
+
skipExisting?: boolean;
|
|
5
|
+
machine?: string;
|
|
5
6
|
}): Promise<void>;
|
package/dist/commands/restore.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import os from "node:os";
|
|
4
3
|
import ora from "ora";
|
|
5
4
|
import { checkbox, select, Separator } from "@inquirer/prompts";
|
|
6
5
|
import { loadConfig } from "../config.js";
|
|
7
6
|
import { gitPull } from "../git.js";
|
|
8
|
-
import { STAGING_DIR, machineDir } from "../staging.js";
|
|
7
|
+
import { STAGING_DIR, machineDir, getRestoreTarget, HOME_NAMESPACE, ROOT_NAMESPACE, } from "../staging.js";
|
|
8
|
+
import { POST_RESTORE_HOOK_PATH } from "../paths.js";
|
|
9
|
+
import { runPostRestoreHook } from "../postRestoreHook.js";
|
|
9
10
|
import { logger } from "../log.js";
|
|
10
11
|
import { pluralize } from "../utils.js";
|
|
11
12
|
import { decrypt, deriveKey } from "../crypto/encryption.js";
|
|
12
13
|
import { resolvePassword, offerToSaveKeyFile, ENC_SUFFIX } from "../crypto/password.js";
|
|
13
|
-
const HOME = os.homedir();
|
|
14
14
|
function listFilesRecursively(dir) {
|
|
15
15
|
return fs
|
|
16
16
|
.readdirSync(dir, { withFileTypes: true })
|
|
@@ -29,10 +29,15 @@ function listMachines() {
|
|
|
29
29
|
.filter((entry) => entry.isDirectory() && entry.name !== ".git")
|
|
30
30
|
.map((entry) => entry.name);
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
function formatMachineList(machines) {
|
|
33
|
+
return machines.map((machine) => ` - ${machine}`).join("\n");
|
|
34
|
+
}
|
|
35
|
+
async function resolveRepoAndMachine(repoUrl, commit, machineOverride) {
|
|
33
36
|
if (!repoUrl) {
|
|
34
37
|
const config = loadConfig();
|
|
35
|
-
|
|
38
|
+
// An explicit --machine wins over the configured machine; the repository
|
|
39
|
+
// still comes from config.
|
|
40
|
+
return { repository: config.repository, machine: machineOverride ?? config.machine };
|
|
36
41
|
}
|
|
37
42
|
const spinner = ora("Cloning backup repository").start();
|
|
38
43
|
try {
|
|
@@ -47,22 +52,28 @@ async function resolveRepoAndMachine(repoUrl, commit) {
|
|
|
47
52
|
if (machines.length === 0) {
|
|
48
53
|
throw new Error("The backup repository is empty (no machine directories found).");
|
|
49
54
|
}
|
|
50
|
-
|
|
55
|
+
if (machineOverride) {
|
|
56
|
+
if (!machines.includes(machineOverride)) {
|
|
57
|
+
throw new Error(`No backup found for machine "${machineOverride}".\n Available machines:\n${formatMachineList(machines)}`);
|
|
58
|
+
}
|
|
59
|
+
return { repository: repoUrl, machine: machineOverride };
|
|
60
|
+
}
|
|
51
61
|
if (machines.length === 1) {
|
|
52
|
-
machine
|
|
62
|
+
return { repository: repoUrl, machine: machines[0] };
|
|
53
63
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
message: "Multiple machines found. Which one do you want to restore?",
|
|
57
|
-
loop: false,
|
|
58
|
-
choices: machines.map((machine) => ({ name: machine, value: machine })),
|
|
59
|
-
});
|
|
64
|
+
if (!process.stdin.isTTY) {
|
|
65
|
+
throw new Error(`Multiple machines found in the backup repository. Re-run with --machine <name>:\n${formatMachineList(machines)}`);
|
|
60
66
|
}
|
|
67
|
+
const machine = await select({
|
|
68
|
+
message: "Multiple machines found. Which one do you want to restore?",
|
|
69
|
+
loop: false,
|
|
70
|
+
choices: machines.map((machine) => ({ name: machine, value: machine })),
|
|
71
|
+
});
|
|
61
72
|
return { repository: repoUrl, machine };
|
|
62
73
|
}
|
|
63
|
-
export async function restore({ repoUrl, commit,
|
|
74
|
+
export async function restore({ repoUrl, commit, skipExisting, machine: machineOverride, } = {}) {
|
|
64
75
|
logger.info("Starting restore");
|
|
65
|
-
const { repository, machine } = await resolveRepoAndMachine(repoUrl, commit);
|
|
76
|
+
const { repository, machine } = await resolveRepoAndMachine(repoUrl, commit, machineOverride);
|
|
66
77
|
const spinner = ora("Fetching latest backup").start();
|
|
67
78
|
const machineStagingDir = machineDir(machine);
|
|
68
79
|
try {
|
|
@@ -87,7 +98,12 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
|
|
|
87
98
|
}
|
|
88
99
|
return;
|
|
89
100
|
}
|
|
90
|
-
|
|
101
|
+
// Restore only the two managed namespaces; anything else in the machine dir
|
|
102
|
+
// (e.g. a user-authored README.md) is documentation, not a file to restore.
|
|
103
|
+
const backupFiles = [HOME_NAMESPACE, ROOT_NAMESPACE]
|
|
104
|
+
.map((namespace) => path.join(machineStagingDir, namespace))
|
|
105
|
+
.filter((namespaceDir) => fs.existsSync(namespaceDir))
|
|
106
|
+
.flatMap(listFilesRecursively);
|
|
91
107
|
logger.info(`Found ${pluralize(backupFiles.length, "file")} in backup repository`);
|
|
92
108
|
if (backupFiles.length === 0) {
|
|
93
109
|
spinner.stop();
|
|
@@ -95,11 +111,12 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
|
|
|
95
111
|
return;
|
|
96
112
|
}
|
|
97
113
|
const fileMappings = backupFiles.map((backupFilePath) => {
|
|
98
|
-
let
|
|
99
|
-
if (
|
|
100
|
-
|
|
114
|
+
let machineRelativePath = path.relative(machineStagingDir, backupFilePath);
|
|
115
|
+
if (machineRelativePath.endsWith(ENC_SUFFIX)) {
|
|
116
|
+
machineRelativePath = machineRelativePath.slice(0, -ENC_SUFFIX.length);
|
|
101
117
|
}
|
|
102
|
-
|
|
118
|
+
const { destination, displayPath } = getRestoreTarget(machineRelativePath);
|
|
119
|
+
return { src: backupFilePath, dest: destination, displayPath };
|
|
103
120
|
});
|
|
104
121
|
const filesAlreadyOnDisk = fileMappings.filter((file) => fs.existsSync(file.dest));
|
|
105
122
|
const newFiles = fileMappings.filter((file) => !fs.existsSync(file.dest));
|
|
@@ -107,10 +124,10 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
|
|
|
107
124
|
spinner.stop();
|
|
108
125
|
console.log();
|
|
109
126
|
let filesToRestore;
|
|
110
|
-
if (
|
|
127
|
+
if (skipExisting) {
|
|
111
128
|
filesToRestore = newFiles;
|
|
112
129
|
if (filesAlreadyOnDisk.length > 0) {
|
|
113
|
-
console.log(` Skipped ${pluralize(filesAlreadyOnDisk.length, "existing file")}. Run without --
|
|
130
|
+
console.log(` Skipped ${pluralize(filesAlreadyOnDisk.length, "existing file")}. Run without --no-overwrite to select them.`);
|
|
114
131
|
console.log();
|
|
115
132
|
}
|
|
116
133
|
}
|
|
@@ -119,13 +136,13 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
|
|
|
119
136
|
if (newFiles.length > 0) {
|
|
120
137
|
choices.push(new Separator(`── New files (${newFiles.length}) ──`));
|
|
121
138
|
for (const file of newFiles) {
|
|
122
|
-
choices.push({ name: file.
|
|
139
|
+
choices.push({ name: file.displayPath, value: file, checked: true });
|
|
123
140
|
}
|
|
124
141
|
}
|
|
125
142
|
if (filesAlreadyOnDisk.length > 0) {
|
|
126
143
|
choices.push(new Separator(`── Existing files — will overwrite (${filesAlreadyOnDisk.length}) ──`));
|
|
127
144
|
for (const file of filesAlreadyOnDisk) {
|
|
128
|
-
choices.push({ name: file.
|
|
145
|
+
choices.push({ name: file.displayPath, value: file, checked: false });
|
|
129
146
|
}
|
|
130
147
|
}
|
|
131
148
|
filesToRestore = await checkbox({
|
|
@@ -163,4 +180,9 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
|
|
|
163
180
|
await offerToSaveKeyFile(password);
|
|
164
181
|
}
|
|
165
182
|
logger.info(`Restored ${pluralize(filesToRestore.length, "file")}`);
|
|
183
|
+
// Run the hook only if it was among the files just restored, so we
|
|
184
|
+
// never execute a stale on-disk script the user chose not to restore.
|
|
185
|
+
if (filesToRestore.some(({ dest }) => dest === POST_RESTORE_HOOK_PATH)) {
|
|
186
|
+
runPostRestoreHook();
|
|
187
|
+
}
|
|
166
188
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import os from "node:os";
|
|
2
3
|
import chalk from "chalk";
|
|
3
4
|
import ora from "ora";
|
|
@@ -24,6 +25,11 @@ function formatVisibility(visibility) {
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
export async function status() {
|
|
28
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
29
|
+
console.log();
|
|
30
|
+
console.log(` No config found. Run ${chalk.bold("backdot init")} to get started.\n`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
27
33
|
const scheduled = isScheduled();
|
|
28
34
|
console.log();
|
|
29
35
|
console.log(` Schedule: ${scheduled ? "active (daily at 02:00)" : `not active (run ${chalk.bold("backdot schedule")} to enable)`}`);
|
|
@@ -46,29 +52,35 @@ export async function status() {
|
|
|
46
52
|
console.log();
|
|
47
53
|
return;
|
|
48
54
|
}
|
|
49
|
-
const
|
|
55
|
+
const resolveKey = config.encrypt
|
|
56
|
+
? async () => deriveKey((await resolvePassword()).password)
|
|
57
|
+
: undefined;
|
|
50
58
|
console.log();
|
|
51
59
|
const spinner = ora("Resolving files").start();
|
|
52
60
|
try {
|
|
53
61
|
const userFiles = resolveFiles(config);
|
|
54
62
|
if (userFiles.length === 0) {
|
|
55
|
-
spinner.warn("No files resolved. Check your ~/.backdot.json entries.");
|
|
63
|
+
spinner.warn("No files resolved. Check your ~/.backdot/config.json entries.");
|
|
56
64
|
console.log();
|
|
57
65
|
return;
|
|
58
66
|
}
|
|
59
67
|
const files = [...userFiles, CONFIG_PATH];
|
|
60
68
|
spinner.text = "Comparing with remote backup";
|
|
61
|
-
const { backedUp, modified, notBackedUp, error } = await compareFiles({
|
|
69
|
+
const { backedUp, modified, notBackedUp, remoteIsEmpty, error } = await compareFiles({
|
|
62
70
|
files,
|
|
63
71
|
machine: config.machine,
|
|
64
72
|
repository: config.repository,
|
|
65
|
-
|
|
73
|
+
resolveKey,
|
|
66
74
|
});
|
|
67
75
|
spinner.stop();
|
|
68
76
|
if (error) {
|
|
69
77
|
console.log(chalk.yellow(` Could not fetch status: ${error}`));
|
|
70
78
|
return;
|
|
71
79
|
}
|
|
80
|
+
if (remoteIsEmpty) {
|
|
81
|
+
console.log(` No backup yet — showing what ${chalk.bold("backdot backup")} would back up.`);
|
|
82
|
+
console.log();
|
|
83
|
+
}
|
|
72
84
|
if (modified.length === 0 && notBackedUp.length === 0) {
|
|
73
85
|
console.log(chalk.green(` All ${pluralize(files.length, "file")} are backed up ✓`));
|
|
74
86
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
|
|
2
|
+
import { CONFIG_PATH } from "./paths.js";
|
|
3
|
+
export { CONFIG_PATH };
|
|
3
4
|
export declare function expandTilde(pattern: string): string;
|
|
4
5
|
declare const ConfigSchema: z.ZodObject<{
|
|
5
6
|
repository: z.ZodString;
|
|
@@ -9,4 +10,3 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
9
10
|
}, z.core.$strip>;
|
|
10
11
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
11
12
|
export declare function loadConfig(): Config;
|
|
12
|
-
export {};
|
package/dist/config.js
CHANGED
|
@@ -2,7 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
|
|
5
|
+
import { CONFIG_PATH } from "./paths.js";
|
|
6
|
+
export { CONFIG_PATH };
|
|
6
7
|
export function expandTilde(pattern) {
|
|
7
8
|
// fast-glob uses "!" for negation patterns — preserve the prefix, expand the rest
|
|
8
9
|
if (pattern.startsWith("!")) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
import { KEY_FILE_PATH } from "../paths.js";
|
|
2
|
+
export { KEY_FILE_PATH };
|
|
2
3
|
export declare const ENC_SUFFIX = ".encrypted";
|
|
4
|
+
export declare function hashPassword(password: string): string;
|
|
3
5
|
export declare function checkKeyFilePermissions(): void;
|
|
4
6
|
export declare function saveKeyFile(password: string): void;
|
|
5
7
|
export type PasswordSource = "env" | "file" | "prompt";
|
package/dist/crypto/password.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import os from "node:os";
|
|
5
3
|
import { password as passwordPrompt, confirm } from "@inquirer/prompts";
|
|
6
|
-
|
|
4
|
+
import { KEY_FILE_PATH } from "../paths.js";
|
|
5
|
+
export { KEY_FILE_PATH };
|
|
7
6
|
export const ENC_SUFFIX = ".encrypted";
|
|
8
|
-
function hashPassword(password) {
|
|
7
|
+
export function hashPassword(password) {
|
|
9
8
|
return crypto.createHash("sha256").update(password).digest("hex");
|
|
10
9
|
}
|
|
11
10
|
export function checkKeyFilePermissions() {
|
package/dist/launchd.js
CHANGED
|
@@ -5,6 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
import ora from "ora";
|
|
6
6
|
import { logger } from "./log.js";
|
|
7
7
|
import { errorMessage } from "./utils.js";
|
|
8
|
+
import { LOG_DIR, LAUNCHD_LOG_PATH } from "./paths.js";
|
|
8
9
|
function escapeXml(text) {
|
|
9
10
|
return text
|
|
10
11
|
.replace(/&/g, "&")
|
|
@@ -22,7 +23,6 @@ function buildPlist() {
|
|
|
22
23
|
const nodePath = process.execPath;
|
|
23
24
|
const scriptPath = getScriptPath();
|
|
24
25
|
const workingDir = path.dirname(scriptPath);
|
|
25
|
-
const logPath = path.join(os.homedir(), ".backdot", "launchd.log");
|
|
26
26
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
27
27
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
28
28
|
<plist version="1.0">
|
|
@@ -45,9 +45,9 @@ function buildPlist() {
|
|
|
45
45
|
<integer>0</integer>
|
|
46
46
|
</dict>
|
|
47
47
|
<key>StandardOutPath</key>
|
|
48
|
-
<string>${escapeXml(
|
|
48
|
+
<string>${escapeXml(LAUNCHD_LOG_PATH)}</string>
|
|
49
49
|
<key>StandardErrorPath</key>
|
|
50
|
-
<string>${escapeXml(
|
|
50
|
+
<string>${escapeXml(LAUNCHD_LOG_PATH)}</string>
|
|
51
51
|
<key>RunAtLoad</key>
|
|
52
52
|
<false/>
|
|
53
53
|
</dict>
|
|
@@ -72,9 +72,8 @@ export function setupLaunchd() {
|
|
|
72
72
|
const spinner = ora("Installing schedule").start();
|
|
73
73
|
const plistContent = buildPlist();
|
|
74
74
|
const launchAgentsDir = path.dirname(PLIST_PATH);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
75
|
+
fs.mkdirSync(launchAgentsDir, { recursive: true });
|
|
76
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
78
77
|
try {
|
|
79
78
|
execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
|
|
80
79
|
}
|
package/dist/log.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import os from "node:os";
|
|
4
2
|
import winston from "winston";
|
|
5
|
-
|
|
6
|
-
const LOG_FILE = path.join(LOG_DIR, "backup.log");
|
|
3
|
+
import { LOG_DIR, CLI_LOG_PATH } from "./paths.js";
|
|
7
4
|
let _logger;
|
|
8
5
|
function getLogger() {
|
|
9
6
|
if (!_logger) {
|
|
@@ -11,7 +8,7 @@ function getLogger() {
|
|
|
11
8
|
_logger = winston.createLogger({
|
|
12
9
|
level: "info",
|
|
13
10
|
format: winston.format.combine(winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), winston.format.printf(({ timestamp, level, message }) => `${timestamp} [${level}] ${message}`)),
|
|
14
|
-
transports: [new winston.transports.File({ filename:
|
|
11
|
+
transports: [new winston.transports.File({ filename: CLI_LOG_PATH })],
|
|
15
12
|
});
|
|
16
13
|
}
|
|
17
14
|
return _logger;
|
package/dist/paths.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export declare const CONFIG_PATH: string;
|
|
2
|
+
export declare const KEY_FILE_PATH: string;
|
|
3
|
+
export declare const POST_RESTORE_HOOK_PATH: string;
|
|
1
4
|
export declare const STAGING_DIR: string;
|
|
2
5
|
export declare const STAGING_GIT_DIR: string;
|
|
6
|
+
export declare const LOG_DIR: string;
|
|
7
|
+
export declare const CLI_LOG_PATH: string;
|
|
8
|
+
export declare const LAUNCHD_LOG_PATH: string;
|
|
3
9
|
export declare function machineDir(machine: string): string;
|
package/dist/paths.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
const HOME = os.homedir();
|
|
4
|
-
|
|
4
|
+
const BACKDOT_DIR = path.join(HOME, ".backdot");
|
|
5
|
+
export const CONFIG_PATH = path.join(BACKDOT_DIR, "config.json");
|
|
6
|
+
export const KEY_FILE_PATH = path.join(BACKDOT_DIR, "encryption.key");
|
|
7
|
+
export const POST_RESTORE_HOOK_PATH = path.join(BACKDOT_DIR, "post-restore");
|
|
8
|
+
export const STAGING_DIR = path.join(BACKDOT_DIR, "repo");
|
|
5
9
|
export const STAGING_GIT_DIR = path.join(STAGING_DIR, ".git");
|
|
10
|
+
export const LOG_DIR = path.join(BACKDOT_DIR, "logs");
|
|
11
|
+
export const CLI_LOG_PATH = path.join(LOG_DIR, "cli.log");
|
|
12
|
+
export const LAUNCHD_LOG_PATH = path.join(LOG_DIR, "launchd.log");
|
|
6
13
|
export function machineDir(machine) {
|
|
7
14
|
return path.join(STAGING_DIR, machine);
|
|
8
15
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the restored `~/.backdot/post-restore` script (a POSIX `sh` script) so a
|
|
3
|
+
* restored machine can provision itself. The caller decides when to invoke this
|
|
4
|
+
* (only when the hook was actually among the restored files). A non-zero exit is
|
|
5
|
+
* surfaced as an error — never swallowed — but files have already been restored.
|
|
6
|
+
*/
|
|
7
|
+
export declare function runPostRestoreHook(): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { logger } from "./log.js";
|
|
4
|
+
import { errorMessage } from "./utils.js";
|
|
5
|
+
import { POST_RESTORE_HOOK_PATH } from "./paths.js";
|
|
6
|
+
/**
|
|
7
|
+
* Runs the restored `~/.backdot/post-restore` script (a POSIX `sh` script) so a
|
|
8
|
+
* restored machine can provision itself. The caller decides when to invoke this
|
|
9
|
+
* (only when the hook was actually among the restored files). A non-zero exit is
|
|
10
|
+
* surfaced as an error — never swallowed — but files have already been restored.
|
|
11
|
+
*/
|
|
12
|
+
export function runPostRestoreHook() {
|
|
13
|
+
if (process.platform === "win32") {
|
|
14
|
+
console.log(" Post-restore hook found, but hooks are not supported on Windows. Skipping.\n");
|
|
15
|
+
logger.warn("Post-restore hook skipped (not supported on Windows)");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
console.log(` Running post-restore hook (${POST_RESTORE_HOOK_PATH})…\n`);
|
|
19
|
+
logger.info("Running post-restore hook");
|
|
20
|
+
try {
|
|
21
|
+
execFileSync("/bin/sh", [POST_RESTORE_HOOK_PATH], {
|
|
22
|
+
cwd: os.homedir(),
|
|
23
|
+
stdio: "inherit",
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
logger.error(`Post-restore hook failed: ${errorMessage(err)}`);
|
|
28
|
+
const exitCode = err.status;
|
|
29
|
+
const exitNote = typeof exitCode === "number" ? ` (exit code ${exitCode})` : "";
|
|
30
|
+
throw new Error(`Files were restored, but the post-restore hook failed${exitNote}.\n` +
|
|
31
|
+
` Fix ${POST_RESTORE_HOOK_PATH} and re-run, or run it manually.`, { cause: err });
|
|
32
|
+
}
|
|
33
|
+
console.log();
|
|
34
|
+
logger.info("Post-restore hook completed");
|
|
35
|
+
}
|
package/dist/repoVisibility.js
CHANGED
|
@@ -36,7 +36,12 @@ function execGitLsRemote(url) {
|
|
|
36
36
|
return new Promise((resolve, reject) => {
|
|
37
37
|
const child = execFile("git", ["-c", "credential.helper=", "ls-remote", "--quiet", url], {
|
|
38
38
|
timeout: 10_000,
|
|
39
|
-
env: {
|
|
39
|
+
env: {
|
|
40
|
+
...process.env,
|
|
41
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
42
|
+
GIT_ASKPASS: undefined,
|
|
43
|
+
SSH_ASKPASS: undefined,
|
|
44
|
+
},
|
|
40
45
|
}, (error) => {
|
|
41
46
|
if (error) {
|
|
42
47
|
reject(error);
|
package/dist/staging.d.ts
CHANGED
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
import { STAGING_DIR, STAGING_GIT_DIR, machineDir } from "./paths.js";
|
|
2
2
|
import { type DerivedKey } from "./crypto/encryption.js";
|
|
3
3
|
export { STAGING_DIR, STAGING_GIT_DIR, machineDir };
|
|
4
|
+
export declare const HOME_NAMESPACE = "home";
|
|
5
|
+
export declare const ROOT_NAMESPACE = "root";
|
|
4
6
|
export declare function getStagedPath(filePath: string, machine: string): string;
|
|
7
|
+
export interface RestoreTarget {
|
|
8
|
+
/** Absolute path the file is restored to on this machine. */
|
|
9
|
+
destination: string;
|
|
10
|
+
/** Path shown in the restore picker (e.g. "~/.zshrc" or "/etc/hosts"). */
|
|
11
|
+
displayPath: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Inverse of getStagedPath: maps a path relative to the machine dir
|
|
15
|
+
* (e.g. "home/.zshrc" or "root/etc/hosts") back to its restore destination.
|
|
16
|
+
*/
|
|
17
|
+
export declare function getRestoreTarget(machineRelativePath: string): RestoreTarget;
|
|
5
18
|
export declare function cleanStaging(machine: string): void;
|
|
6
19
|
export declare function copyToStaging(files: string[], machine: string, derivedKey?: DerivedKey): void;
|
|
7
20
|
export interface ComparisonResult {
|
|
8
21
|
backedUp: string[];
|
|
9
22
|
modified: string[];
|
|
10
23
|
notBackedUp: string[];
|
|
24
|
+
remoteIsEmpty?: boolean;
|
|
11
25
|
error?: string;
|
|
12
26
|
}
|
|
13
27
|
export declare function compareFiles(opts: {
|
|
14
28
|
files: string[];
|
|
15
29
|
machine: string;
|
|
16
30
|
repository: string;
|
|
17
|
-
|
|
31
|
+
resolveKey?: () => Promise<DerivedKey>;
|
|
18
32
|
}): Promise<ComparisonResult>;
|
|
19
33
|
export declare function writeRepoReadme(repository: string, encrypted?: boolean): void;
|
package/dist/staging.js
CHANGED
|
@@ -11,20 +11,44 @@ import { encrypt, decrypt } from "./crypto/encryption.js";
|
|
|
11
11
|
import { KEY_FILE_PATH, ENC_SUFFIX } from "./crypto/password.js";
|
|
12
12
|
export { STAGING_DIR, STAGING_GIT_DIR, machineDir };
|
|
13
13
|
const HOME = os.homedir();
|
|
14
|
+
// Backed-up files live under one of two namespaces inside the machine dir, which
|
|
15
|
+
// keeps the layout lossless: HOME files restore relative to the restoring machine's
|
|
16
|
+
// own home (portable across machines), while files elsewhere restore to their
|
|
17
|
+
// original absolute path.
|
|
18
|
+
export const HOME_NAMESPACE = "home";
|
|
19
|
+
export const ROOT_NAMESPACE = "root";
|
|
20
|
+
function isOutsideHome(filePath) {
|
|
21
|
+
const relativeToHome = path.relative(HOME, filePath);
|
|
22
|
+
return relativeToHome.startsWith("..") || path.isAbsolute(relativeToHome);
|
|
23
|
+
}
|
|
14
24
|
export function getStagedPath(filePath, machine) {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const pathWithinMachineDir = relativePath.startsWith("..") ? filePath.slice(1) : relativePath;
|
|
25
|
+
const pathWithinMachineDir = isOutsideHome(filePath)
|
|
26
|
+
? path.join(ROOT_NAMESPACE, filePath.slice(1)) // strip leading "/" so it stays inside the machine dir
|
|
27
|
+
: path.join(HOME_NAMESPACE, path.relative(HOME, filePath));
|
|
19
28
|
return path.join(machineDir(machine), pathWithinMachineDir);
|
|
20
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Inverse of getStagedPath: maps a path relative to the machine dir
|
|
32
|
+
* (e.g. "home/.zshrc" or "root/etc/hosts") back to its restore destination.
|
|
33
|
+
*/
|
|
34
|
+
export function getRestoreTarget(machineRelativePath) {
|
|
35
|
+
const [namespace, ...rest] = machineRelativePath.split(path.sep);
|
|
36
|
+
const subPath = rest.join(path.sep);
|
|
37
|
+
if (namespace === ROOT_NAMESPACE) {
|
|
38
|
+
const destination = path.join("/", subPath);
|
|
39
|
+
return { destination, displayPath: destination };
|
|
40
|
+
}
|
|
41
|
+
return { destination: path.join(HOME, subPath), displayPath: path.join("~", subPath) };
|
|
42
|
+
}
|
|
21
43
|
export function cleanStaging(machine) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
44
|
+
// Remove only the backdot-managed namespaces so user-authored files in the
|
|
45
|
+
// machine dir (e.g. a hand-written README.md with restore notes) survive
|
|
46
|
+
// across backups. home/ and root/ are still fully rebuilt, so a backup
|
|
47
|
+
// remains a complete snapshot of the configured files.
|
|
48
|
+
for (const namespace of [HOME_NAMESPACE, ROOT_NAMESPACE]) {
|
|
49
|
+
fs.rmSync(path.join(machineDir(machine), namespace), { recursive: true, force: true });
|
|
25
50
|
}
|
|
26
|
-
|
|
27
|
-
logger.info(`Cleaned staging directory for machine "${machine}"`);
|
|
51
|
+
logger.info(`Cleaned staging namespaces for machine "${machine}"`);
|
|
28
52
|
}
|
|
29
53
|
export function copyToStaging(files, machine, derivedKey) {
|
|
30
54
|
const machineStagingDir = machineDir(machine);
|
|
@@ -54,13 +78,18 @@ export function copyToStaging(files, machine, derivedKey) {
|
|
|
54
78
|
function failedComparisonResult(err) {
|
|
55
79
|
return { backedUp: [], modified: [], notBackedUp: [], error: errorMessage(err) };
|
|
56
80
|
}
|
|
81
|
+
// This machine has no snapshot in the remote yet, so every file counts as "not
|
|
82
|
+
// backed up". Lets `status` preview what a first backup would push instead of erroring.
|
|
83
|
+
function emptyRemoteResult(files) {
|
|
84
|
+
return { backedUp: [], modified: [], notBackedUp: files, remoteIsEmpty: true };
|
|
85
|
+
}
|
|
57
86
|
export async function compareFiles(opts) {
|
|
58
|
-
const { files, machine, repository,
|
|
87
|
+
const { files, machine, repository, resolveKey } = opts;
|
|
59
88
|
if (files.length === 0) {
|
|
60
89
|
return { backedUp: [], modified: [], notBackedUp: [] };
|
|
61
90
|
}
|
|
62
91
|
if (!fs.existsSync(STAGING_GIT_DIR)) {
|
|
63
|
-
return
|
|
92
|
+
return emptyRemoteResult(files);
|
|
64
93
|
}
|
|
65
94
|
const git = simpleGit(STAGING_DIR);
|
|
66
95
|
try {
|
|
@@ -92,7 +121,13 @@ export async function compareFiles(opts) {
|
|
|
92
121
|
catch (err) {
|
|
93
122
|
return failedComparisonResult(err);
|
|
94
123
|
}
|
|
95
|
-
|
|
124
|
+
// Repo reachable but this machine has nothing backed up yet (empty repo, or it
|
|
125
|
+
// only holds other machines). Treat as a pre-backup preview.
|
|
126
|
+
if (remoteBlobHashes.size === 0) {
|
|
127
|
+
return emptyRemoteResult(files);
|
|
128
|
+
}
|
|
129
|
+
if (resolveKey) {
|
|
130
|
+
const derivedKey = await resolveKey();
|
|
96
131
|
return compareFilesToRemote(files, machine, remoteBlobHashes, ENC_SUFFIX, (file, remoteBlobHash) => {
|
|
97
132
|
try {
|
|
98
133
|
const blobContent = execFileSync("git", ["cat-file", "blob", remoteBlobHash], {
|
|
@@ -152,9 +187,11 @@ ${encryptionNote}
|
|
|
152
187
|
## Restore
|
|
153
188
|
|
|
154
189
|
\`\`\`bash
|
|
155
|
-
npx backdot restore ${repository}
|
|
190
|
+
npx backdot restore ${repository} --machine <machine>
|
|
156
191
|
\`\`\`
|
|
157
192
|
|
|
193
|
+
Each machine is a top-level directory here; replace \`<machine>\` with the one you want to restore. A machine may also contain its own \`README.md\` with extra, machine-specific restore steps — backdot preserves it across backups.
|
|
194
|
+
|
|
158
195
|
For full documentation, configuration options, and scheduling, see the [official README](https://github.com/sorenlouv/backdot).
|
|
159
196
|
`;
|
|
160
197
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backdot",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.2",
|
|
4
4
|
"description": "Lightweight CLI to backup dotfiles and gitignored files to a Git repo on a daily schedule",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,8 +30,7 @@
|
|
|
30
30
|
"fmt:check": "prettier --check src/",
|
|
31
31
|
"lint": "eslint src/",
|
|
32
32
|
"lint:fix": "eslint src/ --fix",
|
|
33
|
-
"prepare": "husky",
|
|
34
|
-
"release": "./scripts/release.sh",
|
|
33
|
+
"prepare": "cd .. && husky",
|
|
35
34
|
"start": "node dist/cli.js",
|
|
36
35
|
"test": "vitest run --exclude src/e2e.test.ts --exclude src/git.integration.test.ts",
|
|
37
36
|
"test:all": "npm run build && vitest run",
|
|
@@ -40,28 +39,29 @@
|
|
|
40
39
|
"test:watch": "vitest"
|
|
41
40
|
},
|
|
42
41
|
"dependencies": {
|
|
43
|
-
"@inquirer/prompts": "^8.
|
|
42
|
+
"@inquirer/prompts": "^8.5.2",
|
|
44
43
|
"cac": "^7.0.0",
|
|
45
44
|
"chalk": "^5.6.2",
|
|
46
45
|
"fast-glob": "^3.3.3",
|
|
47
|
-
"ora": "^9.
|
|
48
|
-
"p-retry": "^
|
|
49
|
-
"simple-git": "^3.
|
|
46
|
+
"ora": "^9.4.0",
|
|
47
|
+
"p-retry": "^8.0.0",
|
|
48
|
+
"simple-git": "^3.36.0",
|
|
50
49
|
"winston": "^3.19.0",
|
|
51
|
-
"zod": "^4.3
|
|
50
|
+
"zod": "^4.4.3"
|
|
52
51
|
},
|
|
53
52
|
"engines": {
|
|
54
|
-
"node": ">=
|
|
53
|
+
"node": ">=24"
|
|
55
54
|
},
|
|
56
55
|
"devDependencies": {
|
|
57
56
|
"@eslint/js": "^10.0.1",
|
|
58
|
-
"@types/node": "^
|
|
59
|
-
"
|
|
57
|
+
"@types/node": "^25.9.3",
|
|
58
|
+
"esbuild": "^0.28.1",
|
|
59
|
+
"eslint": "^10.5.0",
|
|
60
60
|
"eslint-config-prettier": "^10.1.8",
|
|
61
61
|
"husky": "^9.1.7",
|
|
62
|
-
"prettier": "^3.8.
|
|
63
|
-
"typescript": "^
|
|
64
|
-
"typescript-eslint": "^8.
|
|
65
|
-
"vitest": "^4.
|
|
62
|
+
"prettier": "^3.8.4",
|
|
63
|
+
"typescript": "^6.0.3",
|
|
64
|
+
"typescript-eslint": "^8.61.0",
|
|
65
|
+
"vitest": "^4.1.8"
|
|
66
66
|
}
|
|
67
67
|
}
|
package/README.md
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
<p align="center">
|
|
2
|
-
<img src="logo-small.png" alt="backdot" width="400" />
|
|
3
|
-
</p>
|
|
4
|
-
|
|
5
|
-
<h1 align="center">backdot</h1>
|
|
6
|
-
|
|
7
|
-
<p align="center">Automated backup of important files (configs, dotfiles) to your own private Git repo.</p>
|
|
8
|
-
|
|
9
|
-
## Getting started
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
npm install -g backdot
|
|
13
|
-
backdot init
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
This creates `~/.backdot.json` with sensible defaults and walks you through setup. Open the config file and set your repository URL and the files you want backed up:
|
|
17
|
-
|
|
18
|
-
```json
|
|
19
|
-
{
|
|
20
|
-
"repository": "git@github.com:USERNAME/backdot-backup.git",
|
|
21
|
-
"machine": "my-work-laptop",
|
|
22
|
-
"paths": ["~/.zshrc", "~/.oh-my-zsh/custom/*.zsh", "~/.ssh/config", "~/.npmrc"]
|
|
23
|
-
}
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Run your first backup:
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
backdot backup
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
or configure the backport process to run automatically (daily at 2am)
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
backdot schedule
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
## Configuration
|
|
39
|
-
|
|
40
|
-
| Key | Description |
|
|
41
|
-
| ------- | ------------------------------------------------------ |
|
|
42
|
-
| `paths` | Glob patterns matching individual files or directories |
|
|
43
|
-
|
|
44
|
-
Prefix a pattern with `!` to exclude matching files:
|
|
45
|
-
|
|
46
|
-
```json
|
|
47
|
-
{
|
|
48
|
-
"paths": ["~/.config/ghostty/**", "!~/.config/ghostty/crash-reports/**"]
|
|
49
|
-
}
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
## Encryption
|
|
53
|
-
|
|
54
|
-
To encrypt files before they are pushed to the remote repo, add `"encrypt": true` to your config.
|
|
55
|
-
|
|
56
|
-
On first backup you'll be prompted for a password and offered to save it to `~/.backdot.key` so that future backups do not prompt for a password.
|
|
57
|
-
|
|
58
|
-
## Commands
|
|
59
|
-
|
|
60
|
-
| Command | Description |
|
|
61
|
-
| ------------------------------ | ---------------------------------------------- |
|
|
62
|
-
| `init` | Set up backdot for the first time |
|
|
63
|
-
| `backup` | Run a backup now |
|
|
64
|
-
| `restore` | Restore latest backup from the configured repo |
|
|
65
|
-
| `restore <url>` | Restore from a specific repo URL |
|
|
66
|
-
| `restore [url] --commit <sha>` | Restore from a specific backup commit |
|
|
67
|
-
| `history [url]` | Browse and restore a previous backup |
|
|
68
|
-
| `schedule` | Schedule automatic daily backup (Mac-only) |
|
|
69
|
-
| `unschedule` | Unschedule the daily backup |
|
|
70
|
-
| `status` | Show schedule and resolved file list |
|
|
71
|
-
|
|
72
|
-
## Development
|
|
73
|
-
|
|
74
|
-
```bash
|
|
75
|
-
npm install
|
|
76
|
-
npm run build
|
|
77
|
-
npm start
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
## License
|
|
81
|
-
|
|
82
|
-
MIT
|
package/dist/crypto.d.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
export declare const KEY_FILE_PATH: string;
|
|
2
|
-
export declare const ENC_SUFFIX = ".encrypted";
|
|
3
|
-
export declare function encryptBuffer(plaintext: Buffer, password: string): Buffer;
|
|
4
|
-
export declare function decryptBuffer(data: Buffer, password: string): Buffer;
|
|
5
|
-
export declare function checkKeyFilePermissions(): void;
|
|
6
|
-
export declare function saveKeyFile(password: string): void;
|
|
7
|
-
export interface PasswordResult {
|
|
8
|
-
password: string;
|
|
9
|
-
interactive: boolean;
|
|
10
|
-
}
|
|
11
|
-
export declare function resolvePassword(): Promise<PasswordResult>;
|
|
12
|
-
export declare function confirmPassword(password: string): Promise<void>;
|
|
13
|
-
export declare function offerToSaveKeyFile(password: string): Promise<void>;
|
package/dist/crypto.js
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import os from "node:os";
|
|
5
|
-
import { password as passwordPrompt, confirm } from "@inquirer/prompts";
|
|
6
|
-
// File-format signature identifying backdot-encrypted files
|
|
7
|
-
const MAGIC = Buffer.from("BDOT");
|
|
8
|
-
const VERSION = 0x01;
|
|
9
|
-
const HEADER_SIZE = MAGIC.length + 1; // 5 bytes: magic + version
|
|
10
|
-
const SALT_SIZE = 32;
|
|
11
|
-
const IV_SIZE = 12;
|
|
12
|
-
const TAG_SIZE = 16;
|
|
13
|
-
const KEY_SIZE = 32;
|
|
14
|
-
export const KEY_FILE_PATH = path.join(os.homedir(), ".backdot.key");
|
|
15
|
-
export const ENC_SUFFIX = ".encrypted";
|
|
16
|
-
function deriveKey(password, salt) {
|
|
17
|
-
return crypto.scryptSync(password, salt, KEY_SIZE, { N: 2 ** 14, r: 8, p: 1 });
|
|
18
|
-
}
|
|
19
|
-
export function encryptBuffer(plaintext, password) {
|
|
20
|
-
const salt = crypto.randomBytes(SALT_SIZE);
|
|
21
|
-
const iv = crypto.randomBytes(IV_SIZE);
|
|
22
|
-
const key = deriveKey(password, salt);
|
|
23
|
-
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
24
|
-
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
25
|
-
const tag = cipher.getAuthTag();
|
|
26
|
-
return Buffer.concat([MAGIC, Buffer.from([VERSION]), salt, iv, tag, encrypted]);
|
|
27
|
-
}
|
|
28
|
-
export function decryptBuffer(data, password) {
|
|
29
|
-
if (!isEncrypted(data)) {
|
|
30
|
-
throw new Error("File is not encrypted (missing BDOT header).");
|
|
31
|
-
}
|
|
32
|
-
const minSize = HEADER_SIZE + SALT_SIZE + IV_SIZE + TAG_SIZE;
|
|
33
|
-
if (data.length < minSize) {
|
|
34
|
-
throw new Error("Encrypted file is too short or corrupted.");
|
|
35
|
-
}
|
|
36
|
-
let offset = HEADER_SIZE;
|
|
37
|
-
const salt = data.subarray(offset, offset + SALT_SIZE);
|
|
38
|
-
offset += SALT_SIZE;
|
|
39
|
-
const iv = data.subarray(offset, offset + IV_SIZE);
|
|
40
|
-
offset += IV_SIZE;
|
|
41
|
-
const tag = data.subarray(offset, offset + TAG_SIZE);
|
|
42
|
-
offset += TAG_SIZE;
|
|
43
|
-
const ciphertext = data.subarray(offset);
|
|
44
|
-
const key = deriveKey(password, salt);
|
|
45
|
-
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
46
|
-
decipher.setAuthTag(tag);
|
|
47
|
-
try {
|
|
48
|
-
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
throw new Error("Decryption failed — wrong password or corrupted file.");
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
function isEncrypted(data) {
|
|
55
|
-
return (data.length >= HEADER_SIZE &&
|
|
56
|
-
data[0] === MAGIC[0] &&
|
|
57
|
-
data[1] === MAGIC[1] &&
|
|
58
|
-
data[2] === MAGIC[2] &&
|
|
59
|
-
data[3] === MAGIC[3] &&
|
|
60
|
-
data[4] === VERSION);
|
|
61
|
-
}
|
|
62
|
-
export function checkKeyFilePermissions() {
|
|
63
|
-
if (process.platform === "win32") {
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
if (!fs.existsSync(KEY_FILE_PATH)) {
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
const stat = fs.statSync(KEY_FILE_PATH);
|
|
70
|
-
const mode = stat.mode & 0o777;
|
|
71
|
-
const isAccessibleByGroupOrOthers = (mode & 0o077) !== 0;
|
|
72
|
-
if (isAccessibleByGroupOrOthers) {
|
|
73
|
-
throw new Error(`Key file ${KEY_FILE_PATH} has overly permissive permissions (${mode.toString(8)}).\n` +
|
|
74
|
-
` Run: chmod 600 ${KEY_FILE_PATH}`);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
export function saveKeyFile(password) {
|
|
78
|
-
fs.writeFileSync(KEY_FILE_PATH, password + "\n", { mode: 0o600 });
|
|
79
|
-
}
|
|
80
|
-
function readKeyFile() {
|
|
81
|
-
if (!fs.existsSync(KEY_FILE_PATH)) {
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
checkKeyFilePermissions();
|
|
85
|
-
return fs.readFileSync(KEY_FILE_PATH, "utf-8").trimEnd();
|
|
86
|
-
}
|
|
87
|
-
export async function resolvePassword() {
|
|
88
|
-
const envPassword = process.env.BACKDOT_PASSWORD;
|
|
89
|
-
if (envPassword) {
|
|
90
|
-
return { password: envPassword, interactive: false };
|
|
91
|
-
}
|
|
92
|
-
const filePassword = readKeyFile();
|
|
93
|
-
if (filePassword) {
|
|
94
|
-
return { password: filePassword, interactive: false };
|
|
95
|
-
}
|
|
96
|
-
if (!process.stdin.isTTY) {
|
|
97
|
-
throw new Error('Encryption is enabled but no password found.\n Run "backdot backup" interactively to create ~/.backdot.key, or set BACKDOT_PASSWORD.');
|
|
98
|
-
}
|
|
99
|
-
const enteredPassword = await passwordPrompt({ message: "Enter encryption password:" });
|
|
100
|
-
if (!enteredPassword) {
|
|
101
|
-
throw new Error("No password provided.");
|
|
102
|
-
}
|
|
103
|
-
return { password: enteredPassword, interactive: true };
|
|
104
|
-
}
|
|
105
|
-
export async function confirmPassword(password) {
|
|
106
|
-
const confirmedPassword = await passwordPrompt({ message: "Confirm password:" });
|
|
107
|
-
if (confirmedPassword !== password) {
|
|
108
|
-
throw new Error("Passwords do not match.");
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
export async function offerToSaveKeyFile(password) {
|
|
112
|
-
if (!process.stdin.isTTY) {
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
if (fs.existsSync(KEY_FILE_PATH)) {
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
if (process.env.BACKDOT_PASSWORD) {
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
const save = await confirm({
|
|
122
|
-
message: "Save password to ~/.backdot.key for automated backups?",
|
|
123
|
-
default: true,
|
|
124
|
-
});
|
|
125
|
-
if (save) {
|
|
126
|
-
saveKeyFile(password);
|
|
127
|
-
console.log(` Created ${KEY_FILE_PATH} (permissions: 600)`);
|
|
128
|
-
}
|
|
129
|
-
}
|
package/dist/encryption.d.ts
DELETED
package/dist/encryption.js
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
const ALGORITHM = "aes-256-gcm";
|
|
3
|
-
const SALT_LENGTH = 32;
|
|
4
|
-
const IV_LENGTH = 12;
|
|
5
|
-
const AUTH_TAG_LENGTH = 16;
|
|
6
|
-
const KEY_LENGTH = 32;
|
|
7
|
-
const SCRYPT_PARAMS = { N: 2 ** 14, r: 8, p: 1 };
|
|
8
|
-
const OVERHEAD = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;
|
|
9
|
-
function deriveKey(password, salt) {
|
|
10
|
-
return crypto.scryptSync(password, salt, KEY_LENGTH, SCRYPT_PARAMS);
|
|
11
|
-
}
|
|
12
|
-
export function encrypt(plaintext, password) {
|
|
13
|
-
const salt = crypto.randomBytes(SALT_LENGTH);
|
|
14
|
-
const iv = crypto.randomBytes(IV_LENGTH);
|
|
15
|
-
const key = deriveKey(password, salt);
|
|
16
|
-
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
17
|
-
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
18
|
-
const authTag = cipher.getAuthTag();
|
|
19
|
-
return Buffer.concat([salt, iv, authTag, ciphertext]);
|
|
20
|
-
}
|
|
21
|
-
export function decrypt(encrypted, password) {
|
|
22
|
-
if (encrypted.length < OVERHEAD) {
|
|
23
|
-
throw new Error("Data is too short to be encrypted content.");
|
|
24
|
-
}
|
|
25
|
-
let offset = 0;
|
|
26
|
-
const salt = encrypted.subarray(offset, (offset += SALT_LENGTH));
|
|
27
|
-
const iv = encrypted.subarray(offset, (offset += IV_LENGTH));
|
|
28
|
-
const authTag = encrypted.subarray(offset, (offset += AUTH_TAG_LENGTH));
|
|
29
|
-
const ciphertext = encrypted.subarray(offset);
|
|
30
|
-
const key = deriveKey(password, salt);
|
|
31
|
-
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
32
|
-
decipher.setAuthTag(authTag);
|
|
33
|
-
try {
|
|
34
|
-
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
throw new Error("Decryption failed — wrong password or corrupted data.");
|
|
38
|
-
}
|
|
39
|
-
}
|
package/dist/errorMessage.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function errorMessage(err: unknown): string;
|
package/dist/errorMessage.js
DELETED
package/dist/password.d.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export declare const KEY_FILE_PATH: string;
|
|
2
|
-
export declare const ENC_SUFFIX = ".encrypted";
|
|
3
|
-
export declare function checkKeyFilePermissions(): void;
|
|
4
|
-
export declare function saveKeyFile(password: string): void;
|
|
5
|
-
export interface PasswordResult {
|
|
6
|
-
password: string;
|
|
7
|
-
interactive: boolean;
|
|
8
|
-
}
|
|
9
|
-
export declare function resolvePassword(): Promise<PasswordResult>;
|
|
10
|
-
export declare function confirmPassword(password: string): Promise<void>;
|
|
11
|
-
export declare function offerToSaveKeyFile(password: string): Promise<void>;
|
package/dist/password.js
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import { password as passwordPrompt, confirm } from "@inquirer/prompts";
|
|
5
|
-
export const KEY_FILE_PATH = path.join(os.homedir(), ".backdot.key");
|
|
6
|
-
export const ENC_SUFFIX = ".encrypted";
|
|
7
|
-
export function checkKeyFilePermissions() {
|
|
8
|
-
if (process.platform === "win32") {
|
|
9
|
-
return;
|
|
10
|
-
}
|
|
11
|
-
if (!fs.existsSync(KEY_FILE_PATH)) {
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
|
-
const stat = fs.statSync(KEY_FILE_PATH);
|
|
15
|
-
const mode = stat.mode & 0o777;
|
|
16
|
-
const isAccessibleByGroupOrOthers = (mode & 0o077) !== 0;
|
|
17
|
-
if (isAccessibleByGroupOrOthers) {
|
|
18
|
-
throw new Error(`Key file ${KEY_FILE_PATH} has overly permissive permissions (${mode.toString(8)}).\n` +
|
|
19
|
-
` Run: chmod 600 ${KEY_FILE_PATH}`);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
export function saveKeyFile(password) {
|
|
23
|
-
fs.writeFileSync(KEY_FILE_PATH, password + "\n", { mode: 0o600 });
|
|
24
|
-
}
|
|
25
|
-
function readKeyFile() {
|
|
26
|
-
if (!fs.existsSync(KEY_FILE_PATH)) {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
checkKeyFilePermissions();
|
|
30
|
-
return fs.readFileSync(KEY_FILE_PATH, "utf-8").trimEnd();
|
|
31
|
-
}
|
|
32
|
-
export async function resolvePassword() {
|
|
33
|
-
const envPassword = process.env.BACKDOT_PASSWORD;
|
|
34
|
-
if (envPassword) {
|
|
35
|
-
return { password: envPassword, interactive: false };
|
|
36
|
-
}
|
|
37
|
-
const filePassword = readKeyFile();
|
|
38
|
-
if (filePassword) {
|
|
39
|
-
return { password: filePassword, interactive: false };
|
|
40
|
-
}
|
|
41
|
-
if (!process.stdin.isTTY) {
|
|
42
|
-
throw new Error('Encryption is enabled but no password found.\n Run "backdot backup" interactively to create ~/.backdot.key, or set BACKDOT_PASSWORD.');
|
|
43
|
-
}
|
|
44
|
-
const enteredPassword = await passwordPrompt({ message: "Enter encryption password:" });
|
|
45
|
-
if (!enteredPassword) {
|
|
46
|
-
throw new Error("No password provided.");
|
|
47
|
-
}
|
|
48
|
-
return { password: enteredPassword, interactive: true };
|
|
49
|
-
}
|
|
50
|
-
export async function confirmPassword(password) {
|
|
51
|
-
const confirmedPassword = await passwordPrompt({ message: "Confirm password:" });
|
|
52
|
-
if (confirmedPassword !== password) {
|
|
53
|
-
throw new Error("Passwords do not match.");
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
export async function offerToSaveKeyFile(password) {
|
|
57
|
-
if (!process.stdin.isTTY) {
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
if (fs.existsSync(KEY_FILE_PATH)) {
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
if (process.env.BACKDOT_PASSWORD) {
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
const save = await confirm({
|
|
67
|
-
message: "Save password to ~/.backdot.key for automated backups?",
|
|
68
|
-
default: true,
|
|
69
|
-
});
|
|
70
|
-
if (save) {
|
|
71
|
-
saveKeyFile(password);
|
|
72
|
-
console.log(` Created ${KEY_FILE_PATH} (permissions: 600)`);
|
|
73
|
-
}
|
|
74
|
-
}
|
package/dist/plist.d.ts
DELETED
package/dist/plist.js
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import os from "node:os";
|
|
5
|
-
import ora from "ora";
|
|
6
|
-
import { logger } from "./log.js";
|
|
7
|
-
import { errorMessage } from "./utils.js";
|
|
8
|
-
function escapeXml(s) {
|
|
9
|
-
return s
|
|
10
|
-
.replace(/&/g, "&")
|
|
11
|
-
.replace(/</g, "<")
|
|
12
|
-
.replace(/>/g, ">")
|
|
13
|
-
.replace(/"/g, """);
|
|
14
|
-
}
|
|
15
|
-
const LABEL = "com.backdot.daemon";
|
|
16
|
-
const PLIST_PATH = path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
17
|
-
function getScriptPath() {
|
|
18
|
-
const currentDir = path.dirname(new URL(import.meta.url).pathname);
|
|
19
|
-
return path.resolve(currentDir, "cli.js");
|
|
20
|
-
}
|
|
21
|
-
function buildPlist() {
|
|
22
|
-
const nodePath = process.execPath;
|
|
23
|
-
const scriptPath = getScriptPath();
|
|
24
|
-
const workingDir = path.dirname(scriptPath);
|
|
25
|
-
const logPath = path.join(os.homedir(), ".backdot", "launchd.log");
|
|
26
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
27
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
28
|
-
<plist version="1.0">
|
|
29
|
-
<dict>
|
|
30
|
-
<key>Label</key>
|
|
31
|
-
<string>${LABEL}</string>
|
|
32
|
-
<key>ProgramArguments</key>
|
|
33
|
-
<array>
|
|
34
|
-
<string>${escapeXml(nodePath)}</string>
|
|
35
|
-
<string>${escapeXml(scriptPath)}</string>
|
|
36
|
-
<string>--backup</string>
|
|
37
|
-
</array>
|
|
38
|
-
<key>WorkingDirectory</key>
|
|
39
|
-
<string>${escapeXml(workingDir)}</string>
|
|
40
|
-
<key>StartCalendarInterval</key>
|
|
41
|
-
<dict>
|
|
42
|
-
<key>Hour</key>
|
|
43
|
-
<integer>2</integer>
|
|
44
|
-
<key>Minute</key>
|
|
45
|
-
<integer>0</integer>
|
|
46
|
-
</dict>
|
|
47
|
-
<key>StandardOutPath</key>
|
|
48
|
-
<string>${escapeXml(logPath)}</string>
|
|
49
|
-
<key>StandardErrorPath</key>
|
|
50
|
-
<string>${escapeXml(logPath)}</string>
|
|
51
|
-
<key>RunAtLoad</key>
|
|
52
|
-
<false/>
|
|
53
|
-
</dict>
|
|
54
|
-
</plist>`;
|
|
55
|
-
}
|
|
56
|
-
export function isScheduled() {
|
|
57
|
-
if (!fs.existsSync(PLIST_PATH)) {
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
try {
|
|
61
|
-
const output = execFileSync("launchctl", ["list", LABEL], { encoding: "utf-8", stdio: "pipe" });
|
|
62
|
-
return output.includes(LABEL);
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
export function setupLaunchd() {
|
|
69
|
-
const spinner = ora("Installing schedule").start();
|
|
70
|
-
const plistContent = buildPlist();
|
|
71
|
-
const dir = path.dirname(PLIST_PATH);
|
|
72
|
-
if (!fs.existsSync(dir)) {
|
|
73
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
74
|
-
}
|
|
75
|
-
try {
|
|
76
|
-
execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
79
|
-
// Not loaded, that's fine
|
|
80
|
-
}
|
|
81
|
-
fs.writeFileSync(PLIST_PATH, plistContent);
|
|
82
|
-
logger.info(`Plist written to ${PLIST_PATH}`);
|
|
83
|
-
try {
|
|
84
|
-
execFileSync("launchctl", ["load", PLIST_PATH], { stdio: "pipe" });
|
|
85
|
-
spinner.succeed("Daily backup scheduled (02:00)");
|
|
86
|
-
console.log();
|
|
87
|
-
logger.info("Launchd job loaded");
|
|
88
|
-
}
|
|
89
|
-
catch (err) {
|
|
90
|
-
const msg = errorMessage(err);
|
|
91
|
-
spinner.fail(`Failed to load launchd job: ${msg}`);
|
|
92
|
-
console.log();
|
|
93
|
-
logger.error(`Failed to load launchd job: ${msg}`);
|
|
94
|
-
throw new Error(`Failed to load launchd job: ${msg}`, { cause: err });
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
export function uninstallLaunchd() {
|
|
98
|
-
const spinner = ora("Removing schedule").start();
|
|
99
|
-
try {
|
|
100
|
-
execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
|
|
101
|
-
logger.info("Launchd job unloaded");
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
logger.info("Launchd job was not loaded");
|
|
105
|
-
}
|
|
106
|
-
if (fs.existsSync(PLIST_PATH)) {
|
|
107
|
-
fs.unlinkSync(PLIST_PATH);
|
|
108
|
-
logger.info(`Plist removed: ${PLIST_PATH}`);
|
|
109
|
-
}
|
|
110
|
-
spinner.succeed("Schedule removed");
|
|
111
|
-
console.log();
|
|
112
|
-
}
|
package/dist/resolve.d.ts
DELETED
package/dist/resolve.js
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import fg from "fast-glob";
|
|
3
|
-
import { logger } from "./log.js";
|
|
4
|
-
import { errorMessage, uniq } from "./utils.js";
|
|
5
|
-
function resolveGlobs(patterns) {
|
|
6
|
-
if (patterns.length === 0) {
|
|
7
|
-
return [];
|
|
8
|
-
}
|
|
9
|
-
try {
|
|
10
|
-
return fg.sync(patterns, { absolute: true, dot: true, onlyFiles: true });
|
|
11
|
-
}
|
|
12
|
-
catch (err) {
|
|
13
|
-
logger.warn(`Glob pattern resolution failed: ${errorMessage(err)}`);
|
|
14
|
-
return [];
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
|
|
18
|
-
/**
|
|
19
|
-
* Resolve all file entries to absolute paths.
|
|
20
|
-
* Skips entries that fail resolution and logs warnings.
|
|
21
|
-
*/
|
|
22
|
-
export function resolveFiles(config) {
|
|
23
|
-
const unique = uniq(resolveGlobs(config.paths));
|
|
24
|
-
return unique.filter((filePath) => {
|
|
25
|
-
try {
|
|
26
|
-
fs.accessSync(filePath, fs.constants.R_OK);
|
|
27
|
-
const stat = fs.statSync(filePath);
|
|
28
|
-
if (!stat.isFile()) {
|
|
29
|
-
logger.warn(`Not a regular file, skipping: ${filePath}`);
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
if (stat.size > LARGE_FILE_THRESHOLD) {
|
|
33
|
-
const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
|
|
34
|
-
logger.warn(`Large file (${sizeMB} MB), skipping: ${filePath}`);
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
return true;
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
logger.warn(`File not readable, skipping: ${filePath}`);
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
}
|