backdot 1.1.0 → 1.2.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 +6 -2
- package/dist/cli.js +47 -14
- package/dist/commitUrl.d.ts +1 -0
- package/dist/commitUrl.js +18 -0
- package/dist/git.d.ts +3 -1
- package/dist/git.js +6 -1
- package/dist/restore.d.ts +1 -1
- package/dist/restore.js +33 -7
- package/dist/staging.d.ts +8 -1
- package/dist/staging.js +80 -16
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="logo-small.png" alt="backdot" width="400" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<h1 align="center">backdot</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">Automated backup of important files (configs, dotfiles, gitignored files) to your own private Git repo.</p>
|
|
4
8
|
|
|
5
9
|
## Getting started
|
|
6
10
|
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
5
|
+
import chalk from "chalk";
|
|
4
6
|
import ora from "ora";
|
|
5
7
|
import { loadConfig, CONFIG_PATH } from "./config.js";
|
|
6
8
|
import { resolveFiles } from "./resolve.js";
|
|
7
|
-
import { cleanStaging, copyToStaging, writeRepoReadme } from "./staging.js";
|
|
9
|
+
import { cleanStaging, copyToStaging, writeRepoReadme, compareFiles } from "./staging.js";
|
|
8
10
|
import { gitPull, gitCommitAndPush } from "./git.js";
|
|
9
11
|
import { restore } from "./restore.js";
|
|
10
12
|
import { setupLaunchd, uninstallLaunchd, isScheduled } from "./plist.js";
|
|
@@ -38,10 +40,13 @@ async function backup() {
|
|
|
38
40
|
spinner.text = `Copying ${files.length} file(s) to staging`;
|
|
39
41
|
cleanStaging(config.machine);
|
|
40
42
|
copyToStaging(files, config.machine);
|
|
41
|
-
writeRepoReadme();
|
|
43
|
+
writeRepoReadme(config.repository);
|
|
42
44
|
spinner.text = "Pushing to remote";
|
|
43
|
-
await gitCommitAndPush();
|
|
44
|
-
|
|
45
|
+
const result = await gitCommitAndPush();
|
|
46
|
+
const successMsg = result?.commitUrl
|
|
47
|
+
? `Backup complete → ${result.commitUrl}`
|
|
48
|
+
: "Backup complete";
|
|
49
|
+
spinner.succeed(successMsg);
|
|
45
50
|
console.log();
|
|
46
51
|
}
|
|
47
52
|
catch (err) {
|
|
@@ -50,7 +55,11 @@ async function backup() {
|
|
|
50
55
|
}
|
|
51
56
|
logger.info("Backup complete");
|
|
52
57
|
}
|
|
53
|
-
function
|
|
58
|
+
function tildePath(filePath) {
|
|
59
|
+
const home = os.homedir();
|
|
60
|
+
return filePath.startsWith(home) ? "~" + filePath.slice(home.length) : filePath;
|
|
61
|
+
}
|
|
62
|
+
async function status() {
|
|
54
63
|
const scheduled = isScheduled();
|
|
55
64
|
console.log();
|
|
56
65
|
console.log(` Schedule: ${scheduled ? "active (daily at 02:00)" : "not active"}`);
|
|
@@ -63,15 +72,39 @@ function status() {
|
|
|
63
72
|
const userFiles = resolveFiles(config.files);
|
|
64
73
|
if (userFiles.length === 0) {
|
|
65
74
|
spinner.warn("No files resolved. Check your ~/.backdot.json entries.");
|
|
75
|
+
console.log();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const files = [...userFiles, CONFIG_PATH];
|
|
79
|
+
spinner.text = "Comparing with remote backup";
|
|
80
|
+
const { backedUp, modified, notBackedUp } = await compareFiles(files, config.machine);
|
|
81
|
+
spinner.stop();
|
|
82
|
+
if (modified.length === 0 && notBackedUp.length === 0) {
|
|
83
|
+
console.log(chalk.green(` All ${files.length} file(s) are backed up ✓`));
|
|
66
84
|
}
|
|
67
85
|
else {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
console.log(
|
|
86
|
+
if (modified.length > 0) {
|
|
87
|
+
console.log(chalk.yellow(` Modified since last backup (${modified.length}):`));
|
|
88
|
+
for (const f of modified) {
|
|
89
|
+
console.log(` ${tildePath(f)}`);
|
|
90
|
+
}
|
|
91
|
+
console.log();
|
|
92
|
+
}
|
|
93
|
+
if (notBackedUp.length > 0) {
|
|
94
|
+
console.log(chalk.red(` Not yet backed up (${notBackedUp.length}):`));
|
|
95
|
+
for (const f of notBackedUp) {
|
|
96
|
+
console.log(` ${tildePath(f)}`);
|
|
97
|
+
}
|
|
98
|
+
console.log();
|
|
99
|
+
}
|
|
100
|
+
if (backedUp.length > 0) {
|
|
101
|
+
console.log(chalk.green(` Backed up (${backedUp.length}):`));
|
|
102
|
+
for (const f of backedUp) {
|
|
103
|
+
console.log(` ${tildePath(f)}`);
|
|
104
|
+
}
|
|
105
|
+
console.log();
|
|
74
106
|
}
|
|
107
|
+
console.log(` Run ${chalk.bold("backdot --backup")} to back up all changes.`);
|
|
75
108
|
}
|
|
76
109
|
}
|
|
77
110
|
catch (err) {
|
|
@@ -102,10 +135,10 @@ async function main() {
|
|
|
102
135
|
uninstallLaunchd();
|
|
103
136
|
}
|
|
104
137
|
else if (command === "--status") {
|
|
105
|
-
status();
|
|
138
|
+
await status();
|
|
106
139
|
}
|
|
107
140
|
else if (command === "--restore") {
|
|
108
|
-
await restore();
|
|
141
|
+
await restore(args[1]);
|
|
109
142
|
}
|
|
110
143
|
else if (command === "--version") {
|
|
111
144
|
console.log(getVersion());
|
|
@@ -117,7 +150,7 @@ async function main() {
|
|
|
117
150
|
console.log(" Commands:");
|
|
118
151
|
console.log();
|
|
119
152
|
console.log(" --backup Run a backup now");
|
|
120
|
-
console.log(" --restore
|
|
153
|
+
console.log(" --restore [url] Restore files from the backup repo");
|
|
121
154
|
console.log(" --schedule Install daily backup schedule (macOS launchd)");
|
|
122
155
|
console.log(" --unschedule Remove the daily backup schedule");
|
|
123
156
|
console.log(" --status Show schedule and resolved files");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getCommitUrl(remoteUrl: string, sha: string): string | null;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const PROVIDERS = {
|
|
2
|
+
"github.com": (p, sha) => `https://github.com/${p}/commit/${sha}`,
|
|
3
|
+
"gitlab.com": (p, sha) => `https://gitlab.com/${p}/-/commit/${sha}`,
|
|
4
|
+
"bitbucket.org": (p, sha) => `https://bitbucket.org/${p}/commits/${sha}`,
|
|
5
|
+
};
|
|
6
|
+
export function getCommitUrl(remoteUrl, sha) {
|
|
7
|
+
for (const [host, buildUrl] of Object.entries(PROVIDERS)) {
|
|
8
|
+
const idx = remoteUrl.indexOf(host);
|
|
9
|
+
if (idx === -1)
|
|
10
|
+
continue;
|
|
11
|
+
let repoPath = remoteUrl.slice(idx + host.length + 1).trim();
|
|
12
|
+
if (repoPath.endsWith(".git")) {
|
|
13
|
+
repoPath = repoPath.slice(0, -4);
|
|
14
|
+
}
|
|
15
|
+
return buildUrl(repoPath, sha);
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
package/dist/git.d.ts
CHANGED
package/dist/git.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { simpleGit, CleanOptions } from "simple-git";
|
|
4
4
|
import { logger } from "./log.js";
|
|
5
5
|
import { STAGING_DIR } from "./staging.js";
|
|
6
|
+
import { getCommitUrl } from "./commitUrl.js";
|
|
6
7
|
export async function gitPull(repository) {
|
|
7
8
|
if (fs.existsSync(path.join(STAGING_DIR, ".git"))) {
|
|
8
9
|
const git = simpleGit(STAGING_DIR);
|
|
@@ -30,11 +31,15 @@ export async function gitCommitAndPush() {
|
|
|
30
31
|
const status = await git.status();
|
|
31
32
|
if (status.isClean()) {
|
|
32
33
|
logger.info("No changes to commit");
|
|
33
|
-
return;
|
|
34
|
+
return null;
|
|
34
35
|
}
|
|
35
36
|
const date = new Date().toISOString().split("T")[0];
|
|
36
37
|
const message = `Automated backup: ${date}`;
|
|
37
38
|
await git.commit(message);
|
|
38
39
|
await git.push(["-u", "origin", "HEAD"]);
|
|
39
40
|
logger.info(`Committed and pushed: ${message}`);
|
|
41
|
+
const sha = await git.revparse(["HEAD"]);
|
|
42
|
+
const remoteUrl = (await git.remote(["get-url", "origin"])) ?? "";
|
|
43
|
+
const commitUrl = getCommitUrl(remoteUrl, sha);
|
|
44
|
+
return { commitUrl };
|
|
40
45
|
}
|
package/dist/restore.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function restore(): Promise<void>;
|
|
1
|
+
export declare function restore(repoUrl?: string): Promise<void>;
|
package/dist/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 } from "@inquirer/prompts";
|
|
5
|
+
import { checkbox, select } 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";
|
|
@@ -31,22 +31,48 @@ function listMachines() {
|
|
|
31
31
|
.filter((e) => e.isDirectory() && e.name !== ".git")
|
|
32
32
|
.map((e) => e.name);
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
async function resolveRepoAndMachine(repoUrl) {
|
|
35
|
+
if (!repoUrl) {
|
|
36
|
+
const config = loadConfig();
|
|
37
|
+
return { repository: config.repository, machine: config.machine };
|
|
38
|
+
}
|
|
39
|
+
const spinner = ora("Cloning backup repository").start();
|
|
40
|
+
await gitPull(repoUrl);
|
|
41
|
+
spinner.stop();
|
|
42
|
+
const machines = listMachines();
|
|
43
|
+
if (machines.length === 0) {
|
|
44
|
+
throw new Error("The backup repository is empty (no machine directories found).");
|
|
45
|
+
}
|
|
46
|
+
let machine;
|
|
47
|
+
if (machines.length === 1) {
|
|
48
|
+
machine = machines[0];
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
machine = await select({
|
|
52
|
+
message: "Multiple machines found. Which one do you want to restore?",
|
|
53
|
+
choices: machines.map((m) => ({ name: m, value: m })),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return { repository: repoUrl, machine };
|
|
57
|
+
}
|
|
58
|
+
export async function restore(repoUrl) {
|
|
35
59
|
logger.info("Starting restore");
|
|
36
|
-
const
|
|
37
|
-
const baseDir = machineDir(config.machine);
|
|
60
|
+
const { repository, machine } = await resolveRepoAndMachine(repoUrl);
|
|
38
61
|
const spinner = ora("Fetching latest backup").start();
|
|
39
|
-
|
|
62
|
+
const baseDir = machineDir(machine);
|
|
63
|
+
if (!repoUrl) {
|
|
64
|
+
await gitPull(repository);
|
|
65
|
+
}
|
|
40
66
|
spinner.text = "Resolving files";
|
|
41
67
|
if (!fs.existsSync(baseDir)) {
|
|
42
68
|
spinner.stop();
|
|
43
69
|
const available = listMachines();
|
|
44
70
|
if (available.length > 0) {
|
|
45
|
-
console.log(`\n No backup found for machine "${
|
|
71
|
+
console.log(`\n No backup found for machine "${machine}".`);
|
|
46
72
|
console.log(` Available machines: ${available.join(", ")}\n`);
|
|
47
73
|
}
|
|
48
74
|
else {
|
|
49
|
-
console.log(`\n No backup found for machine "${
|
|
75
|
+
console.log(`\n No backup found for machine "${machine}". The repository is empty.\n`);
|
|
50
76
|
}
|
|
51
77
|
return;
|
|
52
78
|
}
|
package/dist/staging.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export declare const STAGING_DIR: string;
|
|
2
2
|
export declare function machineDir(machine: string): string;
|
|
3
|
+
export declare function getStagedPath(filePath: string, machine: string): string;
|
|
3
4
|
export declare function cleanStaging(machine: string): void;
|
|
4
5
|
export declare function copyToStaging(files: string[], machine: string): void;
|
|
5
|
-
export
|
|
6
|
+
export interface ComparisonResult {
|
|
7
|
+
backedUp: string[];
|
|
8
|
+
modified: string[];
|
|
9
|
+
notBackedUp: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare function compareFiles(files: string[], machine: string): Promise<ComparisonResult>;
|
|
12
|
+
export declare function writeRepoReadme(repository: string): void;
|
package/dist/staging.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { simpleGit } from "simple-git";
|
|
4
6
|
import { logger } from "./log.js";
|
|
5
7
|
const HOME = os.homedir();
|
|
6
8
|
export const STAGING_DIR = path.join(HOME, ".backdot", "repo");
|
|
7
9
|
export function machineDir(machine) {
|
|
8
10
|
return path.join(STAGING_DIR, machine);
|
|
9
11
|
}
|
|
12
|
+
export function getStagedPath(filePath, machine) {
|
|
13
|
+
const rel = path.relative(HOME, filePath);
|
|
14
|
+
const destRel = rel.startsWith("..") ? filePath.slice(1) : rel;
|
|
15
|
+
return path.join(machineDir(machine), destRel);
|
|
16
|
+
}
|
|
10
17
|
export function cleanStaging(machine) {
|
|
11
18
|
const dir = machineDir(machine);
|
|
12
19
|
if (!fs.existsSync(dir))
|
|
@@ -19,9 +26,7 @@ export function copyToStaging(files, machine) {
|
|
|
19
26
|
fs.mkdirSync(dir, { recursive: true });
|
|
20
27
|
let copied = 0;
|
|
21
28
|
for (const filePath of files) {
|
|
22
|
-
const
|
|
23
|
-
const destRel = rel.startsWith("..") ? filePath.slice(1) : rel;
|
|
24
|
-
const dest = path.join(dir, destRel);
|
|
29
|
+
const dest = getStagedPath(filePath, machine);
|
|
25
30
|
try {
|
|
26
31
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
27
32
|
fs.copyFileSync(filePath, dest);
|
|
@@ -33,27 +38,86 @@ export function copyToStaging(files, machine) {
|
|
|
33
38
|
}
|
|
34
39
|
logger.info(`Copied ${copied} file(s) to staging`);
|
|
35
40
|
}
|
|
36
|
-
|
|
41
|
+
export async function compareFiles(files, machine) {
|
|
42
|
+
const result = { backedUp: [], modified: [], notBackedUp: [] };
|
|
43
|
+
if (files.length === 0)
|
|
44
|
+
return result;
|
|
45
|
+
const gitDir = path.join(STAGING_DIR, ".git");
|
|
46
|
+
if (!fs.existsSync(gitDir)) {
|
|
47
|
+
result.notBackedUp.push(...files);
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
const git = simpleGit(STAGING_DIR);
|
|
51
|
+
await git.fetch("origin");
|
|
52
|
+
let branch;
|
|
53
|
+
try {
|
|
54
|
+
branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
result.notBackedUp.push(...files);
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
const committedHashes = new Map();
|
|
61
|
+
try {
|
|
62
|
+
const treeOutput = execFileSync("git", ["ls-tree", "-r", `origin/${branch}`, `${machine}/`], {
|
|
63
|
+
encoding: "utf-8",
|
|
64
|
+
cwd: STAGING_DIR,
|
|
65
|
+
});
|
|
66
|
+
for (const line of treeOutput.split("\n")) {
|
|
67
|
+
if (!line)
|
|
68
|
+
continue;
|
|
69
|
+
const match = line.match(/^\d+ blob ([0-9a-f]+)\t(.+)$/);
|
|
70
|
+
if (match) {
|
|
71
|
+
committedHashes.set(match[2], match[1]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
result.notBackedUp.push(...files);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
let sourceHashes;
|
|
80
|
+
try {
|
|
81
|
+
const hashOutput = execFileSync("git", ["hash-object", "--", ...files], {
|
|
82
|
+
encoding: "utf-8",
|
|
83
|
+
});
|
|
84
|
+
sourceHashes = hashOutput.trim().split("\n");
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
result.notBackedUp.push(...files);
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
for (let i = 0; i < files.length; i++) {
|
|
91
|
+
const repoRelPath = path.relative(STAGING_DIR, getStagedPath(files[i], machine));
|
|
92
|
+
const committedHash = committedHashes.get(repoRelPath);
|
|
93
|
+
const sourceHash = sourceHashes[i];
|
|
94
|
+
if (!committedHash) {
|
|
95
|
+
result.notBackedUp.push(files[i]);
|
|
96
|
+
}
|
|
97
|
+
else if (committedHash === sourceHash) {
|
|
98
|
+
result.backedUp.push(files[i]);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
result.modified.push(files[i]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
function repoReadme(repository) {
|
|
107
|
+
return `# Backdot Backup
|
|
37
108
|
|
|
38
109
|
This repository contains dotfiles backed up automatically using [backdot](https://github.com/sorenlouv/backdot).
|
|
39
110
|
|
|
40
|
-
##
|
|
41
|
-
|
|
42
|
-
Install backdot:
|
|
111
|
+
## Restore
|
|
43
112
|
|
|
44
113
|
\`\`\`bash
|
|
45
|
-
|
|
46
|
-
\`\`\`
|
|
47
|
-
|
|
48
|
-
Restore files from this backup:
|
|
49
|
-
|
|
50
|
-
\`\`\`bash
|
|
51
|
-
backdot --restore
|
|
114
|
+
npx backdot --restore ${repository}
|
|
52
115
|
\`\`\`
|
|
53
116
|
|
|
54
117
|
For full documentation, configuration options, and scheduling, see the [official README](https://github.com/sorenlouv/backdot).
|
|
55
118
|
`;
|
|
56
|
-
|
|
57
|
-
|
|
119
|
+
}
|
|
120
|
+
export function writeRepoReadme(repository) {
|
|
121
|
+
fs.writeFileSync(path.join(STAGING_DIR, "README.md"), repoReadme(repository));
|
|
58
122
|
logger.info("Wrote README.md to staging directory");
|
|
59
123
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backdot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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",
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
23
|
"build": "tsc",
|
|
24
|
+
"build:watch": "tsc --watch",
|
|
25
|
+
"release": "./scripts/release.sh",
|
|
24
26
|
"start": "node dist/cli.js",
|
|
25
27
|
"lint": "eslint src/",
|
|
26
28
|
"lint:fix": "eslint src/ --fix",
|
|
@@ -31,6 +33,7 @@
|
|
|
31
33
|
},
|
|
32
34
|
"dependencies": {
|
|
33
35
|
"@inquirer/prompts": "^8.3.0",
|
|
36
|
+
"chalk": "^5.6.2",
|
|
34
37
|
"fast-glob": "^3.3.3",
|
|
35
38
|
"ora": "^9.3.0",
|
|
36
39
|
"simple-git": "^3.32.2",
|