backdot 1.8.0 → 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 +19 -6
- package/dist/commands/backup.js +23 -9
- package/dist/commands/init.js +2 -0
- package/dist/commands/restore.d.ts +3 -2
- package/dist/commands/restore.js +54 -32
- package/dist/commands/schedule.js +1 -1
- package/dist/commands/status.js +16 -4
- package/dist/commitUrl.js +9 -12
- package/dist/config.d.ts +2 -2
- package/dist/config.js +5 -4
- package/dist/crypto/encryption.d.ts +4 -3
- package/dist/crypto/encryption.js +13 -10
- package/dist/crypto/password.d.ts +6 -3
- package/dist/crypto/password.js +13 -10
- package/dist/git.d.ts +1 -1
- package/dist/git.js +6 -7
- package/dist/launchd.js +7 -8
- package/dist/log.js +2 -5
- package/dist/notify.js +3 -3
- 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 +11 -13
- package/dist/staging.d.ts +15 -1
- package/dist/staging.js +74 -46
- package/dist/utils.d.ts +8 -0
- package/dist/utils.js +20 -0
- package/package.json +21 -21
- 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
|
@@ -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";
|
|
@@ -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,12 +2,14 @@ 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";
|
|
8
9
|
import { logger } from "../log.js";
|
|
9
10
|
import { pluralize } from "../utils.js";
|
|
10
11
|
import { checkRepoVisibility } from "../repoVisibility.js";
|
|
12
|
+
import { confirm } from "@inquirer/prompts";
|
|
11
13
|
import { decrypt, deriveKey } from "../crypto/encryption.js";
|
|
12
14
|
import { resolvePassword, offerToSaveKeyFile, confirmPassword, ENC_SUFFIX, } from "../crypto/password.js";
|
|
13
15
|
function findEncryptedFile(dir) {
|
|
@@ -35,11 +37,12 @@ export async function backup() {
|
|
|
35
37
|
logger.info(`Machine: ${config.machine}`);
|
|
36
38
|
let password;
|
|
37
39
|
let derivedKey;
|
|
38
|
-
let passwordWasInteractive = false;
|
|
39
40
|
if (config.encrypt) {
|
|
40
41
|
const result = await resolvePassword();
|
|
41
42
|
password = result.password;
|
|
42
|
-
|
|
43
|
+
if (result.source === "prompt") {
|
|
44
|
+
await confirmPassword(password);
|
|
45
|
+
}
|
|
43
46
|
derivedKey = deriveKey(password);
|
|
44
47
|
}
|
|
45
48
|
const spinner = ora("Checking repository visibility").start();
|
|
@@ -59,6 +62,10 @@ export async function backup() {
|
|
|
59
62
|
return;
|
|
60
63
|
}
|
|
61
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
|
+
}
|
|
62
69
|
spinner.text = "Syncing with remote";
|
|
63
70
|
await gitPull(config.repository);
|
|
64
71
|
if (derivedKey) {
|
|
@@ -70,15 +77,22 @@ export async function backup() {
|
|
|
70
77
|
decrypt(encryptedContent, derivedKey);
|
|
71
78
|
}
|
|
72
79
|
catch {
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
if (!process.stdin.isTTY) {
|
|
81
|
+
spinner.fail("Backup failed");
|
|
82
|
+
throw new Error("Password does not match the existing backup.\n" +
|
|
83
|
+
"Run interactively to re-encrypt with a new password.");
|
|
84
|
+
}
|
|
85
|
+
spinner.stop();
|
|
86
|
+
const shouldReEncrypt = await confirm({
|
|
87
|
+
message: "Password does not match the existing backup. Re-encrypt all files with the new password?",
|
|
88
|
+
default: false,
|
|
89
|
+
});
|
|
90
|
+
if (!shouldReEncrypt) {
|
|
91
|
+
throw new Error("Backup aborted.");
|
|
92
|
+
}
|
|
93
|
+
spinner.start();
|
|
75
94
|
}
|
|
76
95
|
}
|
|
77
|
-
else if (passwordWasInteractive) {
|
|
78
|
-
spinner.stop();
|
|
79
|
-
await confirmPassword(password);
|
|
80
|
-
spinner.start();
|
|
81
|
-
}
|
|
82
96
|
}
|
|
83
97
|
spinner.text = `Copying ${pluralize(files.length, "file")} to staging`;
|
|
84
98
|
cleanStaging(config.machine);
|
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,24 +52,30 @@ 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((m) => ({ name: m, value: m })),
|
|
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
|
-
const
|
|
78
|
+
const machineStagingDir = machineDir(machine);
|
|
68
79
|
try {
|
|
69
80
|
if (!repoUrl) {
|
|
70
81
|
await gitPull(repository, commit);
|
|
@@ -75,31 +86,37 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
|
|
|
75
86
|
throw err;
|
|
76
87
|
}
|
|
77
88
|
spinner.text = "Resolving files";
|
|
78
|
-
if (!fs.existsSync(
|
|
89
|
+
if (!fs.existsSync(machineStagingDir)) {
|
|
79
90
|
spinner.stop();
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
91
|
+
const availableMachines = listMachines();
|
|
92
|
+
if (availableMachines.length > 0) {
|
|
82
93
|
console.log(`\n No backup found for machine "${machine}".`);
|
|
83
|
-
console.log(` Available machines: ${
|
|
94
|
+
console.log(` Available machines: ${availableMachines.join(", ")}\n`);
|
|
84
95
|
}
|
|
85
96
|
else {
|
|
86
97
|
console.log(`\n No backup found for machine "${machine}". The repository is empty.\n`);
|
|
87
98
|
}
|
|
88
99
|
return;
|
|
89
100
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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);
|
|
107
|
+
logger.info(`Found ${pluralize(backupFiles.length, "file")} in backup repository`);
|
|
108
|
+
if (backupFiles.length === 0) {
|
|
93
109
|
spinner.stop();
|
|
94
110
|
console.log("No files found in backup repository.");
|
|
95
111
|
return;
|
|
96
112
|
}
|
|
97
|
-
const fileMappings =
|
|
98
|
-
let
|
|
99
|
-
if (
|
|
100
|
-
|
|
113
|
+
const fileMappings = backupFiles.map((backupFilePath) => {
|
|
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
|
}
|
|
@@ -14,7 +14,7 @@ export function schedule() {
|
|
|
14
14
|
try {
|
|
15
15
|
const config = loadConfig();
|
|
16
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
|
|
17
|
+
console.log(chalk.yellow(` Encryption is enabled. Run ${chalk.bold("backdot backup")} once to create ${KEY_FILE_PATH},\n` +
|
|
18
18
|
` or set ${chalk.bold("BACKDOT_PASSWORD")} in your environment.\n`));
|
|
19
19
|
}
|
|
20
20
|
}
|
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/commitUrl.js
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
1
|
+
import { extractRepoPath } from "./utils.js";
|
|
1
2
|
const PROVIDERS = {
|
|
2
3
|
"github.com": (repoPath, sha) => `https://github.com/${repoPath}/commit/${sha}`,
|
|
3
4
|
"gitlab.com": (repoPath, sha) => `https://gitlab.com/${repoPath}/-/commit/${sha}`,
|
|
4
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
|
-
const separatorLength = 1; // skip the ":" (SSH) or "/" (HTTPS) after the hostname
|
|
13
|
-
let repoPath = remoteUrl.slice(idx + host.length + separatorLength).trim();
|
|
14
|
-
if (repoPath.endsWith(".git")) {
|
|
15
|
-
repoPath = repoPath.slice(0, -4);
|
|
16
|
-
}
|
|
17
|
-
return buildUrl(repoPath, sha);
|
|
8
|
+
const parsed = extractRepoPath(remoteUrl);
|
|
9
|
+
if (!parsed) {
|
|
10
|
+
return null;
|
|
18
11
|
}
|
|
19
|
-
|
|
12
|
+
const buildUrl = PROVIDERS[parsed.host];
|
|
13
|
+
if (!buildUrl) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return buildUrl(parsed.repoPath, sha);
|
|
20
17
|
}
|
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("!")) {
|
|
@@ -32,14 +33,14 @@ export function loadConfig() {
|
|
|
32
33
|
catch {
|
|
33
34
|
throw new Error(`Failed to read config file: ${CONFIG_PATH}`);
|
|
34
35
|
}
|
|
35
|
-
let
|
|
36
|
+
let parsedJson;
|
|
36
37
|
try {
|
|
37
|
-
|
|
38
|
+
parsedJson = JSON.parse(rawJson);
|
|
38
39
|
}
|
|
39
40
|
catch {
|
|
40
41
|
throw new Error(`Invalid JSON in config file: ${CONFIG_PATH}`);
|
|
41
42
|
}
|
|
42
|
-
const result = ConfigSchema.safeParse(
|
|
43
|
+
const result = ConfigSchema.safeParse(parsedJson);
|
|
43
44
|
if (!result.success) {
|
|
44
45
|
const messages = result.error.issues.map((issue) => {
|
|
45
46
|
const field = issue.path.length > 0 ? `"${issue.path.join(".")}"` : "config";
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
/** An encryption key derived from the user's password via scrypt. */
|
|
1
2
|
export interface DerivedKey {
|
|
2
|
-
|
|
3
|
+
passwordHash: string;
|
|
3
4
|
salt: Buffer;
|
|
4
5
|
key: Buffer;
|
|
5
6
|
}
|
|
6
|
-
export declare function deriveKey(
|
|
7
|
+
export declare function deriveKey(passwordHash: string, salt?: Buffer): DerivedKey;
|
|
7
8
|
export declare function encrypt(plaintext: Buffer, derivedKey: DerivedKey): Buffer;
|
|
8
|
-
export declare function decrypt(
|
|
9
|
+
export declare function decrypt(encryptedPayload: Buffer, derivedKey: DerivedKey): Buffer;
|
|
@@ -15,16 +15,16 @@ const SCRYPT_PARAMS = {
|
|
|
15
15
|
maxmem: 128 * SCRYPT_N * SCRYPT_R * 2, // must exceed 128*N*r
|
|
16
16
|
};
|
|
17
17
|
const OVERHEAD = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;
|
|
18
|
-
export function deriveKey(
|
|
18
|
+
export function deriveKey(passwordHash, salt) {
|
|
19
19
|
const resolvedSalt = salt ?? crypto.randomBytes(SALT_LENGTH);
|
|
20
|
-
const key = crypto.scryptSync(
|
|
21
|
-
return {
|
|
20
|
+
const key = crypto.scryptSync(passwordHash, resolvedSalt, KEY_LENGTH, SCRYPT_PARAMS);
|
|
21
|
+
return { passwordHash, salt: resolvedSalt, key };
|
|
22
22
|
}
|
|
23
23
|
function deriveKeyForSalt(derivedKey, salt) {
|
|
24
24
|
if (derivedKey.salt.equals(salt)) {
|
|
25
25
|
return derivedKey.key;
|
|
26
26
|
}
|
|
27
|
-
return crypto.scryptSync(derivedKey.
|
|
27
|
+
return crypto.scryptSync(derivedKey.passwordHash, salt, KEY_LENGTH, SCRYPT_PARAMS);
|
|
28
28
|
}
|
|
29
29
|
export function encrypt(plaintext, derivedKey) {
|
|
30
30
|
const iv = crypto.randomBytes(IV_LENGTH);
|
|
@@ -33,15 +33,18 @@ export function encrypt(plaintext, derivedKey) {
|
|
|
33
33
|
const authTag = cipher.getAuthTag();
|
|
34
34
|
return Buffer.concat([derivedKey.salt, iv, authTag, ciphertext]);
|
|
35
35
|
}
|
|
36
|
-
export function decrypt(
|
|
37
|
-
if (
|
|
36
|
+
export function decrypt(encryptedPayload, derivedKey) {
|
|
37
|
+
if (encryptedPayload.length < OVERHEAD) {
|
|
38
38
|
throw new Error("Data is too short to be encrypted content.");
|
|
39
39
|
}
|
|
40
40
|
let offset = 0;
|
|
41
|
-
const salt =
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
41
|
+
const salt = encryptedPayload.subarray(offset, offset + SALT_LENGTH);
|
|
42
|
+
offset += SALT_LENGTH;
|
|
43
|
+
const iv = encryptedPayload.subarray(offset, offset + IV_LENGTH);
|
|
44
|
+
offset += IV_LENGTH;
|
|
45
|
+
const authTag = encryptedPayload.subarray(offset, offset + AUTH_TAG_LENGTH);
|
|
46
|
+
offset += AUTH_TAG_LENGTH;
|
|
47
|
+
const ciphertext = encryptedPayload.subarray(offset);
|
|
45
48
|
const key = deriveKeyForSalt(derivedKey, salt);
|
|
46
49
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
47
50
|
decipher.setAuthTag(authTag);
|
|
@@ -1,11 +1,14 @@
|
|
|
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;
|
|
7
|
+
export type PasswordSource = "env" | "file" | "prompt";
|
|
5
8
|
export interface PasswordResult {
|
|
6
9
|
password: string;
|
|
7
|
-
|
|
10
|
+
source: PasswordSource;
|
|
8
11
|
}
|
|
9
12
|
export declare function resolvePassword(): Promise<PasswordResult>;
|
|
10
|
-
export declare function confirmPassword(
|
|
13
|
+
export declare function confirmPassword(hashedPassword: string): Promise<void>;
|
|
11
14
|
export declare function offerToSaveKeyFile(password: string): Promise<void>;
|
package/dist/crypto/password.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import os from "node:os";
|
|
4
3
|
import { password as passwordPrompt, confirm } from "@inquirer/prompts";
|
|
5
|
-
|
|
4
|
+
import { KEY_FILE_PATH } from "../paths.js";
|
|
5
|
+
export { KEY_FILE_PATH };
|
|
6
6
|
export const ENC_SUFFIX = ".encrypted";
|
|
7
|
+
export function hashPassword(password) {
|
|
8
|
+
return crypto.createHash("sha256").update(password).digest("hex");
|
|
9
|
+
}
|
|
7
10
|
export function checkKeyFilePermissions() {
|
|
8
11
|
if (process.platform === "win32") {
|
|
9
12
|
return;
|
|
@@ -32,24 +35,24 @@ function readKeyFile() {
|
|
|
32
35
|
export async function resolvePassword() {
|
|
33
36
|
const envPassword = process.env.BACKDOT_PASSWORD;
|
|
34
37
|
if (envPassword) {
|
|
35
|
-
return { password: envPassword,
|
|
38
|
+
return { password: hashPassword(envPassword), source: "env" };
|
|
36
39
|
}
|
|
37
40
|
const filePassword = readKeyFile();
|
|
38
41
|
if (filePassword) {
|
|
39
|
-
return { password: filePassword,
|
|
42
|
+
return { password: filePassword, source: "file" };
|
|
40
43
|
}
|
|
41
44
|
if (!process.stdin.isTTY) {
|
|
42
|
-
throw new Error(
|
|
45
|
+
throw new Error(`Encryption is enabled but no password found.\n Run "backdot backup" interactively to create ${KEY_FILE_PATH}, or set BACKDOT_PASSWORD.`);
|
|
43
46
|
}
|
|
44
47
|
const enteredPassword = await passwordPrompt({ message: "Enter encryption password:" });
|
|
45
48
|
if (!enteredPassword) {
|
|
46
49
|
throw new Error("No password provided.");
|
|
47
50
|
}
|
|
48
|
-
return { password: enteredPassword,
|
|
51
|
+
return { password: hashPassword(enteredPassword), source: "prompt" };
|
|
49
52
|
}
|
|
50
|
-
export async function confirmPassword(
|
|
53
|
+
export async function confirmPassword(hashedPassword) {
|
|
51
54
|
const confirmedPassword = await passwordPrompt({ message: "Confirm password:" });
|
|
52
|
-
if (confirmedPassword !==
|
|
55
|
+
if (hashPassword(confirmedPassword) !== hashedPassword) {
|
|
53
56
|
throw new Error("Passwords do not match.");
|
|
54
57
|
}
|
|
55
58
|
}
|
|
@@ -64,7 +67,7 @@ export async function offerToSaveKeyFile(password) {
|
|
|
64
67
|
return;
|
|
65
68
|
}
|
|
66
69
|
const save = await confirm({
|
|
67
|
-
message:
|
|
70
|
+
message: `Save password to ${KEY_FILE_PATH} for automated backups?`,
|
|
68
71
|
default: true,
|
|
69
72
|
});
|
|
70
73
|
if (save) {
|
package/dist/git.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ interface FileChangeSummary {
|
|
|
11
11
|
export declare function buildCommitMessage(changes: FileChangeSummary, maxLength?: number): string;
|
|
12
12
|
export declare function ensureRemoteUrl(repository: string): Promise<void>;
|
|
13
13
|
export declare function getCurrentBranch(git: SimpleGit): Promise<string>;
|
|
14
|
-
export declare function friendlyGitError(
|
|
14
|
+
export declare function friendlyGitError(rawErrorMessage: string, repository: string): string;
|
|
15
15
|
export declare function gitError(err: unknown, repository: string): Error;
|
|
16
16
|
export declare function gitPull(repository: string, commit?: string): Promise<void>;
|
|
17
17
|
export declare function gitLog(limit?: number): Promise<Array<{
|
package/dist/git.js
CHANGED
|
@@ -57,8 +57,8 @@ export async function ensureRemoteUrl(repository) {
|
|
|
57
57
|
export async function getCurrentBranch(git) {
|
|
58
58
|
return (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
59
59
|
}
|
|
60
|
-
export function friendlyGitError(
|
|
61
|
-
const normalizedMessage =
|
|
60
|
+
export function friendlyGitError(rawErrorMessage, repository) {
|
|
61
|
+
const normalizedMessage = rawErrorMessage.toLowerCase();
|
|
62
62
|
if (normalizedMessage.includes("not found") ||
|
|
63
63
|
normalizedMessage.includes("does not exist") ||
|
|
64
64
|
normalizedMessage.includes("does not appear to be a git repository")) {
|
|
@@ -73,7 +73,7 @@ export function friendlyGitError(raw, repository) {
|
|
|
73
73
|
normalizedMessage.includes("connection timed out")) {
|
|
74
74
|
return "Could not connect to remote host. Check your internet connection.";
|
|
75
75
|
}
|
|
76
|
-
return
|
|
76
|
+
return rawErrorMessage;
|
|
77
77
|
}
|
|
78
78
|
export function gitError(err, repository) {
|
|
79
79
|
const raw = errorMessage(err);
|
|
@@ -116,8 +116,8 @@ export async function gitPull(repository, commit) {
|
|
|
116
116
|
}
|
|
117
117
|
export async function gitLog(limit = 20) {
|
|
118
118
|
const git = simpleGit(STAGING_DIR);
|
|
119
|
-
const
|
|
120
|
-
return
|
|
119
|
+
const logResult = await git.log({ maxCount: limit });
|
|
120
|
+
return logResult.all.map((entry) => ({
|
|
121
121
|
hash: entry.hash,
|
|
122
122
|
date: entry.date,
|
|
123
123
|
message: entry.message,
|
|
@@ -133,6 +133,7 @@ export async function gitCommitAndPush() {
|
|
|
133
133
|
}
|
|
134
134
|
const message = buildCommitMessage(status);
|
|
135
135
|
await git.commit(message);
|
|
136
|
+
const remoteUrl = ((await git.remote(["get-url", "origin"])) ?? "").trim();
|
|
136
137
|
try {
|
|
137
138
|
await pRetry(async () => git.push(["-u", "origin", "HEAD"]), {
|
|
138
139
|
retries: 5,
|
|
@@ -151,12 +152,10 @@ export async function gitCommitAndPush() {
|
|
|
151
152
|
});
|
|
152
153
|
}
|
|
153
154
|
catch (err) {
|
|
154
|
-
const remoteUrl = ((await git.remote(["get-url", "origin"])) ?? "").trim();
|
|
155
155
|
throw gitError(err, remoteUrl);
|
|
156
156
|
}
|
|
157
157
|
logger.info(`Committed and pushed: ${message}`);
|
|
158
158
|
const commitHash = (await git.revparse(["HEAD"])).trim();
|
|
159
|
-
const remoteUrl = (await git.remote(["get-url", "origin"])) ?? "";
|
|
160
159
|
const commitUrl = getCommitUrl(remoteUrl, commitHash);
|
|
161
160
|
return { commitUrl };
|
|
162
161
|
}
|