backdot 1.6.1 → 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 +20 -14
- package/dist/cli.js +36 -80
- package/dist/commands/backup.js +66 -4
- package/dist/commands/history.js +9 -6
- package/dist/commands/init.js +6 -5
- package/dist/commands/restore.d.ts +4 -4
- package/dist/commands/restore.js +54 -33
- package/dist/commands/schedule.js +14 -0
- package/dist/commands/status.js +47 -12
- package/dist/commitUrl.js +5 -4
- package/dist/config.d.ts +2 -1
- package/dist/config.js +15 -13
- 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 +12 -9
- 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 +86 -38
- package/package.json +2 -9
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npm install -g backdot
|
|
13
|
-
backdot
|
|
13
|
+
backdot init
|
|
14
14
|
```
|
|
15
15
|
|
|
16
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:
|
|
@@ -26,13 +26,13 @@ This creates `~/.backdot.json` with sensible defaults and walks you through setu
|
|
|
26
26
|
Run your first backup:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
backdot
|
|
29
|
+
backdot backup
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
or configure the backport process to run automatically (daily at 2am)
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
-
backdot
|
|
35
|
+
backdot schedule
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
## Configuration
|
|
@@ -49,19 +49,25 @@ 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
|
-
| Command
|
|
55
|
-
|
|
|
56
|
-
|
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
|
|
|
60
|
-
|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
|
|
|
64
|
-
|
|
|
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 |
|
|
65
71
|
|
|
66
72
|
## Development
|
|
67
73
|
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import
|
|
4
|
+
import cac from "cac";
|
|
5
5
|
import chalk from "chalk";
|
|
6
6
|
import { CONFIG_PATH } from "./config.js";
|
|
7
7
|
import { backup } from "./commands/backup.js";
|
|
@@ -23,92 +23,48 @@ function getVersion() {
|
|
|
23
23
|
return "unknown";
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
backup: { type: "boolean" },
|
|
53
|
-
restore: { type: "boolean" },
|
|
54
|
-
history: { type: "boolean" },
|
|
55
|
-
schedule: { type: "boolean" },
|
|
56
|
-
unschedule: { type: "boolean" },
|
|
57
|
-
status: { type: "boolean" },
|
|
58
|
-
version: { type: "boolean" },
|
|
59
|
-
help: { type: "boolean" },
|
|
60
|
-
commit: { type: "string" },
|
|
61
|
-
yes: { type: "boolean", short: "y" },
|
|
62
|
-
},
|
|
63
|
-
allowPositionals: true,
|
|
64
|
-
strict: true,
|
|
65
|
-
}));
|
|
66
|
-
}
|
|
67
|
-
catch (err) {
|
|
68
|
-
console.error(`\n Error: ${errorMessage(err)}\n`);
|
|
69
|
-
printHelp();
|
|
70
|
-
process.exit(1);
|
|
26
|
+
const cli = cac("backdot");
|
|
27
|
+
cli.command("init", "Set up backdot for the first time").action(() => init());
|
|
28
|
+
cli.command("backup", "Run a backup now").action(async () => {
|
|
29
|
+
await backup();
|
|
30
|
+
});
|
|
31
|
+
cli
|
|
32
|
+
.command("restore [url]", "Restore files")
|
|
33
|
+
.option("--commit <sha>", "Restore from a specific backup commit")
|
|
34
|
+
.option("-y, --yes", "Accept defaults without prompting")
|
|
35
|
+
.action(async (url, options) => {
|
|
36
|
+
await restore({ repoUrl: url, commit: options.commit, yes: !!options.yes });
|
|
37
|
+
});
|
|
38
|
+
cli
|
|
39
|
+
.command("history [url]", "List and restore a previous backup")
|
|
40
|
+
.action(async (url) => {
|
|
41
|
+
await history(url);
|
|
42
|
+
});
|
|
43
|
+
cli.command("schedule", "Schedule daily backup").action(() => schedule());
|
|
44
|
+
cli.command("unschedule", "Unschedule the daily backup").action(() => unschedule());
|
|
45
|
+
cli.command("status", "Show the status of the backup").action(async () => {
|
|
46
|
+
await status();
|
|
47
|
+
});
|
|
48
|
+
cli.command("", "").action(() => {
|
|
49
|
+
cli.outputHelp();
|
|
50
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
51
|
+
console.log(` No config found. Run ${chalk.bold("backdot init")} to get started.\n`);
|
|
71
52
|
}
|
|
53
|
+
});
|
|
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")));
|
|
56
|
+
cli.version(getVersion());
|
|
57
|
+
async function main() {
|
|
72
58
|
try {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
else if (values.backup) {
|
|
77
|
-
await backup();
|
|
78
|
-
}
|
|
79
|
-
else if (values.schedule) {
|
|
80
|
-
schedule();
|
|
81
|
-
}
|
|
82
|
-
else if (values.unschedule) {
|
|
83
|
-
unschedule();
|
|
84
|
-
}
|
|
85
|
-
else if (values.status) {
|
|
86
|
-
await status();
|
|
87
|
-
}
|
|
88
|
-
else if (values.restore) {
|
|
89
|
-
await restore(positionals[0], values.commit, {
|
|
90
|
-
yes: !!values.yes,
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
else if (values.history) {
|
|
94
|
-
await history(positionals[0]);
|
|
95
|
-
}
|
|
96
|
-
else if (values.version) {
|
|
97
|
-
console.log(getVersion());
|
|
98
|
-
}
|
|
99
|
-
else {
|
|
100
|
-
printHelp();
|
|
101
|
-
if (!fs.existsSync(CONFIG_PATH)) {
|
|
102
|
-
console.log(` No config found. Run ${chalk.bold("backdot --init")} to get started.`);
|
|
103
|
-
console.log();
|
|
104
|
-
}
|
|
105
|
-
}
|
|
59
|
+
cli.parse(process.argv, { run: false });
|
|
60
|
+
await cli.runMatchedCommand();
|
|
106
61
|
}
|
|
107
62
|
catch (err) {
|
|
108
63
|
const msg = errorMessage(err);
|
|
109
64
|
logger.error(msg);
|
|
110
65
|
console.error(`\n Error: ${msg}\n`);
|
|
111
|
-
|
|
66
|
+
const isRunningInBackground = !process.stdout.isTTY;
|
|
67
|
+
if (isRunningInBackground) {
|
|
112
68
|
sendNotification("Backdot", `Backup failed: ${msg}`);
|
|
113
69
|
}
|
|
114
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,
|
|
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,16 +42,17 @@ 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"));
|
|
52
53
|
console.log();
|
|
53
|
-
console.log(` ${chalk.bold("backdot
|
|
54
|
-
console.log(` ${chalk.bold("backdot
|
|
55
|
-
console.log(` ${chalk.bold("backdot
|
|
54
|
+
console.log(` ${chalk.bold("backdot backup")} Run a one-time backup`);
|
|
55
|
+
console.log(` ${chalk.bold("backdot schedule")} Schedule daily backups (macOS)`);
|
|
56
|
+
console.log(` ${chalk.bold("backdot status")} Check which files will be backed up`);
|
|
56
57
|
console.log();
|
|
57
58
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
export declare function restore({ repoUrl, commit, yes, }?: {
|
|
2
|
+
repoUrl?: string;
|
|
3
|
+
commit?: string;
|
|
2
4
|
yes?: boolean;
|
|
3
|
-
}
|
|
4
|
-
export declare function restore(repoUrl?: string, commit?: string, options?: RestoreOptions): Promise<void>;
|
|
5
|
-
export {};
|
|
5
|
+
}): Promise<void>;
|
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) {
|
|
@@ -58,7 +60,7 @@ async function resolveRepoAndMachine(repoUrl, commit) {
|
|
|
58
60
|
}
|
|
59
61
|
return { repository: repoUrl, machine };
|
|
60
62
|
}
|
|
61
|
-
export async function restore(repoUrl, commit,
|
|
63
|
+
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();
|
|
@@ -85,61 +87,80 @@ export async function restore(repoUrl, commit, options = {}) {
|
|
|
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
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
console.log(` Skipped ${pluralize(
|
|
109
|
+
let filesToRestore;
|
|
110
|
+
if (yes) {
|
|
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,26 +75,26 @@ 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
|
}
|
|
62
|
-
console.log(` Run ${chalk.bold("backdot
|
|
97
|
+
console.log(` Run ${chalk.bold("backdot backup")} to back up all changes.`);
|
|
63
98
|
}
|
|
64
99
|
}
|
|
65
100
|
catch (err) {
|