backdot 1.5.0 → 1.6.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 +11 -8
- package/dist/cli.js +63 -33
- package/dist/commands/history.d.ts +1 -0
- package/dist/commands/history.js +28 -0
- package/dist/commands/restore.d.ts +5 -1
- package/dist/commands/restore.js +28 -23
- package/dist/git.d.ts +17 -1
- package/dist/git.js +56 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,14 +51,17 @@ Prefix a pattern with `!` to exclude matching files:
|
|
|
51
51
|
|
|
52
52
|
## Commands
|
|
53
53
|
|
|
54
|
-
| Command
|
|
55
|
-
|
|
|
56
|
-
| `--init`
|
|
57
|
-
| `--backup`
|
|
58
|
-
| `--restore`
|
|
59
|
-
| `--
|
|
60
|
-
| `--
|
|
61
|
-
| `--
|
|
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 |
|
|
62
65
|
|
|
63
66
|
## Development
|
|
64
67
|
|
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
4
5
|
import chalk from "chalk";
|
|
5
6
|
import { CONFIG_PATH } from "./config.js";
|
|
6
7
|
import { backup } from "./commands/backup.js";
|
|
7
8
|
import { status } from "./commands/status.js";
|
|
8
9
|
import { schedule, unschedule } from "./commands/schedule.js";
|
|
9
10
|
import { restore } from "./commands/restore.js";
|
|
11
|
+
import { history } from "./commands/history.js";
|
|
10
12
|
import { init } from "./commands/init.js";
|
|
11
13
|
import { logger } from "./log.js";
|
|
12
14
|
import { sendNotification } from "./notify.js";
|
|
@@ -20,55 +22,83 @@ function getVersion() {
|
|
|
20
22
|
return "unknown";
|
|
21
23
|
}
|
|
22
24
|
}
|
|
25
|
+
function printHelp() {
|
|
26
|
+
console.log();
|
|
27
|
+
console.log(" Usage: backdot <command>");
|
|
28
|
+
console.log();
|
|
29
|
+
console.log(" Commands:");
|
|
30
|
+
console.log();
|
|
31
|
+
console.log(" --init Set up backdot for the first time");
|
|
32
|
+
console.log(" --backup Run a backup now");
|
|
33
|
+
console.log(" --restore [url] Restore files from the backup repo");
|
|
34
|
+
console.log(" --restore [url] --commit <sha> Restore from a specific backup commit");
|
|
35
|
+
console.log(" --restore [url] --yes (-y) Accept defaults without prompting");
|
|
36
|
+
console.log(" --history [url] Browse and restore a previous backup");
|
|
37
|
+
console.log(" --schedule Install daily backup schedule (macOS launchd)");
|
|
38
|
+
console.log(" --unschedule Remove the daily backup schedule");
|
|
39
|
+
console.log(" --status Show schedule and resolved files");
|
|
40
|
+
console.log(" --version Show version");
|
|
41
|
+
console.log();
|
|
42
|
+
}
|
|
23
43
|
async function main() {
|
|
24
|
-
|
|
25
|
-
|
|
44
|
+
let values;
|
|
45
|
+
let positionals;
|
|
46
|
+
try {
|
|
47
|
+
({ values, positionals } = parseArgs({
|
|
48
|
+
args: process.argv.slice(2),
|
|
49
|
+
options: {
|
|
50
|
+
init: { type: "boolean" },
|
|
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);
|
|
71
|
+
}
|
|
26
72
|
try {
|
|
27
|
-
if (
|
|
73
|
+
if (values.init) {
|
|
28
74
|
init();
|
|
29
75
|
}
|
|
30
|
-
else if (
|
|
76
|
+
else if (values.backup) {
|
|
31
77
|
await backup();
|
|
32
78
|
}
|
|
33
|
-
else if (
|
|
79
|
+
else if (values.schedule) {
|
|
34
80
|
schedule();
|
|
35
81
|
}
|
|
36
|
-
else if (
|
|
82
|
+
else if (values.unschedule) {
|
|
37
83
|
unschedule();
|
|
38
84
|
}
|
|
39
|
-
else if (
|
|
85
|
+
else if (values.status) {
|
|
40
86
|
await status();
|
|
41
87
|
}
|
|
42
|
-
else if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
await restore(url);
|
|
88
|
+
else if (values.restore) {
|
|
89
|
+
await restore(positionals[0], values.commit, {
|
|
90
|
+
yes: !!values.yes,
|
|
91
|
+
});
|
|
48
92
|
}
|
|
49
|
-
else if (
|
|
93
|
+
else if (values.history) {
|
|
94
|
+
await history(positionals[0]);
|
|
95
|
+
}
|
|
96
|
+
else if (values.version) {
|
|
50
97
|
console.log(getVersion());
|
|
51
98
|
}
|
|
52
99
|
else {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
console.log();
|
|
56
|
-
console.log(" Commands:");
|
|
57
|
-
console.log();
|
|
58
|
-
console.log(" --init Set up backdot for the first time");
|
|
59
|
-
console.log(" --backup Run a backup now");
|
|
60
|
-
console.log(" --restore [url] Restore files from the backup repo");
|
|
61
|
-
console.log(" --schedule Install daily backup schedule (macOS launchd)");
|
|
62
|
-
console.log(" --unschedule Remove the daily backup schedule");
|
|
63
|
-
console.log(" --status Show schedule and resolved files");
|
|
64
|
-
console.log(" --version Show version");
|
|
65
|
-
console.log();
|
|
66
|
-
if (command && command !== "--help") {
|
|
67
|
-
console.error(` Unknown command: ${command}`);
|
|
68
|
-
console.log();
|
|
69
|
-
process.exit(1);
|
|
70
|
-
}
|
|
71
|
-
else if (!fs.existsSync(CONFIG_PATH)) {
|
|
100
|
+
printHelp();
|
|
101
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
72
102
|
console.log(` No config found. Run ${chalk.bold("backdot --init")} to get started.`);
|
|
73
103
|
console.log();
|
|
74
104
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function history(repoUrl?: string): Promise<void>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import ora from "ora";
|
|
2
|
+
import { select } from "@inquirer/prompts";
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
4
|
+
import { gitPull, gitLog } from "../git.js";
|
|
5
|
+
import { restore } from "./restore.js";
|
|
6
|
+
import { logger } from "../log.js";
|
|
7
|
+
export async function history(repoUrl) {
|
|
8
|
+
logger.info("Starting history");
|
|
9
|
+
const repository = repoUrl ?? loadConfig().repository;
|
|
10
|
+
const spinner = ora("Fetching backup history").start();
|
|
11
|
+
await gitPull(repository);
|
|
12
|
+
const commits = await gitLog();
|
|
13
|
+
spinner.stop();
|
|
14
|
+
if (commits.length === 0) {
|
|
15
|
+
console.log("\n No backup history found.\n");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const selected = await select({
|
|
19
|
+
message: "Select a backup to restore from:",
|
|
20
|
+
loop: false,
|
|
21
|
+
choices: commits.map((c) => ({
|
|
22
|
+
name: `${c.hash.slice(0, 7)} ${c.date.split("T")[0]} ${c.message}`,
|
|
23
|
+
value: c.hash,
|
|
24
|
+
})),
|
|
25
|
+
});
|
|
26
|
+
console.log();
|
|
27
|
+
await restore(repoUrl, selected);
|
|
28
|
+
}
|
package/dist/commands/restore.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import ora from "ora";
|
|
5
|
-
import { checkbox, select } from "@inquirer/prompts";
|
|
5
|
+
import { checkbox, select, Separator } from "@inquirer/prompts";
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
7
7
|
import { gitPull } from "../git.js";
|
|
8
8
|
import { STAGING_DIR, machineDir } from "../staging.js";
|
|
@@ -25,13 +25,13 @@ function listMachines() {
|
|
|
25
25
|
.filter((e) => e.isDirectory() && e.name !== ".git")
|
|
26
26
|
.map((e) => e.name);
|
|
27
27
|
}
|
|
28
|
-
async function resolveRepoAndMachine(repoUrl) {
|
|
28
|
+
async function resolveRepoAndMachine(repoUrl, commit) {
|
|
29
29
|
if (!repoUrl) {
|
|
30
30
|
const config = loadConfig();
|
|
31
31
|
return { repository: config.repository, machine: config.machine };
|
|
32
32
|
}
|
|
33
33
|
const spinner = ora("Cloning backup repository").start();
|
|
34
|
-
await gitPull(repoUrl);
|
|
34
|
+
await gitPull(repoUrl, commit);
|
|
35
35
|
spinner.stop();
|
|
36
36
|
const machines = listMachines();
|
|
37
37
|
if (machines.length === 0) {
|
|
@@ -44,18 +44,19 @@ async function resolveRepoAndMachine(repoUrl) {
|
|
|
44
44
|
else {
|
|
45
45
|
machine = await select({
|
|
46
46
|
message: "Multiple machines found. Which one do you want to restore?",
|
|
47
|
+
loop: false,
|
|
47
48
|
choices: machines.map((m) => ({ name: m, value: m })),
|
|
48
49
|
});
|
|
49
50
|
}
|
|
50
51
|
return { repository: repoUrl, machine };
|
|
51
52
|
}
|
|
52
|
-
export async function restore(repoUrl) {
|
|
53
|
+
export async function restore(repoUrl, commit, options = {}) {
|
|
53
54
|
logger.info("Starting restore");
|
|
54
|
-
const { repository, machine } = await resolveRepoAndMachine(repoUrl);
|
|
55
|
+
const { repository, machine } = await resolveRepoAndMachine(repoUrl, commit);
|
|
55
56
|
const spinner = ora("Fetching latest backup").start();
|
|
56
57
|
const baseDir = machineDir(machine);
|
|
57
58
|
if (!repoUrl) {
|
|
58
|
-
await gitPull(repository);
|
|
59
|
+
await gitPull(repository, commit);
|
|
59
60
|
}
|
|
60
61
|
spinner.text = "Resolving files";
|
|
61
62
|
if (!fs.existsSync(baseDir)) {
|
|
@@ -86,25 +87,29 @@ export async function restore(repoUrl) {
|
|
|
86
87
|
logger.info(`${fresh.length} new, ${existing.length} already exist`);
|
|
87
88
|
spinner.stop();
|
|
88
89
|
console.log();
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
for (const f of fresh) {
|
|
93
|
-
console.log(` ${f.rel}`);
|
|
94
|
-
}
|
|
95
|
-
console.log();
|
|
90
|
+
let toRestore;
|
|
91
|
+
if (options.yes) {
|
|
92
|
+
toRestore = fresh;
|
|
96
93
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
name: f.rel,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
94
|
+
else {
|
|
95
|
+
const choices = [];
|
|
96
|
+
if (fresh.length > 0) {
|
|
97
|
+
choices.push(new Separator(`── New files (${fresh.length}) ──`));
|
|
98
|
+
for (const f of fresh) {
|
|
99
|
+
choices.push({ name: f.rel, value: f, checked: true });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (existing.length > 0) {
|
|
103
|
+
choices.push(new Separator(`── Existing files — will overwrite (${existing.length}) ──`));
|
|
104
|
+
for (const f of existing) {
|
|
105
|
+
choices.push({ name: f.rel, value: f, checked: false });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
toRestore = await checkbox({
|
|
109
|
+
message: "Select files to restore:",
|
|
110
|
+
loop: false,
|
|
111
|
+
choices,
|
|
106
112
|
});
|
|
107
|
-
toRestore = [...fresh, ...selected];
|
|
108
113
|
console.log();
|
|
109
114
|
}
|
|
110
115
|
if (toRestore.length === 0) {
|
package/dist/git.d.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
interface FileChangeSummary {
|
|
2
|
+
created: string[];
|
|
3
|
+
deleted: string[];
|
|
4
|
+
modified: string[];
|
|
5
|
+
renamed: Array<{
|
|
6
|
+
from: string;
|
|
7
|
+
to: string;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
export declare function buildCommitMessage(changes: FileChangeSummary, maxLen?: number): string;
|
|
11
|
+
export declare function gitPull(repository: string, commit?: string): Promise<void>;
|
|
12
|
+
export declare function gitLog(limit?: number): Promise<Array<{
|
|
13
|
+
hash: string;
|
|
14
|
+
date: string;
|
|
15
|
+
message: string;
|
|
16
|
+
}>>;
|
|
2
17
|
export declare function gitCommitAndPush(): Promise<{
|
|
3
18
|
commitUrl: string | null;
|
|
4
19
|
} | null>;
|
|
20
|
+
export {};
|
package/dist/git.js
CHANGED
|
@@ -5,12 +5,50 @@ import { simpleGit, CleanOptions } from "simple-git";
|
|
|
5
5
|
import { logger } from "./log.js";
|
|
6
6
|
import { STAGING_DIR } from "./staging.js";
|
|
7
7
|
import { getCommitUrl } from "./commitUrl.js";
|
|
8
|
-
export
|
|
8
|
+
export function buildCommitMessage(changes, maxLen = 250) {
|
|
9
|
+
const unique = (paths) => [...new Set(paths.map((f) => path.basename(f)))];
|
|
10
|
+
const removed = unique(changes.deleted);
|
|
11
|
+
const added = unique(changes.created);
|
|
12
|
+
const modified = unique([...changes.modified, ...changes.renamed.map((r) => r.to)]);
|
|
13
|
+
const categories = [
|
|
14
|
+
{ label: "removed", files: removed },
|
|
15
|
+
{ label: "added", files: added },
|
|
16
|
+
{ label: "modified", files: modified },
|
|
17
|
+
].filter((c) => c.files.length > 0);
|
|
18
|
+
if (categories.length === 0) {
|
|
19
|
+
return "backup";
|
|
20
|
+
}
|
|
21
|
+
function format(cat, listFiles) {
|
|
22
|
+
if (listFiles) {
|
|
23
|
+
return `${cat.label}: ${cat.files.join(", ")}`;
|
|
24
|
+
}
|
|
25
|
+
const n = cat.files.length;
|
|
26
|
+
return `${cat.label}: ${n} file${n !== 1 ? "s" : ""}`;
|
|
27
|
+
}
|
|
28
|
+
function build(listFlags) {
|
|
29
|
+
return categories.map((cat, i) => format(cat, listFlags[i])).join("; ");
|
|
30
|
+
}
|
|
31
|
+
const flags = categories.map(() => true);
|
|
32
|
+
let msg = build(flags);
|
|
33
|
+
if (msg.length <= maxLen)
|
|
34
|
+
return msg;
|
|
35
|
+
for (const label of ["modified", "added", "removed"]) {
|
|
36
|
+
const idx = categories.findIndex((c) => c.label === label);
|
|
37
|
+
if (idx !== -1 && flags[idx]) {
|
|
38
|
+
flags[idx] = false;
|
|
39
|
+
msg = build(flags);
|
|
40
|
+
if (msg.length <= maxLen)
|
|
41
|
+
return msg;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return msg.slice(0, maxLen - 3) + "...";
|
|
45
|
+
}
|
|
46
|
+
export async function gitPull(repository, commit) {
|
|
9
47
|
if (fs.existsSync(path.join(STAGING_DIR, ".git"))) {
|
|
10
48
|
const git = simpleGit(STAGING_DIR);
|
|
11
49
|
await git.fetch("origin");
|
|
12
|
-
const
|
|
13
|
-
await git.reset(["--hard",
|
|
50
|
+
const target = commit ?? `origin/${await git.revparse(["--abbrev-ref", "HEAD"])}`;
|
|
51
|
+
await git.reset(["--hard", target]);
|
|
14
52
|
await git.clean(CleanOptions.FORCE, ["-d"]);
|
|
15
53
|
}
|
|
16
54
|
else {
|
|
@@ -23,9 +61,23 @@ export async function gitPull(repository) {
|
|
|
23
61
|
await git.init();
|
|
24
62
|
await git.addRemote("origin", repository);
|
|
25
63
|
}
|
|
64
|
+
if (commit) {
|
|
65
|
+
const git = simpleGit(STAGING_DIR);
|
|
66
|
+
await git.reset(["--hard", commit]);
|
|
67
|
+
await git.clean(CleanOptions.FORCE, ["-d"]);
|
|
68
|
+
}
|
|
26
69
|
}
|
|
27
70
|
logger.info("Synced staging directory from remote");
|
|
28
71
|
}
|
|
72
|
+
export async function gitLog(limit = 20) {
|
|
73
|
+
const git = simpleGit(STAGING_DIR);
|
|
74
|
+
const log = await git.log({ maxCount: limit });
|
|
75
|
+
return log.all.map((entry) => ({
|
|
76
|
+
hash: entry.hash,
|
|
77
|
+
date: entry.date,
|
|
78
|
+
message: entry.message,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
29
81
|
export async function gitCommitAndPush() {
|
|
30
82
|
const git = simpleGit(STAGING_DIR);
|
|
31
83
|
await git.add(".");
|
|
@@ -34,8 +86,7 @@ export async function gitCommitAndPush() {
|
|
|
34
86
|
logger.info("No changes to commit");
|
|
35
87
|
return null;
|
|
36
88
|
}
|
|
37
|
-
const
|
|
38
|
-
const message = `Automated backup: ${date}`;
|
|
89
|
+
const message = buildCommitMessage(status);
|
|
39
90
|
await git.commit(message);
|
|
40
91
|
await pRetry(async () => git.push(["-u", "origin", "HEAD"]), {
|
|
41
92
|
retries: 5,
|