backdot 1.7.0 → 1.8.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/README.md +6 -0
- package/dist/cli.js +6 -4
- package/dist/commands/backup.js +75 -4
- package/dist/commands/history.js +9 -6
- package/dist/commands/init.js +3 -2
- package/dist/commands/restore.js +60 -39
- package/dist/commands/schedule.js +14 -0
- package/dist/commands/status.js +46 -11
- package/dist/commitUrl.js +12 -14
- package/dist/config.d.ts +2 -1
- package/dist/config.js +16 -14
- package/dist/crypto/encryption.d.ts +9 -0
- package/dist/crypto/encryption.js +57 -0
- package/dist/crypto/password.d.ts +12 -0
- package/dist/crypto/password.js +78 -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 +2 -2
- package/dist/git.js +43 -41
- package/dist/launchd.js +13 -10
- package/dist/notify.js +3 -3
- package/dist/password.d.ts +11 -0
- package/dist/password.js +74 -0
- package/dist/repoVisibility.d.ts +15 -0
- package/dist/repoVisibility.js +51 -0
- package/dist/resolveFiles.js +2 -2
- package/dist/staging.d.ts +9 -3
- package/dist/staging.js +77 -38
- package/dist/utils.d.ts +8 -0
- package/dist/utils.js +20 -0
- package/package.json +9 -17
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
|
@@ -16,8 +16,8 @@ import { sendNotification } from "./notify.js";
|
|
|
16
16
|
function getVersion() {
|
|
17
17
|
const pkgPath = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../package.json");
|
|
18
18
|
try {
|
|
19
|
-
const
|
|
20
|
-
return
|
|
19
|
+
const packageJson = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
20
|
+
return packageJson.version ?? "unknown";
|
|
21
21
|
}
|
|
22
22
|
catch {
|
|
23
23
|
return "unknown";
|
|
@@ -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,59 @@
|
|
|
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 { confirm } from "@inquirer/prompts";
|
|
12
|
+
import { decrypt, deriveKey } from "../crypto/encryption.js";
|
|
13
|
+
import { resolvePassword, offerToSaveKeyFile, confirmPassword, ENC_SUFFIX, } from "../crypto/password.js";
|
|
14
|
+
function findEncryptedFile(dir) {
|
|
15
|
+
if (!fs.existsSync(dir)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
19
|
+
const fullPath = path.join(dir, entry.name);
|
|
20
|
+
if (entry.isDirectory()) {
|
|
21
|
+
const encryptedFile = findEncryptedFile(fullPath);
|
|
22
|
+
if (encryptedFile) {
|
|
23
|
+
return encryptedFile;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else if (entry.name.endsWith(ENC_SUFFIX)) {
|
|
27
|
+
return fullPath;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
8
32
|
export async function backup() {
|
|
9
33
|
logger.info("Starting backup");
|
|
10
34
|
const config = loadConfig();
|
|
11
35
|
logger.info(`Repository: ${config.repository}`);
|
|
12
36
|
logger.info(`Machine: ${config.machine}`);
|
|
13
|
-
|
|
37
|
+
let password;
|
|
38
|
+
let derivedKey;
|
|
39
|
+
if (config.encrypt) {
|
|
40
|
+
const result = await resolvePassword();
|
|
41
|
+
password = result.password;
|
|
42
|
+
if (result.source === "prompt") {
|
|
43
|
+
await confirmPassword(password);
|
|
44
|
+
}
|
|
45
|
+
derivedKey = deriveKey(password);
|
|
46
|
+
}
|
|
47
|
+
const spinner = ora("Checking repository visibility").start();
|
|
14
48
|
try {
|
|
49
|
+
const visibility = await checkRepoVisibility(config.repository);
|
|
50
|
+
if (visibility === "public") {
|
|
51
|
+
spinner.fail("Backup refused — repository is public");
|
|
52
|
+
throw new Error(`Repository "${config.repository}" is publicly accessible.\n` +
|
|
53
|
+
"Backing up to a public repo would expose sensitive files.\n" +
|
|
54
|
+
"Make the repository private, then try again.");
|
|
55
|
+
}
|
|
56
|
+
spinner.text = "Resolving files";
|
|
15
57
|
const userFiles = resolveFiles(config);
|
|
16
58
|
logger.info(`Resolved ${pluralize(userFiles.length, "file")}`);
|
|
17
59
|
if (userFiles.length === 0) {
|
|
@@ -21,10 +63,36 @@ export async function backup() {
|
|
|
21
63
|
const files = [...userFiles, CONFIG_PATH];
|
|
22
64
|
spinner.text = "Syncing with remote";
|
|
23
65
|
await gitPull(config.repository);
|
|
66
|
+
if (derivedKey) {
|
|
67
|
+
const existingEncryptedFile = findEncryptedFile(machineDir(config.machine));
|
|
68
|
+
if (existingEncryptedFile) {
|
|
69
|
+
spinner.text = "Verifying encryption password";
|
|
70
|
+
const encryptedContent = fs.readFileSync(existingEncryptedFile);
|
|
71
|
+
try {
|
|
72
|
+
decrypt(encryptedContent, derivedKey);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
if (!process.stdin.isTTY) {
|
|
76
|
+
spinner.fail("Backup failed");
|
|
77
|
+
throw new Error("Password does not match the existing backup.\n" +
|
|
78
|
+
"Run interactively to re-encrypt with a new password.");
|
|
79
|
+
}
|
|
80
|
+
spinner.stop();
|
|
81
|
+
const shouldReEncrypt = await confirm({
|
|
82
|
+
message: "Password does not match the existing backup. Re-encrypt all files with the new password?",
|
|
83
|
+
default: false,
|
|
84
|
+
});
|
|
85
|
+
if (!shouldReEncrypt) {
|
|
86
|
+
throw new Error("Backup aborted.");
|
|
87
|
+
}
|
|
88
|
+
spinner.start();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
24
92
|
spinner.text = `Copying ${pluralize(files.length, "file")} to staging`;
|
|
25
93
|
cleanStaging(config.machine);
|
|
26
|
-
copyToStaging(files, config.machine);
|
|
27
|
-
writeRepoReadme(config.repository);
|
|
94
|
+
copyToStaging(files, config.machine, derivedKey);
|
|
95
|
+
writeRepoReadme(config.repository, config.encrypt);
|
|
28
96
|
spinner.text = "Pushing to remote";
|
|
29
97
|
const result = await gitCommitAndPush();
|
|
30
98
|
const successMsg = result?.commitUrl
|
|
@@ -37,5 +105,8 @@ export async function backup() {
|
|
|
37
105
|
spinner.fail("Backup failed");
|
|
38
106
|
throw err;
|
|
39
107
|
}
|
|
108
|
+
if (password) {
|
|
109
|
+
await offerToSaveKeyFile(password);
|
|
110
|
+
}
|
|
40
111
|
logger.info("Backup complete");
|
|
41
112
|
}
|
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) {
|
|
@@ -53,7 +55,7 @@ async function resolveRepoAndMachine(repoUrl, commit) {
|
|
|
53
55
|
machine = await select({
|
|
54
56
|
message: "Multiple machines found. Which one do you want to restore?",
|
|
55
57
|
loop: false,
|
|
56
|
-
choices: machines.map((
|
|
58
|
+
choices: machines.map((machine) => ({ name: machine, value: machine })),
|
|
57
59
|
});
|
|
58
60
|
}
|
|
59
61
|
return { repository: repoUrl, machine };
|
|
@@ -62,7 +64,7 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
|
|
|
62
64
|
logger.info("Starting restore");
|
|
63
65
|
const { repository, machine } = await resolveRepoAndMachine(repoUrl, commit);
|
|
64
66
|
const spinner = ora("Fetching latest backup").start();
|
|
65
|
-
const
|
|
67
|
+
const machineStagingDir = machineDir(machine);
|
|
66
68
|
try {
|
|
67
69
|
if (!repoUrl) {
|
|
68
70
|
await gitPull(repository, commit);
|
|
@@ -73,73 +75,92 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
|
|
|
73
75
|
throw err;
|
|
74
76
|
}
|
|
75
77
|
spinner.text = "Resolving files";
|
|
76
|
-
if (!fs.existsSync(
|
|
78
|
+
if (!fs.existsSync(machineStagingDir)) {
|
|
77
79
|
spinner.stop();
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
+
const availableMachines = listMachines();
|
|
81
|
+
if (availableMachines.length > 0) {
|
|
80
82
|
console.log(`\n No backup found for machine "${machine}".`);
|
|
81
|
-
console.log(` Available machines: ${
|
|
83
|
+
console.log(` Available machines: ${availableMachines.join(", ")}\n`);
|
|
82
84
|
}
|
|
83
85
|
else {
|
|
84
86
|
console.log(`\n No backup found for machine "${machine}". The repository is empty.\n`);
|
|
85
87
|
}
|
|
86
88
|
return;
|
|
87
89
|
}
|
|
88
|
-
const
|
|
89
|
-
logger.info(`Found ${pluralize(
|
|
90
|
-
if (
|
|
90
|
+
const backupFiles = listFilesRecursively(machineStagingDir);
|
|
91
|
+
logger.info(`Found ${pluralize(backupFiles.length, "file")} in backup repository`);
|
|
92
|
+
if (backupFiles.length === 0) {
|
|
91
93
|
spinner.stop();
|
|
92
94
|
console.log("No files found in backup repository.");
|
|
93
95
|
return;
|
|
94
96
|
}
|
|
95
|
-
const fileMappings =
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
const fileMappings = backupFiles.map((backupFilePath) => {
|
|
98
|
+
let relativePath = path.relative(machineStagingDir, backupFilePath);
|
|
99
|
+
if (relativePath.endsWith(ENC_SUFFIX)) {
|
|
100
|
+
relativePath = relativePath.slice(0, -ENC_SUFFIX.length);
|
|
101
|
+
}
|
|
102
|
+
return { src: backupFilePath, dest: path.join(HOME, relativePath), 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.relativePath, 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.relativePath, 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 ${KEY_FILE_PATH},\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,19 +1,17 @@
|
|
|
1
|
+
import { extractRepoPath } from "./utils.js";
|
|
1
2
|
const PROVIDERS = {
|
|
2
|
-
"github.com": (
|
|
3
|
-
"gitlab.com": (
|
|
4
|
-
"bitbucket.org": (
|
|
3
|
+
"github.com": (repoPath, sha) => `https://github.com/${repoPath}/commit/${sha}`,
|
|
4
|
+
"gitlab.com": (repoPath, sha) => `https://gitlab.com/${repoPath}/-/commit/${sha}`,
|
|
5
|
+
"bitbucket.org": (repoPath, sha) => `https://bitbucket.org/${repoPath}/commits/${sha}`,
|
|
5
6
|
};
|
|
6
7
|
export function getCommitUrl(remoteUrl, sha) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
continue;
|
|
11
|
-
}
|
|
12
|
-
let repoPath = remoteUrl.slice(idx + host.length + 1).trim();
|
|
13
|
-
if (repoPath.endsWith(".git")) {
|
|
14
|
-
repoPath = repoPath.slice(0, -4);
|
|
15
|
-
}
|
|
16
|
-
return buildUrl(repoPath, sha);
|
|
8
|
+
const parsed = extractRepoPath(remoteUrl);
|
|
9
|
+
if (!parsed) {
|
|
10
|
+
return null;
|
|
17
11
|
}
|
|
18
|
-
|
|
12
|
+
const buildUrl = PROVIDERS[parsed.host];
|
|
13
|
+
if (!buildUrl) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return buildUrl(parsed.repoPath, sha);
|
|
19
17
|
}
|
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
|
-
let
|
|
35
|
+
let parsedJson;
|
|
34
36
|
try {
|
|
35
|
-
|
|
37
|
+
parsedJson = JSON.parse(rawJson);
|
|
36
38
|
}
|
|
37
39
|
catch {
|
|
38
40
|
throw new Error(`Invalid JSON in config file: ${CONFIG_PATH}`);
|
|
39
41
|
}
|
|
40
|
-
const result = ConfigSchema.safeParse(
|
|
42
|
+
const result = ConfigSchema.safeParse(parsedJson);
|
|
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,9 @@
|
|
|
1
|
+
/** An encryption key derived from the user's password via scrypt. */
|
|
2
|
+
export interface DerivedKey {
|
|
3
|
+
passwordHash: string;
|
|
4
|
+
salt: Buffer;
|
|
5
|
+
key: Buffer;
|
|
6
|
+
}
|
|
7
|
+
export declare function deriveKey(passwordHash: string, salt?: Buffer): DerivedKey;
|
|
8
|
+
export declare function encrypt(plaintext: Buffer, derivedKey: DerivedKey): Buffer;
|
|
9
|
+
export declare function decrypt(encryptedPayload: Buffer, derivedKey: DerivedKey): Buffer;
|