backdot 1.7.0 → 1.8.0
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/README.md +6 -0
- package/dist/cli.js +4 -2
- package/dist/commands/backup.js +66 -4
- package/dist/commands/history.js +9 -6
- package/dist/commands/init.js +3 -2
- package/dist/commands/restore.js +52 -31
- package/dist/commands/schedule.js +14 -0
- package/dist/commands/status.js +46 -11
- package/dist/commitUrl.js +5 -4
- package/dist/config.d.ts +2 -1
- package/dist/config.js +14 -12
- package/dist/crypto/encryption.d.ts +8 -0
- package/dist/crypto/encryption.js +54 -0
- package/dist/crypto/password.d.ts +11 -0
- package/dist/crypto/password.js +74 -0
- package/dist/crypto.d.ts +13 -0
- package/dist/crypto.js +129 -0
- package/dist/encryption.d.ts +2 -0
- package/dist/encryption.js +39 -0
- package/dist/git.d.ts +1 -1
- package/dist/git.js +38 -35
- package/dist/launchd.js +11 -8
- package/dist/password.d.ts +11 -0
- package/dist/password.js +74 -0
- package/dist/repoVisibility.d.ts +15 -0
- package/dist/repoVisibility.js +58 -0
- package/dist/resolveFiles.js +2 -2
- package/dist/staging.d.ts +9 -3
- package/dist/staging.js +84 -36
- package/package.json +1 -9
package/README.md
CHANGED
|
@@ -49,6 +49,12 @@ Prefix a pattern with `!` to exclude matching files:
|
|
|
49
49
|
}
|
|
50
50
|
```
|
|
51
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
|
+
|
|
52
58
|
## Commands
|
|
53
59
|
|
|
54
60
|
| Command | Description |
|
package/dist/cli.js
CHANGED
|
@@ -51,7 +51,8 @@ cli.command("", "").action(() => {
|
|
|
51
51
|
console.log(` No config found. Run ${chalk.bold("backdot init")} to get started.\n`);
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
|
-
|
|
54
|
+
// cac auto-adds a --help flag; remove its redundant section from help output
|
|
55
|
+
cli.help((sections) => sections.filter((s) => !s.body?.includes("--help")));
|
|
55
56
|
cli.version(getVersion());
|
|
56
57
|
async function main() {
|
|
57
58
|
try {
|
|
@@ -62,7 +63,8 @@ async function main() {
|
|
|
62
63
|
const msg = errorMessage(err);
|
|
63
64
|
logger.error(msg);
|
|
64
65
|
console.error(`\n Error: ${msg}\n`);
|
|
65
|
-
|
|
66
|
+
const isRunningInBackground = !process.stdout.isTTY;
|
|
67
|
+
if (isRunningInBackground) {
|
|
66
68
|
sendNotification("Backdot", `Backup failed: ${msg}`);
|
|
67
69
|
}
|
|
68
70
|
process.exit(1);
|
package/dist/commands/backup.js
CHANGED
|
@@ -1,17 +1,57 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import ora from "ora";
|
|
2
4
|
import { loadConfig, CONFIG_PATH } from "../config.js";
|
|
3
5
|
import { resolveFiles } from "../resolveFiles.js";
|
|
4
|
-
import { cleanStaging, copyToStaging, writeRepoReadme } from "../staging.js";
|
|
6
|
+
import { cleanStaging, copyToStaging, writeRepoReadme, machineDir } from "../staging.js";
|
|
5
7
|
import { gitPull, gitCommitAndPush } from "../git.js";
|
|
6
8
|
import { logger } from "../log.js";
|
|
7
9
|
import { pluralize } from "../utils.js";
|
|
10
|
+
import { checkRepoVisibility } from "../repoVisibility.js";
|
|
11
|
+
import { decrypt, deriveKey } from "../crypto/encryption.js";
|
|
12
|
+
import { resolvePassword, offerToSaveKeyFile, confirmPassword, ENC_SUFFIX, } from "../crypto/password.js";
|
|
13
|
+
function findEncryptedFile(dir) {
|
|
14
|
+
if (!fs.existsSync(dir)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
18
|
+
const fullPath = path.join(dir, entry.name);
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
const encryptedFile = findEncryptedFile(fullPath);
|
|
21
|
+
if (encryptedFile) {
|
|
22
|
+
return encryptedFile;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else if (entry.name.endsWith(ENC_SUFFIX)) {
|
|
26
|
+
return fullPath;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
8
31
|
export async function backup() {
|
|
9
32
|
logger.info("Starting backup");
|
|
10
33
|
const config = loadConfig();
|
|
11
34
|
logger.info(`Repository: ${config.repository}`);
|
|
12
35
|
logger.info(`Machine: ${config.machine}`);
|
|
13
|
-
|
|
36
|
+
let password;
|
|
37
|
+
let derivedKey;
|
|
38
|
+
let passwordWasInteractive = false;
|
|
39
|
+
if (config.encrypt) {
|
|
40
|
+
const result = await resolvePassword();
|
|
41
|
+
password = result.password;
|
|
42
|
+
passwordWasInteractive = result.interactive;
|
|
43
|
+
derivedKey = deriveKey(password);
|
|
44
|
+
}
|
|
45
|
+
const spinner = ora("Checking repository visibility").start();
|
|
14
46
|
try {
|
|
47
|
+
const visibility = await checkRepoVisibility(config.repository);
|
|
48
|
+
if (visibility === "public") {
|
|
49
|
+
spinner.fail("Backup refused — repository is public");
|
|
50
|
+
throw new Error(`Repository "${config.repository}" is publicly accessible.\n` +
|
|
51
|
+
"Backing up to a public repo would expose sensitive files.\n" +
|
|
52
|
+
"Make the repository private, then try again.");
|
|
53
|
+
}
|
|
54
|
+
spinner.text = "Resolving files";
|
|
15
55
|
const userFiles = resolveFiles(config);
|
|
16
56
|
logger.info(`Resolved ${pluralize(userFiles.length, "file")}`);
|
|
17
57
|
if (userFiles.length === 0) {
|
|
@@ -21,10 +61,29 @@ export async function backup() {
|
|
|
21
61
|
const files = [...userFiles, CONFIG_PATH];
|
|
22
62
|
spinner.text = "Syncing with remote";
|
|
23
63
|
await gitPull(config.repository);
|
|
64
|
+
if (derivedKey) {
|
|
65
|
+
const existingEncryptedFile = findEncryptedFile(machineDir(config.machine));
|
|
66
|
+
if (existingEncryptedFile) {
|
|
67
|
+
spinner.text = "Verifying encryption password";
|
|
68
|
+
const encryptedContent = fs.readFileSync(existingEncryptedFile);
|
|
69
|
+
try {
|
|
70
|
+
decrypt(encryptedContent, derivedKey);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
spinner.fail("Backup failed");
|
|
74
|
+
throw new Error("Password does not match the existing backup.");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else if (passwordWasInteractive) {
|
|
78
|
+
spinner.stop();
|
|
79
|
+
await confirmPassword(password);
|
|
80
|
+
spinner.start();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
24
83
|
spinner.text = `Copying ${pluralize(files.length, "file")} to staging`;
|
|
25
84
|
cleanStaging(config.machine);
|
|
26
|
-
copyToStaging(files, config.machine);
|
|
27
|
-
writeRepoReadme(config.repository);
|
|
85
|
+
copyToStaging(files, config.machine, derivedKey);
|
|
86
|
+
writeRepoReadme(config.repository, config.encrypt);
|
|
28
87
|
spinner.text = "Pushing to remote";
|
|
29
88
|
const result = await gitCommitAndPush();
|
|
30
89
|
const successMsg = result?.commitUrl
|
|
@@ -37,5 +96,8 @@ export async function backup() {
|
|
|
37
96
|
spinner.fail("Backup failed");
|
|
38
97
|
throw err;
|
|
39
98
|
}
|
|
99
|
+
if (password) {
|
|
100
|
+
await offerToSaveKeyFile(password);
|
|
101
|
+
}
|
|
40
102
|
logger.info("Backup complete");
|
|
41
103
|
}
|
package/dist/commands/history.js
CHANGED
|
@@ -22,14 +22,17 @@ export async function history(repoUrl) {
|
|
|
22
22
|
console.log("\n No backup history found.\n");
|
|
23
23
|
return;
|
|
24
24
|
}
|
|
25
|
-
const
|
|
25
|
+
const selectedCommitHash = await select({
|
|
26
26
|
message: "Select a backup to restore from:",
|
|
27
27
|
loop: false,
|
|
28
|
-
choices: commits.map((
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
choices: commits.map((commit) => {
|
|
29
|
+
const dateOnly = commit.date.split("T")[0];
|
|
30
|
+
return {
|
|
31
|
+
name: `${commit.hash.slice(0, 7)} ${dateOnly} ${commit.message}`,
|
|
32
|
+
value: commit.hash,
|
|
33
|
+
};
|
|
34
|
+
}),
|
|
32
35
|
});
|
|
33
36
|
console.log();
|
|
34
|
-
await restore({ repoUrl, commit:
|
|
37
|
+
await restore({ repoUrl, commit: selectedCommitHash });
|
|
35
38
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -17,7 +17,7 @@ function getMachineName() {
|
|
|
17
17
|
}
|
|
18
18
|
return os.hostname().replace(/\.(local|localdomain)$/, "");
|
|
19
19
|
}
|
|
20
|
-
const
|
|
20
|
+
const DEFAULT_CONFIG = {
|
|
21
21
|
repository: "git@github.com:USERNAME/backdot-backup.git",
|
|
22
22
|
machine: getMachineName(),
|
|
23
23
|
paths: ["~/.zshrc", "~/.gitconfig"],
|
|
@@ -42,10 +42,11 @@ export function init() {
|
|
|
42
42
|
console.log(` ${chalk.yellow(`${CONFIG_PATH} already exists — skipping creation.`)}`);
|
|
43
43
|
}
|
|
44
44
|
else {
|
|
45
|
-
fs.writeFileSync(CONFIG_PATH, JSON.stringify(
|
|
45
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
|
|
46
46
|
console.log(` Created ${CONFIG_PATH} with defaults.`);
|
|
47
47
|
}
|
|
48
48
|
console.log(" Open it and set your repository URL and files to back up.");
|
|
49
|
+
console.log(` To encrypt backups, add ${chalk.bold('"encrypt": true')} to the config.`);
|
|
49
50
|
console.log();
|
|
50
51
|
// Step 3
|
|
51
52
|
console.log(chalk.bold(" Step 3 — Run your first backup"));
|
package/dist/commands/restore.js
CHANGED
|
@@ -8,14 +8,16 @@ import { gitPull } from "../git.js";
|
|
|
8
8
|
import { STAGING_DIR, machineDir } from "../staging.js";
|
|
9
9
|
import { logger } from "../log.js";
|
|
10
10
|
import { pluralize } from "../utils.js";
|
|
11
|
+
import { decrypt, deriveKey } from "../crypto/encryption.js";
|
|
12
|
+
import { resolvePassword, offerToSaveKeyFile, ENC_SUFFIX } from "../crypto/password.js";
|
|
11
13
|
const HOME = os.homedir();
|
|
12
|
-
function
|
|
14
|
+
function listFilesRecursively(dir) {
|
|
13
15
|
return fs
|
|
14
16
|
.readdirSync(dir, { withFileTypes: true })
|
|
15
17
|
.filter((entry) => entry.name !== ".git")
|
|
16
18
|
.flatMap((entry) => {
|
|
17
|
-
const
|
|
18
|
-
return entry.isDirectory() ?
|
|
19
|
+
const fullPath = path.join(dir, entry.name);
|
|
20
|
+
return entry.isDirectory() ? listFilesRecursively(fullPath) : [fullPath];
|
|
19
21
|
});
|
|
20
22
|
}
|
|
21
23
|
function listMachines() {
|
|
@@ -24,8 +26,8 @@ function listMachines() {
|
|
|
24
26
|
}
|
|
25
27
|
return fs
|
|
26
28
|
.readdirSync(STAGING_DIR, { withFileTypes: true })
|
|
27
|
-
.filter((
|
|
28
|
-
.map((
|
|
29
|
+
.filter((entry) => entry.isDirectory() && entry.name !== ".git")
|
|
30
|
+
.map((entry) => entry.name);
|
|
29
31
|
}
|
|
30
32
|
async function resolveRepoAndMachine(repoUrl, commit) {
|
|
31
33
|
if (!repoUrl) {
|
|
@@ -85,61 +87,80 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
|
|
|
85
87
|
}
|
|
86
88
|
return;
|
|
87
89
|
}
|
|
88
|
-
const stagedFiles =
|
|
90
|
+
const stagedFiles = listFilesRecursively(baseDir);
|
|
89
91
|
logger.info(`Found ${pluralize(stagedFiles.length, "file")} in backup repository`);
|
|
90
92
|
if (stagedFiles.length === 0) {
|
|
91
93
|
spinner.stop();
|
|
92
94
|
console.log("No files found in backup repository.");
|
|
93
95
|
return;
|
|
94
96
|
}
|
|
95
|
-
const fileMappings = stagedFiles.map((
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
const fileMappings = stagedFiles.map((stagedFilePath) => {
|
|
98
|
+
let relativePath = path.relative(baseDir, stagedFilePath);
|
|
99
|
+
if (relativePath.endsWith(ENC_SUFFIX)) {
|
|
100
|
+
relativePath = relativePath.slice(0, -ENC_SUFFIX.length);
|
|
101
|
+
}
|
|
102
|
+
return { src: stagedFilePath, dest: path.join(HOME, relativePath), rel: relativePath };
|
|
98
103
|
});
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
logger.info(`${
|
|
104
|
+
const filesAlreadyOnDisk = fileMappings.filter((file) => fs.existsSync(file.dest));
|
|
105
|
+
const newFiles = fileMappings.filter((file) => !fs.existsSync(file.dest));
|
|
106
|
+
logger.info(`${newFiles.length} new, ${filesAlreadyOnDisk.length} already exist`);
|
|
102
107
|
spinner.stop();
|
|
103
108
|
console.log();
|
|
104
|
-
let
|
|
109
|
+
let filesToRestore;
|
|
105
110
|
if (yes) {
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
console.log(` Skipped ${pluralize(
|
|
111
|
+
filesToRestore = newFiles;
|
|
112
|
+
if (filesAlreadyOnDisk.length > 0) {
|
|
113
|
+
console.log(` Skipped ${pluralize(filesAlreadyOnDisk.length, "existing file")}. Run without --yes to select them.`);
|
|
109
114
|
console.log();
|
|
110
115
|
}
|
|
111
116
|
}
|
|
112
117
|
else {
|
|
113
118
|
const choices = [];
|
|
114
|
-
if (
|
|
115
|
-
choices.push(new Separator(`── New files (${
|
|
116
|
-
for (const
|
|
117
|
-
choices.push({ name:
|
|
119
|
+
if (newFiles.length > 0) {
|
|
120
|
+
choices.push(new Separator(`── New files (${newFiles.length}) ──`));
|
|
121
|
+
for (const file of newFiles) {
|
|
122
|
+
choices.push({ name: file.rel, value: file, checked: true });
|
|
118
123
|
}
|
|
119
124
|
}
|
|
120
|
-
if (
|
|
121
|
-
choices.push(new Separator(`── Existing files — will overwrite (${
|
|
122
|
-
for (const
|
|
123
|
-
choices.push({ name:
|
|
125
|
+
if (filesAlreadyOnDisk.length > 0) {
|
|
126
|
+
choices.push(new Separator(`── Existing files — will overwrite (${filesAlreadyOnDisk.length}) ──`));
|
|
127
|
+
for (const file of filesAlreadyOnDisk) {
|
|
128
|
+
choices.push({ name: file.rel, value: file, checked: false });
|
|
124
129
|
}
|
|
125
130
|
}
|
|
126
|
-
|
|
131
|
+
filesToRestore = await checkbox({
|
|
127
132
|
message: "Select files to restore:",
|
|
128
133
|
loop: false,
|
|
129
134
|
choices,
|
|
130
135
|
});
|
|
131
136
|
console.log();
|
|
132
137
|
}
|
|
133
|
-
if (
|
|
138
|
+
if (filesToRestore.length === 0) {
|
|
134
139
|
console.log("No files selected for restore.");
|
|
135
140
|
return;
|
|
136
141
|
}
|
|
137
|
-
|
|
138
|
-
|
|
142
|
+
let password;
|
|
143
|
+
const hasEncryptedFiles = filesToRestore.some(({ src }) => src.endsWith(ENC_SUFFIX));
|
|
144
|
+
if (hasEncryptedFiles) {
|
|
145
|
+
const result = await resolvePassword();
|
|
146
|
+
password = result.password;
|
|
147
|
+
}
|
|
148
|
+
const derivedKey = password ? deriveKey(password) : undefined;
|
|
149
|
+
const restoreSpinner = ora("Restoring files").start();
|
|
150
|
+
for (const { src, dest } of filesToRestore) {
|
|
139
151
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
140
|
-
|
|
152
|
+
if (derivedKey && src.endsWith(ENC_SUFFIX)) {
|
|
153
|
+
const content = fs.readFileSync(src);
|
|
154
|
+
fs.writeFileSync(dest, decrypt(content, derivedKey));
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
fs.copyFileSync(src, dest);
|
|
158
|
+
}
|
|
141
159
|
}
|
|
142
|
-
|
|
160
|
+
restoreSpinner.succeed(`Restored ${pluralize(filesToRestore.length, "file")}`);
|
|
143
161
|
console.log();
|
|
144
|
-
|
|
162
|
+
if (password) {
|
|
163
|
+
await offerToSaveKeyFile(password);
|
|
164
|
+
}
|
|
165
|
+
logger.info(`Restored ${pluralize(filesToRestore.length, "file")}`);
|
|
145
166
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import chalk from "chalk";
|
|
1
3
|
import { setupLaunchd, uninstallLaunchd } from "../launchd.js";
|
|
4
|
+
import { loadConfig } from "../config.js";
|
|
5
|
+
import { KEY_FILE_PATH } from "../crypto/password.js";
|
|
2
6
|
function requireMacOS() {
|
|
3
7
|
if (process.platform !== "darwin") {
|
|
4
8
|
throw new Error("Scheduling is only supported on macOS (launchd). Use cron or systemd on Linux.");
|
|
@@ -7,6 +11,16 @@ function requireMacOS() {
|
|
|
7
11
|
export function schedule() {
|
|
8
12
|
requireMacOS();
|
|
9
13
|
setupLaunchd();
|
|
14
|
+
try {
|
|
15
|
+
const config = loadConfig();
|
|
16
|
+
if (config.encrypt && !fs.existsSync(KEY_FILE_PATH)) {
|
|
17
|
+
console.log(chalk.yellow(` Encryption is enabled. Run ${chalk.bold("backdot backup")} once to create ~/.backdot.key,\n` +
|
|
18
|
+
` or set ${chalk.bold("BACKDOT_PASSWORD")} in your environment.\n`));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Config may not exist yet; ignore
|
|
23
|
+
}
|
|
10
24
|
}
|
|
11
25
|
export function unschedule() {
|
|
12
26
|
requireMacOS();
|
package/dist/commands/status.js
CHANGED
|
@@ -6,17 +6,47 @@ import { resolveFiles } from "../resolveFiles.js";
|
|
|
6
6
|
import { compareFiles } from "../staging.js";
|
|
7
7
|
import { isScheduled } from "../launchd.js";
|
|
8
8
|
import { pluralize } from "../utils.js";
|
|
9
|
-
|
|
9
|
+
import { checkRepoVisibility } from "../repoVisibility.js";
|
|
10
|
+
import { deriveKey } from "../crypto/encryption.js";
|
|
11
|
+
import { resolvePassword } from "../crypto/password.js";
|
|
12
|
+
function abbreviateHomePath(filePath) {
|
|
10
13
|
const home = os.homedir();
|
|
11
14
|
return filePath.startsWith(home) ? "~" + filePath.slice(home.length) : filePath;
|
|
12
15
|
}
|
|
16
|
+
function formatVisibility(visibility) {
|
|
17
|
+
switch (visibility) {
|
|
18
|
+
case "public":
|
|
19
|
+
return chalk.red.bold("public (backup disabled)");
|
|
20
|
+
case "private":
|
|
21
|
+
return chalk.green("private");
|
|
22
|
+
case "unknown":
|
|
23
|
+
return chalk.yellow("unknown (could not verify)");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
13
26
|
export async function status() {
|
|
14
27
|
const scheduled = isScheduled();
|
|
15
28
|
console.log();
|
|
16
|
-
console.log(` Schedule:
|
|
29
|
+
console.log(` Schedule: ${scheduled ? "active (daily at 02:00)" : `not active (run ${chalk.bold("backdot schedule")} to enable)`}`);
|
|
17
30
|
const config = loadConfig();
|
|
18
|
-
console.log(` Repo:
|
|
19
|
-
console.log(` Machine:
|
|
31
|
+
console.log(` Repo: ${config.repository}`);
|
|
32
|
+
console.log(` Machine: ${config.machine}`);
|
|
33
|
+
if (config.encrypt) {
|
|
34
|
+
console.log(` Encryption: ${chalk.green("enabled")}`);
|
|
35
|
+
}
|
|
36
|
+
const visibilitySpinner = ora("Checking repository visibility").start();
|
|
37
|
+
const visibility = await checkRepoVisibility(config.repository);
|
|
38
|
+
visibilitySpinner.stop();
|
|
39
|
+
if (visibility !== "private") {
|
|
40
|
+
console.log(` Visibility: ${formatVisibility(visibility)}`);
|
|
41
|
+
}
|
|
42
|
+
if (visibility === "public") {
|
|
43
|
+
console.log();
|
|
44
|
+
console.log(chalk.red(" ⚠ Repository is public. Backup is disabled to prevent leaking sensitive files.\n" +
|
|
45
|
+
" Make the repository private, then try again."));
|
|
46
|
+
console.log();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const derivedKey = config.encrypt ? deriveKey((await resolvePassword()).password) : undefined;
|
|
20
50
|
console.log();
|
|
21
51
|
const spinner = ora("Resolving files").start();
|
|
22
52
|
try {
|
|
@@ -28,7 +58,12 @@ export async function status() {
|
|
|
28
58
|
}
|
|
29
59
|
const files = [...userFiles, CONFIG_PATH];
|
|
30
60
|
spinner.text = "Comparing with remote backup";
|
|
31
|
-
const { backedUp, modified, notBackedUp, error } = await compareFiles(
|
|
61
|
+
const { backedUp, modified, notBackedUp, error } = await compareFiles({
|
|
62
|
+
files,
|
|
63
|
+
machine: config.machine,
|
|
64
|
+
repository: config.repository,
|
|
65
|
+
derivedKey,
|
|
66
|
+
});
|
|
32
67
|
spinner.stop();
|
|
33
68
|
if (error) {
|
|
34
69
|
console.log(chalk.yellow(` Could not fetch status: ${error}`));
|
|
@@ -40,22 +75,22 @@ export async function status() {
|
|
|
40
75
|
else {
|
|
41
76
|
if (modified.length > 0) {
|
|
42
77
|
console.log(chalk.yellow(` Modified since last backup (${modified.length}):`));
|
|
43
|
-
for (const
|
|
44
|
-
console.log(` ${
|
|
78
|
+
for (const filePath of modified) {
|
|
79
|
+
console.log(` ${abbreviateHomePath(filePath)}`);
|
|
45
80
|
}
|
|
46
81
|
console.log();
|
|
47
82
|
}
|
|
48
83
|
if (notBackedUp.length > 0) {
|
|
49
84
|
console.log(chalk.red(` Not yet backed up (${notBackedUp.length}):`));
|
|
50
|
-
for (const
|
|
51
|
-
console.log(` ${
|
|
85
|
+
for (const filePath of notBackedUp) {
|
|
86
|
+
console.log(` ${abbreviateHomePath(filePath)}`);
|
|
52
87
|
}
|
|
53
88
|
console.log();
|
|
54
89
|
}
|
|
55
90
|
if (backedUp.length > 0) {
|
|
56
91
|
console.log(chalk.green(` Backed up (${backedUp.length}):`));
|
|
57
|
-
for (const
|
|
58
|
-
console.log(` ${
|
|
92
|
+
for (const filePath of backedUp) {
|
|
93
|
+
console.log(` ${abbreviateHomePath(filePath)}`);
|
|
59
94
|
}
|
|
60
95
|
console.log();
|
|
61
96
|
}
|
package/dist/commitUrl.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const PROVIDERS = {
|
|
2
|
-
"github.com": (
|
|
3
|
-
"gitlab.com": (
|
|
4
|
-
"bitbucket.org": (
|
|
2
|
+
"github.com": (repoPath, sha) => `https://github.com/${repoPath}/commit/${sha}`,
|
|
3
|
+
"gitlab.com": (repoPath, sha) => `https://gitlab.com/${repoPath}/-/commit/${sha}`,
|
|
4
|
+
"bitbucket.org": (repoPath, sha) => `https://bitbucket.org/${repoPath}/commits/${sha}`,
|
|
5
5
|
};
|
|
6
6
|
export function getCommitUrl(remoteUrl, sha) {
|
|
7
7
|
for (const [host, buildUrl] of Object.entries(PROVIDERS)) {
|
|
@@ -9,7 +9,8 @@ export function getCommitUrl(remoteUrl, sha) {
|
|
|
9
9
|
if (idx === -1) {
|
|
10
10
|
continue;
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
const separatorLength = 1; // skip the ":" (SSH) or "/" (HTTPS) after the hostname
|
|
13
|
+
let repoPath = remoteUrl.slice(idx + host.length + separatorLength).trim();
|
|
13
14
|
if (repoPath.endsWith(".git")) {
|
|
14
15
|
repoPath = repoPath.slice(0, -4);
|
|
15
16
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export declare const CONFIG_PATH: string;
|
|
3
|
-
export declare function expandTilde(
|
|
3
|
+
export declare function expandTilde(pattern: string): string;
|
|
4
4
|
declare const ConfigSchema: z.ZodObject<{
|
|
5
5
|
repository: z.ZodString;
|
|
6
6
|
machine: z.ZodString;
|
|
7
7
|
paths: z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
|
|
8
|
+
encrypt: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
8
9
|
}, z.core.$strip>;
|
|
9
10
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
10
11
|
export declare function loadConfig(): Config;
|
package/dist/config.js
CHANGED
|
@@ -3,14 +3,15 @@ import path from "node:path";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
export const CONFIG_PATH = path.join(os.homedir(), ".backdot.json");
|
|
6
|
-
export function expandTilde(
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
export function expandTilde(pattern) {
|
|
7
|
+
// fast-glob uses "!" for negation patterns — preserve the prefix, expand the rest
|
|
8
|
+
if (pattern.startsWith("!")) {
|
|
9
|
+
return "!" + expandTilde(pattern.slice(1));
|
|
9
10
|
}
|
|
10
|
-
if (
|
|
11
|
-
return path.join(os.homedir(),
|
|
11
|
+
if (pattern.startsWith("~/") || pattern === "~") {
|
|
12
|
+
return path.join(os.homedir(), pattern.slice(1));
|
|
12
13
|
}
|
|
13
|
-
return
|
|
14
|
+
return pattern;
|
|
14
15
|
}
|
|
15
16
|
const ConfigSchema = z.object({
|
|
16
17
|
repository: z.string().min(1),
|
|
@@ -18,30 +19,31 @@ const ConfigSchema = z.object({
|
|
|
18
19
|
paths: z
|
|
19
20
|
.array(z.string().min(1).transform(expandTilde))
|
|
20
21
|
.min(1, '"paths" must be a non-empty array'),
|
|
22
|
+
encrypt: z.boolean().optional().default(false),
|
|
21
23
|
});
|
|
22
24
|
export function loadConfig() {
|
|
23
25
|
if (!fs.existsSync(CONFIG_PATH)) {
|
|
24
26
|
throw new Error(`Config file not found: ${CONFIG_PATH}\n Run "backdot init" to create it.`);
|
|
25
27
|
}
|
|
26
|
-
let
|
|
28
|
+
let rawJson;
|
|
27
29
|
try {
|
|
28
|
-
|
|
30
|
+
rawJson = fs.readFileSync(CONFIG_PATH, "utf-8");
|
|
29
31
|
}
|
|
30
32
|
catch {
|
|
31
33
|
throw new Error(`Failed to read config file: ${CONFIG_PATH}`);
|
|
32
34
|
}
|
|
33
35
|
let parsed;
|
|
34
36
|
try {
|
|
35
|
-
parsed = JSON.parse(
|
|
37
|
+
parsed = JSON.parse(rawJson);
|
|
36
38
|
}
|
|
37
39
|
catch {
|
|
38
40
|
throw new Error(`Invalid JSON in config file: ${CONFIG_PATH}`);
|
|
39
41
|
}
|
|
40
42
|
const result = ConfigSchema.safeParse(parsed);
|
|
41
43
|
if (!result.success) {
|
|
42
|
-
const messages = result.error.issues.map((
|
|
43
|
-
const
|
|
44
|
-
return ` - ${
|
|
44
|
+
const messages = result.error.issues.map((issue) => {
|
|
45
|
+
const field = issue.path.length > 0 ? `"${issue.path.join(".")}"` : "config";
|
|
46
|
+
return ` - ${field}: ${issue.message}`;
|
|
45
47
|
});
|
|
46
48
|
throw new Error(`Invalid config in ${CONFIG_PATH}:\n${messages.join("\n")}`);
|
|
47
49
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface DerivedKey {
|
|
2
|
+
password: string;
|
|
3
|
+
salt: Buffer;
|
|
4
|
+
key: Buffer;
|
|
5
|
+
}
|
|
6
|
+
export declare function deriveKey(password: string, salt?: Buffer): DerivedKey;
|
|
7
|
+
export declare function encrypt(plaintext: Buffer, derivedKey: DerivedKey): Buffer;
|
|
8
|
+
export declare function decrypt(encrypted: Buffer, derivedKey: DerivedKey): Buffer;
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
// Higher N = harder to brute-force but slower to encrypt/decrypt (~300ms).
|
|
8
|
+
// 2^17 is the OWASP minimum recommendation for password-based key derivation.
|
|
9
|
+
const SCRYPT_N = 2 ** 17;
|
|
10
|
+
const SCRYPT_R = 8;
|
|
11
|
+
const SCRYPT_PARAMS = {
|
|
12
|
+
N: SCRYPT_N,
|
|
13
|
+
r: SCRYPT_R,
|
|
14
|
+
p: 1,
|
|
15
|
+
maxmem: 128 * SCRYPT_N * SCRYPT_R * 2, // must exceed 128*N*r
|
|
16
|
+
};
|
|
17
|
+
const OVERHEAD = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;
|
|
18
|
+
export function deriveKey(password, salt) {
|
|
19
|
+
const resolvedSalt = salt ?? crypto.randomBytes(SALT_LENGTH);
|
|
20
|
+
const key = crypto.scryptSync(password, resolvedSalt, KEY_LENGTH, SCRYPT_PARAMS);
|
|
21
|
+
return { password, salt: resolvedSalt, key };
|
|
22
|
+
}
|
|
23
|
+
function deriveKeyForSalt(derivedKey, salt) {
|
|
24
|
+
if (derivedKey.salt.equals(salt)) {
|
|
25
|
+
return derivedKey.key;
|
|
26
|
+
}
|
|
27
|
+
return crypto.scryptSync(derivedKey.password, salt, KEY_LENGTH, SCRYPT_PARAMS);
|
|
28
|
+
}
|
|
29
|
+
export function encrypt(plaintext, derivedKey) {
|
|
30
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
31
|
+
const cipher = crypto.createCipheriv(ALGORITHM, derivedKey.key, iv);
|
|
32
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
33
|
+
const authTag = cipher.getAuthTag();
|
|
34
|
+
return Buffer.concat([derivedKey.salt, iv, authTag, ciphertext]);
|
|
35
|
+
}
|
|
36
|
+
export function decrypt(encrypted, derivedKey) {
|
|
37
|
+
if (encrypted.length < OVERHEAD) {
|
|
38
|
+
throw new Error("Data is too short to be encrypted content.");
|
|
39
|
+
}
|
|
40
|
+
let offset = 0;
|
|
41
|
+
const salt = encrypted.subarray(offset, (offset += SALT_LENGTH));
|
|
42
|
+
const iv = encrypted.subarray(offset, (offset += IV_LENGTH));
|
|
43
|
+
const authTag = encrypted.subarray(offset, (offset += AUTH_TAG_LENGTH));
|
|
44
|
+
const ciphertext = encrypted.subarray(offset);
|
|
45
|
+
const key = deriveKeyForSalt(derivedKey, salt);
|
|
46
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
47
|
+
decipher.setAuthTag(authTag);
|
|
48
|
+
try {
|
|
49
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new Error("Decryption failed — wrong password or corrupted data.");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
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>;
|