backdot 1.6.0 → 1.7.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 +14 -14
- package/dist/cli.js +35 -81
- package/dist/commands/backup.js +4 -3
- package/dist/commands/history.js +11 -4
- package/dist/commands/init.js +19 -4
- package/dist/commands/restore.d.ts +4 -4
- package/dist/commands/restore.js +27 -9
- package/dist/commands/schedule.js +1 -1
- package/dist/commands/status.js +7 -6
- package/dist/commitUrl.js +2 -1
- package/dist/config.js +1 -1
- package/dist/errorMessage.d.ts +1 -0
- package/dist/errorMessage.js +3 -0
- package/dist/git.d.ts +5 -0
- package/dist/git.js +89 -41
- package/dist/launchd.d.ts +3 -0
- package/dist/launchd.js +112 -0
- package/dist/log.d.ts +5 -1
- package/dist/log.js +17 -6
- package/dist/notify.js +4 -3
- package/dist/paths.d.ts +3 -0
- package/dist/paths.js +8 -0
- package/dist/plist.js +14 -6
- package/dist/resolve.js +6 -4
- package/dist/resolveFiles.d.ts +6 -0
- package/dist/resolveFiles.js +44 -0
- package/dist/staging.d.ts +3 -3
- package/dist/staging.js +20 -18
- package/dist/utils.d.ts +3 -0
- package/dist/utils.js +9 -0
- package/package.json +5 -2
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
|
|
@@ -51,17 +51,17 @@ Prefix a pattern with `!` to exclude matching files:
|
|
|
51
51
|
|
|
52
52
|
## Commands
|
|
53
53
|
|
|
54
|
-
| Command
|
|
55
|
-
|
|
|
56
|
-
|
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
|
|
|
60
|
-
|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
|
|
|
64
|
-
|
|
|
54
|
+
| Command | Description |
|
|
55
|
+
| ------------------------------ | ---------------------------------------------- |
|
|
56
|
+
| `init` | Set up backdot for the first time |
|
|
57
|
+
| `backup` | Run a backup now |
|
|
58
|
+
| `restore` | Restore latest backup from the configured repo |
|
|
59
|
+
| `restore <url>` | Restore from a specific repo URL |
|
|
60
|
+
| `restore [url] --commit <sha>` | Restore from a specific backup commit |
|
|
61
|
+
| `history [url]` | Browse and restore a previous backup |
|
|
62
|
+
| `schedule` | Schedule automatic daily backup (Mac-only) |
|
|
63
|
+
| `unschedule` | Unschedule the daily backup |
|
|
64
|
+
| `status` | Show schedule and resolved file list |
|
|
65
65
|
|
|
66
66
|
## Development
|
|
67
67
|
|
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";
|
|
@@ -11,6 +11,7 @@ import { restore } from "./commands/restore.js";
|
|
|
11
11
|
import { history } from "./commands/history.js";
|
|
12
12
|
import { init } from "./commands/init.js";
|
|
13
13
|
import { logger } from "./log.js";
|
|
14
|
+
import { errorMessage } from "./utils.js";
|
|
14
15
|
import { sendNotification } from "./notify.js";
|
|
15
16
|
function getVersion() {
|
|
16
17
|
const pkgPath = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../package.json");
|
|
@@ -22,90 +23,43 @@ function getVersion() {
|
|
|
22
23
|
return "unknown";
|
|
23
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
|
-
backup: { type: "boolean" },
|
|
52
|
-
restore: { type: "boolean" },
|
|
53
|
-
history: { type: "boolean" },
|
|
54
|
-
schedule: { type: "boolean" },
|
|
55
|
-
unschedule: { type: "boolean" },
|
|
56
|
-
status: { type: "boolean" },
|
|
57
|
-
version: { type: "boolean" },
|
|
58
|
-
help: { type: "boolean" },
|
|
59
|
-
commit: { type: "string" },
|
|
60
|
-
yes: { type: "boolean", short: "y" },
|
|
61
|
-
},
|
|
62
|
-
allowPositionals: true,
|
|
63
|
-
strict: true,
|
|
64
|
-
}));
|
|
65
|
-
}
|
|
66
|
-
catch (err) {
|
|
67
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
68
|
-
console.error(`\n Error: ${msg}\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
|
+
cli.help();
|
|
55
|
+
cli.version(getVersion());
|
|
56
|
+
async function main() {
|
|
72
57
|
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
|
-
}
|
|
58
|
+
cli.parse(process.argv, { run: false });
|
|
59
|
+
await cli.runMatchedCommand();
|
|
106
60
|
}
|
|
107
61
|
catch (err) {
|
|
108
|
-
const msg =
|
|
62
|
+
const msg = errorMessage(err);
|
|
109
63
|
logger.error(msg);
|
|
110
64
|
console.error(`\n Error: ${msg}\n`);
|
|
111
65
|
if (!process.stdout.isTTY) {
|
package/dist/commands/backup.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import ora from "ora";
|
|
2
2
|
import { loadConfig, CONFIG_PATH } from "../config.js";
|
|
3
|
-
import { resolveFiles } from "../
|
|
3
|
+
import { resolveFiles } from "../resolveFiles.js";
|
|
4
4
|
import { cleanStaging, copyToStaging, writeRepoReadme } from "../staging.js";
|
|
5
5
|
import { gitPull, gitCommitAndPush } from "../git.js";
|
|
6
6
|
import { logger } from "../log.js";
|
|
7
|
+
import { pluralize } from "../utils.js";
|
|
7
8
|
export async function backup() {
|
|
8
9
|
logger.info("Starting backup");
|
|
9
10
|
const config = loadConfig();
|
|
@@ -12,7 +13,7 @@ export async function backup() {
|
|
|
12
13
|
const spinner = ora("Resolving files").start();
|
|
13
14
|
try {
|
|
14
15
|
const userFiles = resolveFiles(config);
|
|
15
|
-
logger.info(`Resolved ${userFiles.length
|
|
16
|
+
logger.info(`Resolved ${pluralize(userFiles.length, "file")}`);
|
|
16
17
|
if (userFiles.length === 0) {
|
|
17
18
|
spinner.info("No files resolved, nothing to back up");
|
|
18
19
|
return;
|
|
@@ -20,7 +21,7 @@ export async function backup() {
|
|
|
20
21
|
const files = [...userFiles, CONFIG_PATH];
|
|
21
22
|
spinner.text = "Syncing with remote";
|
|
22
23
|
await gitPull(config.repository);
|
|
23
|
-
spinner.text = `Copying ${files.length
|
|
24
|
+
spinner.text = `Copying ${pluralize(files.length, "file")} to staging`;
|
|
24
25
|
cleanStaging(config.machine);
|
|
25
26
|
copyToStaging(files, config.machine);
|
|
26
27
|
writeRepoReadme(config.repository);
|
package/dist/commands/history.js
CHANGED
|
@@ -8,14 +8,21 @@ export async function history(repoUrl) {
|
|
|
8
8
|
logger.info("Starting history");
|
|
9
9
|
const repository = repoUrl ?? loadConfig().repository;
|
|
10
10
|
const spinner = ora("Fetching backup history").start();
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
let commits;
|
|
12
|
+
try {
|
|
13
|
+
await gitPull(repository);
|
|
14
|
+
commits = await gitLog();
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
spinner.fail("Failed to fetch backup history");
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
13
20
|
spinner.stop();
|
|
14
21
|
if (commits.length === 0) {
|
|
15
22
|
console.log("\n No backup history found.\n");
|
|
16
23
|
return;
|
|
17
24
|
}
|
|
18
|
-
const
|
|
25
|
+
const selectedCommit = await select({
|
|
19
26
|
message: "Select a backup to restore from:",
|
|
20
27
|
loop: false,
|
|
21
28
|
choices: commits.map((c) => ({
|
|
@@ -24,5 +31,5 @@ export async function history(repoUrl) {
|
|
|
24
31
|
})),
|
|
25
32
|
});
|
|
26
33
|
console.log();
|
|
27
|
-
await restore(repoUrl,
|
|
34
|
+
await restore({ repoUrl, commit: selectedCommit });
|
|
28
35
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import chalk from "chalk";
|
|
4
5
|
import { CONFIG_PATH } from "../config.js";
|
|
6
|
+
function getMachineName() {
|
|
7
|
+
if (process.platform === "darwin") {
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync("scutil", ["--get", "LocalHostName"], {
|
|
10
|
+
encoding: "utf-8",
|
|
11
|
+
stdio: "pipe",
|
|
12
|
+
}).trim();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// fall through
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return os.hostname().replace(/\.(local|localdomain)$/, "");
|
|
19
|
+
}
|
|
5
20
|
const TEMPLATE = {
|
|
6
21
|
repository: "git@github.com:USERNAME/backdot-backup.git",
|
|
7
|
-
machine:
|
|
22
|
+
machine: getMachineName(),
|
|
8
23
|
paths: ["~/.zshrc", "~/.gitconfig"],
|
|
9
24
|
};
|
|
10
25
|
export function init() {
|
|
@@ -35,8 +50,8 @@ export function init() {
|
|
|
35
50
|
// Step 3
|
|
36
51
|
console.log(chalk.bold(" Step 3 — Run your first backup"));
|
|
37
52
|
console.log();
|
|
38
|
-
console.log(` ${chalk.bold("backdot
|
|
39
|
-
console.log(` ${chalk.bold("backdot
|
|
40
|
-
console.log(` ${chalk.bold("backdot
|
|
53
|
+
console.log(` ${chalk.bold("backdot backup")} Run a one-time backup`);
|
|
54
|
+
console.log(` ${chalk.bold("backdot schedule")} Schedule daily backups (macOS)`);
|
|
55
|
+
console.log(` ${chalk.bold("backdot status")} Check which files will be backed up`);
|
|
41
56
|
console.log();
|
|
42
57
|
}
|
|
@@ -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
|
@@ -7,6 +7,7 @@ import { loadConfig } from "../config.js";
|
|
|
7
7
|
import { gitPull } from "../git.js";
|
|
8
8
|
import { STAGING_DIR, machineDir } from "../staging.js";
|
|
9
9
|
import { logger } from "../log.js";
|
|
10
|
+
import { pluralize } from "../utils.js";
|
|
10
11
|
const HOME = os.homedir();
|
|
11
12
|
function walkDir(dir) {
|
|
12
13
|
return fs
|
|
@@ -18,8 +19,9 @@ function walkDir(dir) {
|
|
|
18
19
|
});
|
|
19
20
|
}
|
|
20
21
|
function listMachines() {
|
|
21
|
-
if (!fs.existsSync(STAGING_DIR))
|
|
22
|
+
if (!fs.existsSync(STAGING_DIR)) {
|
|
22
23
|
return [];
|
|
24
|
+
}
|
|
23
25
|
return fs
|
|
24
26
|
.readdirSync(STAGING_DIR, { withFileTypes: true })
|
|
25
27
|
.filter((e) => e.isDirectory() && e.name !== ".git")
|
|
@@ -31,7 +33,13 @@ async function resolveRepoAndMachine(repoUrl, commit) {
|
|
|
31
33
|
return { repository: config.repository, machine: config.machine };
|
|
32
34
|
}
|
|
33
35
|
const spinner = ora("Cloning backup repository").start();
|
|
34
|
-
|
|
36
|
+
try {
|
|
37
|
+
await gitPull(repoUrl, commit);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
spinner.fail("Failed to clone backup repository");
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
35
43
|
spinner.stop();
|
|
36
44
|
const machines = listMachines();
|
|
37
45
|
if (machines.length === 0) {
|
|
@@ -50,13 +58,19 @@ async function resolveRepoAndMachine(repoUrl, commit) {
|
|
|
50
58
|
}
|
|
51
59
|
return { repository: repoUrl, machine };
|
|
52
60
|
}
|
|
53
|
-
export async function restore(repoUrl, commit,
|
|
61
|
+
export async function restore({ repoUrl, commit, yes, } = {}) {
|
|
54
62
|
logger.info("Starting restore");
|
|
55
63
|
const { repository, machine } = await resolveRepoAndMachine(repoUrl, commit);
|
|
56
64
|
const spinner = ora("Fetching latest backup").start();
|
|
57
65
|
const baseDir = machineDir(machine);
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
try {
|
|
67
|
+
if (!repoUrl) {
|
|
68
|
+
await gitPull(repository, commit);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
spinner.fail("Failed to fetch latest backup");
|
|
73
|
+
throw err;
|
|
60
74
|
}
|
|
61
75
|
spinner.text = "Resolving files";
|
|
62
76
|
if (!fs.existsSync(baseDir)) {
|
|
@@ -72,7 +86,7 @@ export async function restore(repoUrl, commit, options = {}) {
|
|
|
72
86
|
return;
|
|
73
87
|
}
|
|
74
88
|
const stagedFiles = walkDir(baseDir);
|
|
75
|
-
logger.info(`Found ${stagedFiles.length
|
|
89
|
+
logger.info(`Found ${pluralize(stagedFiles.length, "file")} in backup repository`);
|
|
76
90
|
if (stagedFiles.length === 0) {
|
|
77
91
|
spinner.stop();
|
|
78
92
|
console.log("No files found in backup repository.");
|
|
@@ -88,8 +102,12 @@ export async function restore(repoUrl, commit, options = {}) {
|
|
|
88
102
|
spinner.stop();
|
|
89
103
|
console.log();
|
|
90
104
|
let toRestore;
|
|
91
|
-
if (
|
|
105
|
+
if (yes) {
|
|
92
106
|
toRestore = fresh;
|
|
107
|
+
if (existing.length > 0) {
|
|
108
|
+
console.log(` Skipped ${pluralize(existing.length, "existing file")}. Run without --yes to select them.`);
|
|
109
|
+
console.log();
|
|
110
|
+
}
|
|
93
111
|
}
|
|
94
112
|
else {
|
|
95
113
|
const choices = [];
|
|
@@ -121,7 +139,7 @@ export async function restore(repoUrl, commit, options = {}) {
|
|
|
121
139
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
122
140
|
fs.copyFileSync(src, dest);
|
|
123
141
|
}
|
|
124
|
-
copySpinner.succeed(`Restored ${toRestore.length
|
|
142
|
+
copySpinner.succeed(`Restored ${pluralize(toRestore.length, "file")}`);
|
|
125
143
|
console.log();
|
|
126
|
-
logger.info(`Restored ${toRestore.length
|
|
144
|
+
logger.info(`Restored ${pluralize(toRestore.length, "file")}`);
|
|
127
145
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { setupLaunchd, uninstallLaunchd } from "../
|
|
1
|
+
import { setupLaunchd, uninstallLaunchd } from "../launchd.js";
|
|
2
2
|
function requireMacOS() {
|
|
3
3
|
if (process.platform !== "darwin") {
|
|
4
4
|
throw new Error("Scheduling is only supported on macOS (launchd). Use cron or systemd on Linux.");
|
package/dist/commands/status.js
CHANGED
|
@@ -2,9 +2,10 @@ import os from "node:os";
|
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import ora from "ora";
|
|
4
4
|
import { loadConfig, CONFIG_PATH } from "../config.js";
|
|
5
|
-
import { resolveFiles } from "../
|
|
5
|
+
import { resolveFiles } from "../resolveFiles.js";
|
|
6
6
|
import { compareFiles } from "../staging.js";
|
|
7
|
-
import { isScheduled } from "../
|
|
7
|
+
import { isScheduled } from "../launchd.js";
|
|
8
|
+
import { pluralize } from "../utils.js";
|
|
8
9
|
function tildePath(filePath) {
|
|
9
10
|
const home = os.homedir();
|
|
10
11
|
return filePath.startsWith(home) ? "~" + filePath.slice(home.length) : filePath;
|
|
@@ -12,7 +13,7 @@ function tildePath(filePath) {
|
|
|
12
13
|
export async function status() {
|
|
13
14
|
const scheduled = isScheduled();
|
|
14
15
|
console.log();
|
|
15
|
-
console.log(` Schedule: ${scheduled ? "active (daily at 02:00)" :
|
|
16
|
+
console.log(` Schedule: ${scheduled ? "active (daily at 02:00)" : `not active (run ${chalk.bold("backdot schedule")} to enable)`}`);
|
|
16
17
|
const config = loadConfig();
|
|
17
18
|
console.log(` Repo: ${config.repository}`);
|
|
18
19
|
console.log(` Machine: ${config.machine}`);
|
|
@@ -27,14 +28,14 @@ export async function status() {
|
|
|
27
28
|
}
|
|
28
29
|
const files = [...userFiles, CONFIG_PATH];
|
|
29
30
|
spinner.text = "Comparing with remote backup";
|
|
30
|
-
const { backedUp, modified, notBackedUp, error } = await compareFiles(files, config.machine);
|
|
31
|
+
const { backedUp, modified, notBackedUp, error } = await compareFiles(files, config.machine, config.repository);
|
|
31
32
|
spinner.stop();
|
|
32
33
|
if (error) {
|
|
33
34
|
console.log(chalk.yellow(` Could not fetch status: ${error}`));
|
|
34
35
|
return;
|
|
35
36
|
}
|
|
36
37
|
if (modified.length === 0 && notBackedUp.length === 0) {
|
|
37
|
-
console.log(chalk.green(` All ${files.length
|
|
38
|
+
console.log(chalk.green(` All ${pluralize(files.length, "file")} are backed up ✓`));
|
|
38
39
|
}
|
|
39
40
|
else {
|
|
40
41
|
if (modified.length > 0) {
|
|
@@ -58,7 +59,7 @@ export async function status() {
|
|
|
58
59
|
}
|
|
59
60
|
console.log();
|
|
60
61
|
}
|
|
61
|
-
console.log(` Run ${chalk.bold("backdot
|
|
62
|
+
console.log(` Run ${chalk.bold("backdot backup")} to back up all changes.`);
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
catch (err) {
|
package/dist/commitUrl.js
CHANGED
|
@@ -6,8 +6,9 @@ const PROVIDERS = {
|
|
|
6
6
|
export function getCommitUrl(remoteUrl, sha) {
|
|
7
7
|
for (const [host, buildUrl] of Object.entries(PROVIDERS)) {
|
|
8
8
|
const idx = remoteUrl.indexOf(host);
|
|
9
|
-
if (idx === -1)
|
|
9
|
+
if (idx === -1) {
|
|
10
10
|
continue;
|
|
11
|
+
}
|
|
11
12
|
let repoPath = remoteUrl.slice(idx + host.length + 1).trim();
|
|
12
13
|
if (repoPath.endsWith(".git")) {
|
|
13
14
|
repoPath = repoPath.slice(0, -4);
|
package/dist/config.js
CHANGED
|
@@ -21,7 +21,7 @@ const ConfigSchema = z.object({
|
|
|
21
21
|
});
|
|
22
22
|
export function loadConfig() {
|
|
23
23
|
if (!fs.existsSync(CONFIG_PATH)) {
|
|
24
|
-
throw new Error(`Config file not found: ${CONFIG_PATH}\n Run "backdot
|
|
24
|
+
throw new Error(`Config file not found: ${CONFIG_PATH}\n Run "backdot init" to create it.`);
|
|
25
25
|
}
|
|
26
26
|
let raw;
|
|
27
27
|
try {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function errorMessage(err: unknown): string;
|
package/dist/git.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SimpleGit } from "simple-git";
|
|
1
2
|
interface FileChangeSummary {
|
|
2
3
|
created: string[];
|
|
3
4
|
deleted: string[];
|
|
@@ -8,6 +9,10 @@ interface FileChangeSummary {
|
|
|
8
9
|
}>;
|
|
9
10
|
}
|
|
10
11
|
export declare function buildCommitMessage(changes: FileChangeSummary, maxLen?: number): string;
|
|
12
|
+
export declare function ensureRemoteUrl(repository: string): Promise<void>;
|
|
13
|
+
export declare function getCurrentBranch(git: SimpleGit): Promise<string>;
|
|
14
|
+
export declare function friendlyGitError(raw: string, repository: string): string;
|
|
15
|
+
export declare function gitError(err: unknown, repository: string): Error;
|
|
11
16
|
export declare function gitPull(repository: string, commit?: string): Promise<void>;
|
|
12
17
|
export declare function gitLog(limit?: number): Promise<Array<{
|
|
13
18
|
hash: string;
|
package/dist/git.js
CHANGED
|
@@ -3,10 +3,11 @@ import path from "node:path";
|
|
|
3
3
|
import pRetry from "p-retry";
|
|
4
4
|
import { simpleGit, CleanOptions } from "simple-git";
|
|
5
5
|
import { logger } from "./log.js";
|
|
6
|
-
import { STAGING_DIR } from "./
|
|
6
|
+
import { STAGING_DIR, STAGING_GIT_DIR } from "./paths.js";
|
|
7
7
|
import { getCommitUrl } from "./commitUrl.js";
|
|
8
|
+
import { errorMessage, pluralize, uniq } from "./utils.js";
|
|
8
9
|
export function buildCommitMessage(changes, maxLen = 250) {
|
|
9
|
-
const unique = (paths) =>
|
|
10
|
+
const unique = (paths) => uniq(paths.map((f) => path.basename(f)));
|
|
10
11
|
const removed = unique(changes.deleted);
|
|
11
12
|
const added = unique(changes.created);
|
|
12
13
|
const modified = unique([...changes.modified, ...changes.renamed.map((r) => r.to)]);
|
|
@@ -16,56 +17,97 @@ export function buildCommitMessage(changes, maxLen = 250) {
|
|
|
16
17
|
{ label: "modified", files: modified },
|
|
17
18
|
].filter((c) => c.files.length > 0);
|
|
18
19
|
if (categories.length === 0) {
|
|
19
|
-
return "backup";
|
|
20
|
+
return "backup"; // fallback commit message when no changes are categorized
|
|
20
21
|
}
|
|
21
22
|
function format(cat, listFiles) {
|
|
22
23
|
if (listFiles) {
|
|
23
24
|
return `${cat.label}: ${cat.files.join(", ")}`;
|
|
24
25
|
}
|
|
25
|
-
|
|
26
|
-
return `${cat.label}: ${n} file${n !== 1 ? "s" : ""}`;
|
|
26
|
+
return `${cat.label}: ${pluralize(cat.files.length, "file")}`;
|
|
27
27
|
}
|
|
28
28
|
function build(listFlags) {
|
|
29
29
|
return categories.map((cat, i) => format(cat, listFlags[i])).join("; ");
|
|
30
30
|
}
|
|
31
31
|
const flags = categories.map(() => true);
|
|
32
32
|
let msg = build(flags);
|
|
33
|
-
if (msg.length <= maxLen)
|
|
33
|
+
if (msg.length <= maxLen) {
|
|
34
34
|
return msg;
|
|
35
|
+
}
|
|
35
36
|
for (const label of ["modified", "added", "removed"]) {
|
|
36
37
|
const idx = categories.findIndex((c) => c.label === label);
|
|
37
38
|
if (idx !== -1 && flags[idx]) {
|
|
38
39
|
flags[idx] = false;
|
|
39
40
|
msg = build(flags);
|
|
40
|
-
if (msg.length <= maxLen)
|
|
41
|
+
if (msg.length <= maxLen) {
|
|
41
42
|
return msg;
|
|
43
|
+
}
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
return msg.slice(0, maxLen - 3) + "...";
|
|
45
47
|
}
|
|
46
|
-
export async function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
await git.reset(["--hard", target]);
|
|
52
|
-
await git.clean(CleanOptions.FORCE, ["-d"]);
|
|
48
|
+
export async function ensureRemoteUrl(repository) {
|
|
49
|
+
const git = simpleGit(STAGING_DIR);
|
|
50
|
+
const currentUrl = (await git.remote(["get-url", "origin"]))?.trim();
|
|
51
|
+
if (currentUrl !== repository) {
|
|
52
|
+
await git.remote(["set-url", "origin", repository]);
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
54
|
+
}
|
|
55
|
+
export async function getCurrentBranch(git) {
|
|
56
|
+
return (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
57
|
+
}
|
|
58
|
+
export function friendlyGitError(raw, repository) {
|
|
59
|
+
const msg = raw.toLowerCase();
|
|
60
|
+
if (msg.includes("not found") ||
|
|
61
|
+
msg.includes("does not exist") ||
|
|
62
|
+
msg.includes("does not appear to be a git repository")) {
|
|
63
|
+
return `Repository "${repository}" not found. Check the URL and that you have access.`;
|
|
64
|
+
}
|
|
65
|
+
if (msg.includes("authentication failed") || msg.includes("could not read username")) {
|
|
66
|
+
return `Authentication failed for "${repository}". Check your credentials or SSH key.`;
|
|
67
|
+
}
|
|
68
|
+
if (msg.includes("could not resolve host") ||
|
|
69
|
+
msg.includes("connection refused") ||
|
|
70
|
+
msg.includes("connection timed out")) {
|
|
71
|
+
return "Could not connect to remote host. Check your internet connection.";
|
|
72
|
+
}
|
|
73
|
+
return raw;
|
|
74
|
+
}
|
|
75
|
+
export function gitError(err, repository) {
|
|
76
|
+
const raw = errorMessage(err);
|
|
77
|
+
return new Error(friendlyGitError(raw, repository), { cause: err });
|
|
78
|
+
}
|
|
79
|
+
export async function gitPull(repository, commit) {
|
|
80
|
+
try {
|
|
81
|
+
if (fs.existsSync(STAGING_GIT_DIR)) {
|
|
65
82
|
const git = simpleGit(STAGING_DIR);
|
|
66
|
-
await
|
|
83
|
+
await ensureRemoteUrl(repository);
|
|
84
|
+
await git.fetch("origin");
|
|
85
|
+
const target = commit ?? `origin/${await getCurrentBranch(git)}`;
|
|
86
|
+
await git.reset(["--hard", target]);
|
|
67
87
|
await git.clean(CleanOptions.FORCE, ["-d"]);
|
|
68
88
|
}
|
|
89
|
+
else {
|
|
90
|
+
try {
|
|
91
|
+
await simpleGit().clone(repository, STAGING_DIR);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (!errorMessage(err).includes("empty repository")) {
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
fs.mkdirSync(STAGING_DIR, { recursive: true });
|
|
98
|
+
const git = simpleGit(STAGING_DIR);
|
|
99
|
+
await git.init();
|
|
100
|
+
await git.addRemote("origin", repository);
|
|
101
|
+
}
|
|
102
|
+
if (commit) {
|
|
103
|
+
const git = simpleGit(STAGING_DIR);
|
|
104
|
+
await git.reset(["--hard", commit]);
|
|
105
|
+
await git.clean(CleanOptions.FORCE, ["-d"]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
throw gitError(err, repository);
|
|
69
111
|
}
|
|
70
112
|
logger.info("Synced staging directory from remote");
|
|
71
113
|
}
|
|
@@ -88,21 +130,27 @@ export async function gitCommitAndPush() {
|
|
|
88
130
|
}
|
|
89
131
|
const message = buildCommitMessage(status);
|
|
90
132
|
await git.commit(message);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
133
|
+
try {
|
|
134
|
+
await pRetry(async () => git.push(["-u", "origin", "HEAD"]), {
|
|
135
|
+
retries: 5,
|
|
136
|
+
onFailedAttempt: async ({ attemptNumber, retriesLeft }) => {
|
|
137
|
+
logger.info(`Push failed (attempt ${attemptNumber}, ${retriesLeft} retries left), rebasing`);
|
|
138
|
+
await git.fetch("origin");
|
|
139
|
+
const branch = await getCurrentBranch(git);
|
|
140
|
+
try {
|
|
141
|
+
await git.rebase([`origin/${branch}`]);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
await git.rebase(["--abort"]);
|
|
145
|
+
throw new Error("Rebase conflict, aborting retry");
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
const remoteUrl = ((await git.remote(["get-url", "origin"])) ?? "").trim();
|
|
152
|
+
throw gitError(err, remoteUrl);
|
|
153
|
+
}
|
|
106
154
|
logger.info(`Committed and pushed: ${message}`);
|
|
107
155
|
const sha = (await git.revparse(["HEAD"])).trim();
|
|
108
156
|
const remoteUrl = (await git.remote(["get-url", "origin"])) ?? "";
|
package/dist/launchd.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import ora from "ora";
|
|
6
|
+
import { logger } from "./log.js";
|
|
7
|
+
import { errorMessage } from "./utils.js";
|
|
8
|
+
function escapeXml(s) {
|
|
9
|
+
return s
|
|
10
|
+
.replace(/&/g, "&")
|
|
11
|
+
.replace(/</g, "<")
|
|
12
|
+
.replace(/>/g, ">")
|
|
13
|
+
.replace(/"/g, """);
|
|
14
|
+
}
|
|
15
|
+
const LABEL = "com.backdot.daemon";
|
|
16
|
+
const PLIST_PATH = path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
17
|
+
function getScriptPath() {
|
|
18
|
+
const currentDir = path.dirname(new URL(import.meta.url).pathname);
|
|
19
|
+
return path.resolve(currentDir, "cli.js");
|
|
20
|
+
}
|
|
21
|
+
function buildPlist() {
|
|
22
|
+
const nodePath = process.execPath;
|
|
23
|
+
const scriptPath = getScriptPath();
|
|
24
|
+
const workingDir = path.dirname(scriptPath);
|
|
25
|
+
const logPath = path.join(os.homedir(), ".backdot", "launchd.log");
|
|
26
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
27
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
28
|
+
<plist version="1.0">
|
|
29
|
+
<dict>
|
|
30
|
+
<key>Label</key>
|
|
31
|
+
<string>${LABEL}</string>
|
|
32
|
+
<key>ProgramArguments</key>
|
|
33
|
+
<array>
|
|
34
|
+
<string>${escapeXml(nodePath)}</string>
|
|
35
|
+
<string>${escapeXml(scriptPath)}</string>
|
|
36
|
+
<string>backup</string>
|
|
37
|
+
</array>
|
|
38
|
+
<key>WorkingDirectory</key>
|
|
39
|
+
<string>${escapeXml(workingDir)}</string>
|
|
40
|
+
<key>StartCalendarInterval</key>
|
|
41
|
+
<dict>
|
|
42
|
+
<key>Hour</key>
|
|
43
|
+
<integer>2</integer>
|
|
44
|
+
<key>Minute</key>
|
|
45
|
+
<integer>0</integer>
|
|
46
|
+
</dict>
|
|
47
|
+
<key>StandardOutPath</key>
|
|
48
|
+
<string>${escapeXml(logPath)}</string>
|
|
49
|
+
<key>StandardErrorPath</key>
|
|
50
|
+
<string>${escapeXml(logPath)}</string>
|
|
51
|
+
<key>RunAtLoad</key>
|
|
52
|
+
<false/>
|
|
53
|
+
</dict>
|
|
54
|
+
</plist>`;
|
|
55
|
+
}
|
|
56
|
+
export function isScheduled() {
|
|
57
|
+
if (!fs.existsSync(PLIST_PATH)) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const output = execFileSync("launchctl", ["list", LABEL], { encoding: "utf-8", stdio: "pipe" });
|
|
62
|
+
return output.includes(LABEL);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function setupLaunchd() {
|
|
69
|
+
const spinner = ora("Installing schedule").start();
|
|
70
|
+
const plistContent = buildPlist();
|
|
71
|
+
const dir = path.dirname(PLIST_PATH);
|
|
72
|
+
if (!fs.existsSync(dir)) {
|
|
73
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Not loaded, that's fine
|
|
80
|
+
}
|
|
81
|
+
fs.writeFileSync(PLIST_PATH, plistContent);
|
|
82
|
+
logger.info(`Plist written to ${PLIST_PATH}`);
|
|
83
|
+
try {
|
|
84
|
+
execFileSync("launchctl", ["load", PLIST_PATH], { stdio: "pipe" });
|
|
85
|
+
spinner.succeed("Daily backup scheduled (02:00)");
|
|
86
|
+
console.log();
|
|
87
|
+
logger.info("Launchd job loaded");
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
const msg = errorMessage(err);
|
|
91
|
+
spinner.fail(`Failed to load launchd job: ${msg}`);
|
|
92
|
+
console.log();
|
|
93
|
+
logger.error(`Failed to load launchd job: ${msg}`);
|
|
94
|
+
throw new Error(`Failed to load launchd job: ${msg}`, { cause: err });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function uninstallLaunchd() {
|
|
98
|
+
const spinner = ora("Removing schedule").start();
|
|
99
|
+
try {
|
|
100
|
+
execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
|
|
101
|
+
logger.info("Launchd job unloaded");
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
logger.info("Launchd job was not loaded");
|
|
105
|
+
}
|
|
106
|
+
if (fs.existsSync(PLIST_PATH)) {
|
|
107
|
+
fs.unlinkSync(PLIST_PATH);
|
|
108
|
+
logger.info(`Plist removed: ${PLIST_PATH}`);
|
|
109
|
+
}
|
|
110
|
+
spinner.succeed("Schedule removed");
|
|
111
|
+
console.log();
|
|
112
|
+
}
|
package/dist/log.d.ts
CHANGED
package/dist/log.js
CHANGED
|
@@ -3,10 +3,21 @@ import path from "node:path";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import winston from "winston";
|
|
5
5
|
const LOG_DIR = path.join(os.homedir(), ".backdot");
|
|
6
|
-
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
7
6
|
const LOG_FILE = path.join(LOG_DIR, "backup.log");
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
let _logger;
|
|
8
|
+
function getLogger() {
|
|
9
|
+
if (!_logger) {
|
|
10
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
11
|
+
_logger = winston.createLogger({
|
|
12
|
+
level: "info",
|
|
13
|
+
format: winston.format.combine(winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), winston.format.printf(({ timestamp, level, message }) => `${timestamp} [${level}] ${message}`)),
|
|
14
|
+
transports: [new winston.transports.File({ filename: LOG_FILE })],
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return _logger;
|
|
18
|
+
}
|
|
19
|
+
export const logger = {
|
|
20
|
+
info: (message) => getLogger().info(message),
|
|
21
|
+
warn: (message) => getLogger().warn(message),
|
|
22
|
+
error: (message) => getLogger().error(message),
|
|
23
|
+
};
|
package/dist/notify.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { logger } from "./log.js";
|
|
3
|
+
import { errorMessage } from "./utils.js";
|
|
3
4
|
function escapeAppleScript(str) {
|
|
4
5
|
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
5
6
|
}
|
|
6
7
|
export function sendNotification(title, message) {
|
|
7
|
-
if (process.platform !== "darwin")
|
|
8
|
+
if (process.platform !== "darwin") {
|
|
8
9
|
return;
|
|
10
|
+
}
|
|
9
11
|
const escaped = escapeAppleScript(message);
|
|
10
12
|
const titleEscaped = escapeAppleScript(title);
|
|
11
13
|
try {
|
|
@@ -15,7 +17,6 @@ export function sendNotification(title, message) {
|
|
|
15
17
|
], { stdio: "pipe" });
|
|
16
18
|
}
|
|
17
19
|
catch (err) {
|
|
18
|
-
|
|
19
|
-
logger.warn(`Failed to send notification: ${msg}`);
|
|
20
|
+
logger.warn(`Failed to send notification: ${errorMessage(err)}`);
|
|
20
21
|
}
|
|
21
22
|
}
|
package/dist/paths.d.ts
ADDED
package/dist/paths.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
const HOME = os.homedir();
|
|
4
|
+
export const STAGING_DIR = path.join(HOME, ".backdot", "repo");
|
|
5
|
+
export const STAGING_GIT_DIR = path.join(STAGING_DIR, ".git");
|
|
6
|
+
export function machineDir(machine) {
|
|
7
|
+
return path.join(STAGING_DIR, machine);
|
|
8
|
+
}
|
package/dist/plist.js
CHANGED
|
@@ -4,6 +4,14 @@ import path from "node:path";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import ora from "ora";
|
|
6
6
|
import { logger } from "./log.js";
|
|
7
|
+
import { errorMessage } from "./utils.js";
|
|
8
|
+
function escapeXml(s) {
|
|
9
|
+
return s
|
|
10
|
+
.replace(/&/g, "&")
|
|
11
|
+
.replace(/</g, "<")
|
|
12
|
+
.replace(/>/g, ">")
|
|
13
|
+
.replace(/"/g, """);
|
|
14
|
+
}
|
|
7
15
|
const LABEL = "com.backdot.daemon";
|
|
8
16
|
const PLIST_PATH = path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
9
17
|
function getScriptPath() {
|
|
@@ -23,12 +31,12 @@ function buildPlist() {
|
|
|
23
31
|
<string>${LABEL}</string>
|
|
24
32
|
<key>ProgramArguments</key>
|
|
25
33
|
<array>
|
|
26
|
-
<string>${nodePath}</string>
|
|
27
|
-
<string>${scriptPath}</string>
|
|
34
|
+
<string>${escapeXml(nodePath)}</string>
|
|
35
|
+
<string>${escapeXml(scriptPath)}</string>
|
|
28
36
|
<string>--backup</string>
|
|
29
37
|
</array>
|
|
30
38
|
<key>WorkingDirectory</key>
|
|
31
|
-
<string>${workingDir}</string>
|
|
39
|
+
<string>${escapeXml(workingDir)}</string>
|
|
32
40
|
<key>StartCalendarInterval</key>
|
|
33
41
|
<dict>
|
|
34
42
|
<key>Hour</key>
|
|
@@ -37,9 +45,9 @@ function buildPlist() {
|
|
|
37
45
|
<integer>0</integer>
|
|
38
46
|
</dict>
|
|
39
47
|
<key>StandardOutPath</key>
|
|
40
|
-
<string>${logPath}</string>
|
|
48
|
+
<string>${escapeXml(logPath)}</string>
|
|
41
49
|
<key>StandardErrorPath</key>
|
|
42
|
-
<string>${logPath}</string>
|
|
50
|
+
<string>${escapeXml(logPath)}</string>
|
|
43
51
|
<key>RunAtLoad</key>
|
|
44
52
|
<false/>
|
|
45
53
|
</dict>
|
|
@@ -79,7 +87,7 @@ export function setupLaunchd() {
|
|
|
79
87
|
logger.info("Launchd job loaded");
|
|
80
88
|
}
|
|
81
89
|
catch (err) {
|
|
82
|
-
const msg =
|
|
90
|
+
const msg = errorMessage(err);
|
|
83
91
|
spinner.fail(`Failed to load launchd job: ${msg}`);
|
|
84
92
|
console.log();
|
|
85
93
|
logger.error(`Failed to load launchd job: ${msg}`);
|
package/dist/resolve.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import fg from "fast-glob";
|
|
3
3
|
import { logger } from "./log.js";
|
|
4
|
+
import { errorMessage, uniq } from "./utils.js";
|
|
4
5
|
function resolveGlobs(patterns) {
|
|
5
|
-
if (patterns.length === 0)
|
|
6
|
+
if (patterns.length === 0) {
|
|
6
7
|
return [];
|
|
8
|
+
}
|
|
7
9
|
try {
|
|
8
10
|
return fg.sync(patterns, { absolute: true, dot: true, onlyFiles: true });
|
|
9
11
|
}
|
|
10
|
-
catch {
|
|
11
|
-
logger.warn(
|
|
12
|
+
catch (err) {
|
|
13
|
+
logger.warn(`Glob pattern resolution failed: ${errorMessage(err)}`);
|
|
12
14
|
return [];
|
|
13
15
|
}
|
|
14
16
|
}
|
|
@@ -18,7 +20,7 @@ const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
|
|
|
18
20
|
* Skips entries that fail resolution and logs warnings.
|
|
19
21
|
*/
|
|
20
22
|
export function resolveFiles(config) {
|
|
21
|
-
const unique =
|
|
23
|
+
const unique = uniq(resolveGlobs(config.paths));
|
|
22
24
|
return unique.filter((filePath) => {
|
|
23
25
|
try {
|
|
24
26
|
fs.accessSync(filePath, fs.constants.R_OK);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fg from "fast-glob";
|
|
3
|
+
import { logger } from "./log.js";
|
|
4
|
+
import { errorMessage, uniq } from "./utils.js";
|
|
5
|
+
function resolveGlobs(patterns) {
|
|
6
|
+
if (patterns.length === 0) {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
return fg.sync(patterns, { absolute: true, dot: true, onlyFiles: true });
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
logger.warn(`Glob pattern resolution failed: ${errorMessage(err)}`);
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
|
|
18
|
+
/**
|
|
19
|
+
* Resolve all file entries to absolute paths.
|
|
20
|
+
* Skips entries that fail resolution and logs warnings.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveFiles(config) {
|
|
23
|
+
const unique = uniq(resolveGlobs(config.paths));
|
|
24
|
+
return unique.filter((filePath) => {
|
|
25
|
+
try {
|
|
26
|
+
fs.accessSync(filePath, fs.constants.R_OK);
|
|
27
|
+
const stat = fs.statSync(filePath);
|
|
28
|
+
if (!stat.isFile()) {
|
|
29
|
+
logger.warn(`Not a regular file, skipping: ${filePath}`);
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (stat.size > LARGE_FILE_THRESHOLD) {
|
|
33
|
+
const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
|
|
34
|
+
logger.warn(`Large file (${sizeMB} MB), skipping: ${filePath}`);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
logger.warn(`File not readable, skipping: ${filePath}`);
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
package/dist/staging.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
1
|
+
import { STAGING_DIR, STAGING_GIT_DIR, machineDir } from "./paths.js";
|
|
2
|
+
export { STAGING_DIR, STAGING_GIT_DIR, machineDir };
|
|
3
3
|
export declare function getStagedPath(filePath: string, machine: string): string;
|
|
4
4
|
export declare function cleanStaging(machine: string): void;
|
|
5
5
|
export declare function copyToStaging(files: string[], machine: string): void;
|
|
@@ -9,5 +9,5 @@ export interface ComparisonResult {
|
|
|
9
9
|
notBackedUp: string[];
|
|
10
10
|
error?: string;
|
|
11
11
|
}
|
|
12
|
-
export declare function compareFiles(files: string[], machine: string): Promise<ComparisonResult>;
|
|
12
|
+
export declare function compareFiles(files: string[], machine: string, repository: string): Promise<ComparisonResult>;
|
|
13
13
|
export declare function writeRepoReadme(repository: string): void;
|
package/dist/staging.js
CHANGED
|
@@ -4,11 +4,11 @@ import os from "node:os";
|
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
5
|
import { simpleGit } from "simple-git";
|
|
6
6
|
import { logger } from "./log.js";
|
|
7
|
+
import { errorMessage, pluralize } from "./utils.js";
|
|
8
|
+
import { ensureRemoteUrl, getCurrentBranch, gitError } from "./git.js";
|
|
9
|
+
import { STAGING_DIR, STAGING_GIT_DIR, machineDir } from "./paths.js";
|
|
10
|
+
export { STAGING_DIR, STAGING_GIT_DIR, machineDir };
|
|
7
11
|
const HOME = os.homedir();
|
|
8
|
-
export const STAGING_DIR = path.join(HOME, ".backdot", "repo");
|
|
9
|
-
export function machineDir(machine) {
|
|
10
|
-
return path.join(STAGING_DIR, machine);
|
|
11
|
-
}
|
|
12
12
|
export function getStagedPath(filePath, machine) {
|
|
13
13
|
const rel = path.relative(HOME, filePath);
|
|
14
14
|
const destRel = rel.startsWith("..") ? filePath.slice(1) : rel;
|
|
@@ -16,8 +16,9 @@ export function getStagedPath(filePath, machine) {
|
|
|
16
16
|
}
|
|
17
17
|
export function cleanStaging(machine) {
|
|
18
18
|
const dir = machineDir(machine);
|
|
19
|
-
if (!fs.existsSync(dir))
|
|
19
|
+
if (!fs.existsSync(dir)) {
|
|
20
20
|
return;
|
|
21
|
+
}
|
|
21
22
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
22
23
|
logger.info(`Cleaned staging directory for machine "${machine}"`);
|
|
23
24
|
}
|
|
@@ -36,29 +37,29 @@ export function copyToStaging(files, machine) {
|
|
|
36
37
|
logger.warn(`Failed to copy: ${filePath} -> ${dest}`);
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
|
-
logger.info(`Copied ${copied
|
|
40
|
+
logger.info(`Copied ${pluralize(copied, "file")} to staging`);
|
|
40
41
|
}
|
|
41
42
|
function failedComparisonResult(err) {
|
|
42
|
-
|
|
43
|
-
return { backedUp: [], modified: [], notBackedUp: [], error: errorMessage };
|
|
43
|
+
return { backedUp: [], modified: [], notBackedUp: [], error: errorMessage(err) };
|
|
44
44
|
}
|
|
45
|
-
export async function compareFiles(files, machine) {
|
|
46
|
-
if (files.length === 0)
|
|
45
|
+
export async function compareFiles(files, machine, repository) {
|
|
46
|
+
if (files.length === 0) {
|
|
47
47
|
return { backedUp: [], modified: [], notBackedUp: [] };
|
|
48
|
-
|
|
49
|
-
if (!fs.existsSync(
|
|
50
|
-
return failedComparisonResult(new Error(
|
|
48
|
+
}
|
|
49
|
+
if (!fs.existsSync(STAGING_GIT_DIR)) {
|
|
50
|
+
return failedComparisonResult(new Error('Backup repository not found. Run "backdot backup" first.'));
|
|
51
51
|
}
|
|
52
52
|
const git = simpleGit(STAGING_DIR);
|
|
53
53
|
try {
|
|
54
|
+
await ensureRemoteUrl(repository);
|
|
54
55
|
await git.fetch("origin");
|
|
55
56
|
}
|
|
56
57
|
catch (err) {
|
|
57
|
-
return failedComparisonResult(err);
|
|
58
|
+
return failedComparisonResult(gitError(err, repository));
|
|
58
59
|
}
|
|
59
60
|
let branch;
|
|
60
61
|
try {
|
|
61
|
-
branch =
|
|
62
|
+
branch = await getCurrentBranch(git);
|
|
62
63
|
}
|
|
63
64
|
catch (err) {
|
|
64
65
|
return failedComparisonResult(err);
|
|
@@ -80,8 +81,9 @@ export async function compareFiles(files, machine) {
|
|
|
80
81
|
}
|
|
81
82
|
let sourceHashes;
|
|
82
83
|
try {
|
|
83
|
-
const hashOutput = execFileSync("git", ["hash-object", "--"
|
|
84
|
+
const hashOutput = execFileSync("git", ["hash-object", "--stdin-paths"], {
|
|
84
85
|
encoding: "utf-8",
|
|
86
|
+
input: files.join("\n") + "\n",
|
|
85
87
|
});
|
|
86
88
|
sourceHashes = hashOutput.trim().split("\n");
|
|
87
89
|
}
|
|
@@ -106,12 +108,12 @@ export async function compareFiles(files, machine) {
|
|
|
106
108
|
function repoReadme(repository) {
|
|
107
109
|
return `# Backdot Backup
|
|
108
110
|
|
|
109
|
-
This repository contains
|
|
111
|
+
This repository contains files backed up automatically using [backdot](https://github.com/sorenlouv/backdot).
|
|
110
112
|
|
|
111
113
|
## Restore
|
|
112
114
|
|
|
113
115
|
\`\`\`bash
|
|
114
|
-
npx backdot
|
|
116
|
+
npx backdot restore ${repository}
|
|
115
117
|
\`\`\`
|
|
116
118
|
|
|
117
119
|
For full documentation, configuration options, and scheduling, see the [official README](https://github.com/sorenlouv/backdot).
|
package/dist/utils.d.ts
ADDED
package/dist/utils.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function errorMessage(err) {
|
|
2
|
+
return err instanceof Error ? err.message : String(err);
|
|
3
|
+
}
|
|
4
|
+
export function uniq(items) {
|
|
5
|
+
return [...new Set(items)];
|
|
6
|
+
}
|
|
7
|
+
export function pluralize(count, word) {
|
|
8
|
+
return `${count} ${word}${count !== 1 ? "s" : ""}`;
|
|
9
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backdot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Lightweight CLI to backup dotfiles and gitignored files to a Git repo on a daily schedule",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,13 +32,16 @@
|
|
|
32
32
|
"lint:fix": "eslint src/ --fix",
|
|
33
33
|
"format": "prettier --write src/",
|
|
34
34
|
"format:check": "prettier --check src/",
|
|
35
|
-
"test": "vitest run --exclude src/e2e.test.ts",
|
|
35
|
+
"test": "vitest run --exclude src/e2e.test.ts --exclude src/git.integration.test.ts",
|
|
36
|
+
"test:integration": "vitest run src/git.integration.test.ts",
|
|
36
37
|
"test:e2e": "npm run build && vitest run src/e2e.test.ts",
|
|
38
|
+
"test:all": "npm run build && vitest run",
|
|
37
39
|
"test:watch": "vitest",
|
|
38
40
|
"prepare": "husky"
|
|
39
41
|
},
|
|
40
42
|
"dependencies": {
|
|
41
43
|
"@inquirer/prompts": "^8.3.0",
|
|
44
|
+
"cac": "^7.0.0",
|
|
42
45
|
"chalk": "^5.6.2",
|
|
43
46
|
"fast-glob": "^3.3.3",
|
|
44
47
|
"ora": "^9.3.0",
|