backdot 1.1.0 → 1.3.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 +16 -10
- package/dist/cli.js +59 -17
- package/dist/commitUrl.d.ts +1 -0
- package/dist/commitUrl.js +18 -0
- package/dist/config.d.ts +4 -16
- package/dist/config.js +6 -14
- package/dist/git.d.ts +3 -1
- package/dist/git.js +16 -2
- package/dist/init.d.ts +1 -0
- package/dist/init.js +43 -0
- package/dist/resolve.d.ts +1 -1
- package/dist/resolve.js +7 -9
- package/dist/restore.d.ts +1 -1
- package/dist/restore.js +39 -19
- package/dist/staging.d.ts +8 -1
- package/dist/staging.js +75 -16
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
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
|
|
|
7
11
|
```bash
|
|
8
12
|
npm install -g backdot
|
|
13
|
+
backdot --init
|
|
9
14
|
```
|
|
10
15
|
|
|
11
|
-
|
|
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:
|
|
12
17
|
|
|
13
18
|
```json
|
|
14
19
|
{
|
|
15
|
-
"repository": "git@github.com:USERNAME/
|
|
20
|
+
"repository": "git@github.com:USERNAME/backdot-backup.git",
|
|
16
21
|
"machine": "my-work-laptop",
|
|
17
|
-
"
|
|
18
|
-
"
|
|
22
|
+
"gitignored": ["~/my-project"],
|
|
23
|
+
"paths": ["~/.zshrc", "~/.oh-my-zsh/custom/*.zsh", "~/.ssh/config/config", "~/.npmrc"]
|
|
19
24
|
}
|
|
20
25
|
```
|
|
21
26
|
|
|
@@ -27,15 +32,16 @@ backdot --backup
|
|
|
27
32
|
|
|
28
33
|
## Configuration
|
|
29
34
|
|
|
30
|
-
| Key
|
|
31
|
-
|
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
35
|
+
| Key | Description |
|
|
36
|
+
| ------------ | ------------------------------------------------------ |
|
|
37
|
+
| `gitignored` | Directories to scan for gitignored files |
|
|
38
|
+
| `paths` | Glob patterns matching individual files or directories |
|
|
34
39
|
|
|
35
40
|
## Commands
|
|
36
41
|
|
|
37
42
|
| Command | Description |
|
|
38
43
|
| -------------- | ------------------------------------------------------ |
|
|
44
|
+
| `--init` | Set up backdot for the first time |
|
|
39
45
|
| `--backup` | Run a backup now |
|
|
40
46
|
| `--restore` | Restore files to their original locations |
|
|
41
47
|
| `--schedule` | Schedule automatic daily backup via launchd (Mac-only) |
|
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
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";
|
|
12
|
+
import { init } from "./init.js";
|
|
10
13
|
import { setupLaunchd, uninstallLaunchd, isScheduled } from "./plist.js";
|
|
11
14
|
import { logger } from "./log.js";
|
|
12
15
|
function getVersion() {
|
|
@@ -26,7 +29,7 @@ async function backup() {
|
|
|
26
29
|
logger.info(`Machine: ${config.machine}`);
|
|
27
30
|
const spinner = ora("Resolving files").start();
|
|
28
31
|
try {
|
|
29
|
-
const userFiles = resolveFiles(config
|
|
32
|
+
const userFiles = resolveFiles(config);
|
|
30
33
|
logger.info(`Resolved ${userFiles.length} file(s)`);
|
|
31
34
|
if (userFiles.length === 0) {
|
|
32
35
|
spinner.info("No files resolved, nothing to back up");
|
|
@@ -38,10 +41,13 @@ async function backup() {
|
|
|
38
41
|
spinner.text = `Copying ${files.length} file(s) to staging`;
|
|
39
42
|
cleanStaging(config.machine);
|
|
40
43
|
copyToStaging(files, config.machine);
|
|
41
|
-
writeRepoReadme();
|
|
44
|
+
writeRepoReadme(config.repository);
|
|
42
45
|
spinner.text = "Pushing to remote";
|
|
43
|
-
await gitCommitAndPush();
|
|
44
|
-
|
|
46
|
+
const result = await gitCommitAndPush();
|
|
47
|
+
const successMsg = result?.commitUrl
|
|
48
|
+
? `Backup complete → ${result.commitUrl}`
|
|
49
|
+
: "Backup complete";
|
|
50
|
+
spinner.succeed(successMsg);
|
|
45
51
|
console.log();
|
|
46
52
|
}
|
|
47
53
|
catch (err) {
|
|
@@ -50,7 +56,11 @@ async function backup() {
|
|
|
50
56
|
}
|
|
51
57
|
logger.info("Backup complete");
|
|
52
58
|
}
|
|
53
|
-
function
|
|
59
|
+
function tildePath(filePath) {
|
|
60
|
+
const home = os.homedir();
|
|
61
|
+
return filePath.startsWith(home) ? "~" + filePath.slice(home.length) : filePath;
|
|
62
|
+
}
|
|
63
|
+
async function status() {
|
|
54
64
|
const scheduled = isScheduled();
|
|
55
65
|
console.log();
|
|
56
66
|
console.log(` Schedule: ${scheduled ? "active (daily at 02:00)" : "not active"}`);
|
|
@@ -60,18 +70,42 @@ function status() {
|
|
|
60
70
|
console.log(` Machine: ${config.machine}`);
|
|
61
71
|
console.log();
|
|
62
72
|
const spinner = ora("Resolving files").start();
|
|
63
|
-
const userFiles = resolveFiles(config
|
|
73
|
+
const userFiles = resolveFiles(config);
|
|
64
74
|
if (userFiles.length === 0) {
|
|
65
75
|
spinner.warn("No files resolved. Check your ~/.backdot.json entries.");
|
|
76
|
+
console.log();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const files = [...userFiles, CONFIG_PATH];
|
|
80
|
+
spinner.text = "Comparing with remote backup";
|
|
81
|
+
const { backedUp, modified, notBackedUp } = await compareFiles(files, config.machine);
|
|
82
|
+
spinner.stop();
|
|
83
|
+
if (modified.length === 0 && notBackedUp.length === 0) {
|
|
84
|
+
console.log(chalk.green(` All ${files.length} file(s) are backed up ✓`));
|
|
66
85
|
}
|
|
67
86
|
else {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
console.log(
|
|
87
|
+
if (modified.length > 0) {
|
|
88
|
+
console.log(chalk.yellow(` Modified since last backup (${modified.length}):`));
|
|
89
|
+
for (const f of modified) {
|
|
90
|
+
console.log(` ${tildePath(f)}`);
|
|
91
|
+
}
|
|
92
|
+
console.log();
|
|
74
93
|
}
|
|
94
|
+
if (notBackedUp.length > 0) {
|
|
95
|
+
console.log(chalk.red(` Not yet backed up (${notBackedUp.length}):`));
|
|
96
|
+
for (const f of notBackedUp) {
|
|
97
|
+
console.log(` ${tildePath(f)}`);
|
|
98
|
+
}
|
|
99
|
+
console.log();
|
|
100
|
+
}
|
|
101
|
+
if (backedUp.length > 0) {
|
|
102
|
+
console.log(chalk.green(` Backed up (${backedUp.length}):`));
|
|
103
|
+
for (const f of backedUp) {
|
|
104
|
+
console.log(` ${tildePath(f)}`);
|
|
105
|
+
}
|
|
106
|
+
console.log();
|
|
107
|
+
}
|
|
108
|
+
console.log(` Run ${chalk.bold("backdot --backup")} to back up all changes.`);
|
|
75
109
|
}
|
|
76
110
|
}
|
|
77
111
|
catch (err) {
|
|
@@ -90,7 +124,10 @@ async function main() {
|
|
|
90
124
|
const args = process.argv.slice(2);
|
|
91
125
|
const command = args[0];
|
|
92
126
|
try {
|
|
93
|
-
if (command === "--
|
|
127
|
+
if (command === "--init") {
|
|
128
|
+
init();
|
|
129
|
+
}
|
|
130
|
+
else if (command === "--backup") {
|
|
94
131
|
await backup();
|
|
95
132
|
}
|
|
96
133
|
else if (command === "--schedule") {
|
|
@@ -102,10 +139,10 @@ async function main() {
|
|
|
102
139
|
uninstallLaunchd();
|
|
103
140
|
}
|
|
104
141
|
else if (command === "--status") {
|
|
105
|
-
status();
|
|
142
|
+
await status();
|
|
106
143
|
}
|
|
107
144
|
else if (command === "--restore") {
|
|
108
|
-
await restore();
|
|
145
|
+
await restore(args[1]);
|
|
109
146
|
}
|
|
110
147
|
else if (command === "--version") {
|
|
111
148
|
console.log(getVersion());
|
|
@@ -116,8 +153,9 @@ async function main() {
|
|
|
116
153
|
console.log();
|
|
117
154
|
console.log(" Commands:");
|
|
118
155
|
console.log();
|
|
156
|
+
console.log(" --init Set up backdot for the first time");
|
|
119
157
|
console.log(" --backup Run a backup now");
|
|
120
|
-
console.log(" --restore
|
|
158
|
+
console.log(" --restore [url] Restore files from the backup repo");
|
|
121
159
|
console.log(" --schedule Install daily backup schedule (macOS launchd)");
|
|
122
160
|
console.log(" --unschedule Remove the daily backup schedule");
|
|
123
161
|
console.log(" --status Show schedule and resolved files");
|
|
@@ -128,6 +166,10 @@ async function main() {
|
|
|
128
166
|
console.log();
|
|
129
167
|
process.exit(1);
|
|
130
168
|
}
|
|
169
|
+
else if (!fs.existsSync(CONFIG_PATH)) {
|
|
170
|
+
console.log(` No config found. Run ${chalk.bold("backdot --init")} to get started.`);
|
|
171
|
+
console.log();
|
|
172
|
+
}
|
|
131
173
|
}
|
|
132
174
|
}
|
|
133
175
|
catch (err) {
|
|
@@ -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/config.d.ts
CHANGED
|
@@ -1,24 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export declare const CONFIG_PATH: string;
|
|
3
3
|
export declare function expandTilde(p: string): string;
|
|
4
|
-
declare const ConfigSchema: z.
|
|
4
|
+
declare const ConfigSchema: z.ZodObject<{
|
|
5
5
|
repository: z.ZodString;
|
|
6
6
|
machine: z.ZodString;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}, z.core.$strip
|
|
10
|
-
repository: string;
|
|
11
|
-
machine: string;
|
|
12
|
-
files: {
|
|
13
|
-
gitignored: string[];
|
|
14
|
-
match: string[];
|
|
15
|
-
};
|
|
16
|
-
}, {
|
|
17
|
-
repository: string;
|
|
18
|
-
machine: string;
|
|
19
|
-
"files.gitignored": string[];
|
|
20
|
-
"files.match": string[];
|
|
21
|
-
}>>;
|
|
7
|
+
gitignored: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>>;
|
|
8
|
+
paths: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>>;
|
|
9
|
+
}, z.core.$strip>;
|
|
22
10
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
23
11
|
export declare function loadConfig(): Config;
|
|
24
12
|
export {};
|
package/dist/config.js
CHANGED
|
@@ -14,23 +14,15 @@ const ConfigSchema = z
|
|
|
14
14
|
.object({
|
|
15
15
|
repository: z.string().min(1),
|
|
16
16
|
machine: z.string().min(1),
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
gitignored: pathList,
|
|
18
|
+
paths: pathList,
|
|
19
19
|
})
|
|
20
|
-
.refine((c) => c
|
|
21
|
-
message: 'At least one of "
|
|
22
|
-
})
|
|
23
|
-
.transform((c) => ({
|
|
24
|
-
repository: c.repository,
|
|
25
|
-
machine: c.machine,
|
|
26
|
-
files: {
|
|
27
|
-
gitignored: c["files.gitignored"],
|
|
28
|
-
match: c["files.match"],
|
|
29
|
-
},
|
|
30
|
-
}));
|
|
20
|
+
.refine((c) => c.gitignored.length > 0 || c.paths.length > 0, {
|
|
21
|
+
message: 'At least one of "gitignored" or "paths" must be a non-empty array',
|
|
22
|
+
});
|
|
31
23
|
export function loadConfig() {
|
|
32
24
|
if (!fs.existsSync(CONFIG_PATH)) {
|
|
33
|
-
throw new Error(`Config file not found: ${CONFIG_PATH}
|
|
25
|
+
throw new Error(`Config file not found: ${CONFIG_PATH}\n Run "backdot --init" to create it.`);
|
|
34
26
|
}
|
|
35
27
|
let raw;
|
|
36
28
|
try {
|
package/dist/git.d.ts
CHANGED
package/dist/git.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import pRetry from "p-retry";
|
|
3
4
|
import { simpleGit, CleanOptions } from "simple-git";
|
|
4
5
|
import { logger } from "./log.js";
|
|
5
6
|
import { STAGING_DIR } from "./staging.js";
|
|
7
|
+
import { getCommitUrl } from "./commitUrl.js";
|
|
6
8
|
export async function gitPull(repository) {
|
|
7
9
|
if (fs.existsSync(path.join(STAGING_DIR, ".git"))) {
|
|
8
10
|
const git = simpleGit(STAGING_DIR);
|
|
@@ -30,11 +32,23 @@ export async function gitCommitAndPush() {
|
|
|
30
32
|
const status = await git.status();
|
|
31
33
|
if (status.isClean()) {
|
|
32
34
|
logger.info("No changes to commit");
|
|
33
|
-
return;
|
|
35
|
+
return null;
|
|
34
36
|
}
|
|
35
37
|
const date = new Date().toISOString().split("T")[0];
|
|
36
38
|
const message = `Automated backup: ${date}`;
|
|
37
39
|
await git.commit(message);
|
|
38
|
-
await git.push(["-u", "origin", "HEAD"])
|
|
40
|
+
await pRetry(async () => git.push(["-u", "origin", "HEAD"]), {
|
|
41
|
+
retries: 5,
|
|
42
|
+
onFailedAttempt: async ({ attemptNumber, retriesLeft }) => {
|
|
43
|
+
logger.info(`Push failed (attempt ${attemptNumber}, ${retriesLeft} retries left), rebasing`);
|
|
44
|
+
await git.fetch("origin");
|
|
45
|
+
const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
46
|
+
await git.rebase([`origin/${branch}`]);
|
|
47
|
+
},
|
|
48
|
+
});
|
|
39
49
|
logger.info(`Committed and pushed: ${message}`);
|
|
50
|
+
const sha = await git.revparse(["HEAD"]);
|
|
51
|
+
const remoteUrl = (await git.remote(["get-url", "origin"])) ?? "";
|
|
52
|
+
const commitUrl = getCommitUrl(remoteUrl, sha);
|
|
53
|
+
return { commitUrl };
|
|
40
54
|
}
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function init(): void;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { CONFIG_PATH } from "./config.js";
|
|
5
|
+
const TEMPLATE = {
|
|
6
|
+
repository: "git@github.com:USERNAME/backdot-backup.git",
|
|
7
|
+
machine: os.hostname(),
|
|
8
|
+
gitignored: [],
|
|
9
|
+
paths: ["~/.zshrc", "~/.gitconfig"],
|
|
10
|
+
};
|
|
11
|
+
export function init() {
|
|
12
|
+
console.log();
|
|
13
|
+
console.log(chalk.bold(" Welcome to backdot!"));
|
|
14
|
+
console.log();
|
|
15
|
+
// Step 1
|
|
16
|
+
console.log(chalk.bold(" Step 1 — Create a private Git repository"));
|
|
17
|
+
console.log();
|
|
18
|
+
console.log(" If you don't have a backup repo yet, create one:");
|
|
19
|
+
console.log();
|
|
20
|
+
console.log(` GitHub: ${chalk.cyan("https://github.com/new?name=backdot-backup&visibility=private")}`);
|
|
21
|
+
console.log(` GitLab: ${chalk.cyan("https://gitlab.com/projects/new#blank_project")}`);
|
|
22
|
+
console.log(` Bitbucket: ${chalk.cyan("https://bitbucket.org/repo/create")}`);
|
|
23
|
+
console.log();
|
|
24
|
+
// Step 2
|
|
25
|
+
console.log(chalk.bold(` Step 2 — Edit ${CONFIG_PATH}`));
|
|
26
|
+
console.log();
|
|
27
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
28
|
+
console.log(` ${chalk.yellow(`${CONFIG_PATH} already exists — skipping creation.`)}`);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(TEMPLATE, null, 2) + "\n");
|
|
32
|
+
console.log(` Created ${CONFIG_PATH} with defaults.`);
|
|
33
|
+
}
|
|
34
|
+
console.log(" Open it and set your repository URL and files to back up.");
|
|
35
|
+
console.log();
|
|
36
|
+
// Step 3
|
|
37
|
+
console.log(chalk.bold(" Step 3 — Run your first backup"));
|
|
38
|
+
console.log();
|
|
39
|
+
console.log(` ${chalk.bold("backdot --backup")} Run a one-time backup`);
|
|
40
|
+
console.log(` ${chalk.bold("backdot --schedule")} Schedule daily backups (macOS)`);
|
|
41
|
+
console.log(` ${chalk.bold("backdot --status")} Check which files will be backed up`);
|
|
42
|
+
console.log();
|
|
43
|
+
}
|
package/dist/resolve.d.ts
CHANGED
|
@@ -3,4 +3,4 @@ import { Config } from "./config.js";
|
|
|
3
3
|
* Resolve all file entries to absolute paths.
|
|
4
4
|
* Skips entries that fail resolution and logs warnings.
|
|
5
5
|
*/
|
|
6
|
-
export declare function resolveFiles(
|
|
6
|
+
export declare function resolveFiles(config: Config): string[];
|
package/dist/resolve.js
CHANGED
|
@@ -37,15 +37,13 @@ const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
|
|
|
37
37
|
* Resolve all file entries to absolute paths.
|
|
38
38
|
* Skips entries that fail resolution and logs warnings.
|
|
39
39
|
*/
|
|
40
|
-
export function resolveFiles(
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
const unique = [...new Set(allFiles)];
|
|
40
|
+
export function resolveFiles(config) {
|
|
41
|
+
const unique = [
|
|
42
|
+
...new Set([
|
|
43
|
+
...config.gitignored.flatMap(resolveGitignored),
|
|
44
|
+
...config.paths.flatMap(resolveGlob),
|
|
45
|
+
]),
|
|
46
|
+
];
|
|
49
47
|
return unique.filter((filePath) => {
|
|
50
48
|
try {
|
|
51
49
|
fs.accessSync(filePath, fs.constants.R_OK);
|
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,26 +2,20 @@ 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";
|
|
9
9
|
import { logger } from "./log.js";
|
|
10
10
|
const HOME = os.homedir();
|
|
11
11
|
function walkDir(dir) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
return fs
|
|
13
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
14
|
+
.filter((entry) => entry.name !== ".git")
|
|
15
|
+
.flatMap((entry) => {
|
|
16
16
|
const full = path.join(dir, entry.name);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
else {
|
|
21
|
-
results.push(full);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return results;
|
|
17
|
+
return entry.isDirectory() ? walkDir(full) : [full];
|
|
18
|
+
});
|
|
25
19
|
}
|
|
26
20
|
function listMachines() {
|
|
27
21
|
if (!fs.existsSync(STAGING_DIR))
|
|
@@ -31,22 +25,48 @@ function listMachines() {
|
|
|
31
25
|
.filter((e) => e.isDirectory() && e.name !== ".git")
|
|
32
26
|
.map((e) => e.name);
|
|
33
27
|
}
|
|
34
|
-
|
|
28
|
+
async function resolveRepoAndMachine(repoUrl) {
|
|
29
|
+
if (!repoUrl) {
|
|
30
|
+
const config = loadConfig();
|
|
31
|
+
return { repository: config.repository, machine: config.machine };
|
|
32
|
+
}
|
|
33
|
+
const spinner = ora("Cloning backup repository").start();
|
|
34
|
+
await gitPull(repoUrl);
|
|
35
|
+
spinner.stop();
|
|
36
|
+
const machines = listMachines();
|
|
37
|
+
if (machines.length === 0) {
|
|
38
|
+
throw new Error("The backup repository is empty (no machine directories found).");
|
|
39
|
+
}
|
|
40
|
+
let machine;
|
|
41
|
+
if (machines.length === 1) {
|
|
42
|
+
machine = machines[0];
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
machine = await select({
|
|
46
|
+
message: "Multiple machines found. Which one do you want to restore?",
|
|
47
|
+
choices: machines.map((m) => ({ name: m, value: m })),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return { repository: repoUrl, machine };
|
|
51
|
+
}
|
|
52
|
+
export async function restore(repoUrl) {
|
|
35
53
|
logger.info("Starting restore");
|
|
36
|
-
const
|
|
37
|
-
const baseDir = machineDir(config.machine);
|
|
54
|
+
const { repository, machine } = await resolveRepoAndMachine(repoUrl);
|
|
38
55
|
const spinner = ora("Fetching latest backup").start();
|
|
39
|
-
|
|
56
|
+
const baseDir = machineDir(machine);
|
|
57
|
+
if (!repoUrl) {
|
|
58
|
+
await gitPull(repository);
|
|
59
|
+
}
|
|
40
60
|
spinner.text = "Resolving files";
|
|
41
61
|
if (!fs.existsSync(baseDir)) {
|
|
42
62
|
spinner.stop();
|
|
43
63
|
const available = listMachines();
|
|
44
64
|
if (available.length > 0) {
|
|
45
|
-
console.log(`\n No backup found for machine "${
|
|
65
|
+
console.log(`\n No backup found for machine "${machine}".`);
|
|
46
66
|
console.log(` Available machines: ${available.join(", ")}\n`);
|
|
47
67
|
}
|
|
48
68
|
else {
|
|
49
|
-
console.log(`\n No backup found for machine "${
|
|
69
|
+
console.log(`\n No backup found for machine "${machine}". The repository is empty.\n`);
|
|
50
70
|
}
|
|
51
71
|
return;
|
|
52
72
|
}
|
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,81 @@ export function copyToStaging(files, machine) {
|
|
|
33
38
|
}
|
|
34
39
|
logger.info(`Copied ${copied} file(s) to staging`);
|
|
35
40
|
}
|
|
36
|
-
const
|
|
41
|
+
const allNotBackedUp = (files) => ({
|
|
42
|
+
backedUp: [],
|
|
43
|
+
modified: [],
|
|
44
|
+
notBackedUp: files,
|
|
45
|
+
});
|
|
46
|
+
export async function compareFiles(files, machine) {
|
|
47
|
+
if (files.length === 0)
|
|
48
|
+
return { backedUp: [], modified: [], notBackedUp: [] };
|
|
49
|
+
const gitDir = path.join(STAGING_DIR, ".git");
|
|
50
|
+
if (!fs.existsSync(gitDir))
|
|
51
|
+
return allNotBackedUp(files);
|
|
52
|
+
const git = simpleGit(STAGING_DIR);
|
|
53
|
+
await git.fetch("origin");
|
|
54
|
+
let branch;
|
|
55
|
+
try {
|
|
56
|
+
branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return allNotBackedUp(files);
|
|
60
|
+
}
|
|
61
|
+
let committedHashes;
|
|
62
|
+
try {
|
|
63
|
+
const treeOutput = execFileSync("git", ["ls-tree", "-r", `origin/${branch}`, `${machine}/`], {
|
|
64
|
+
encoding: "utf-8",
|
|
65
|
+
cwd: STAGING_DIR,
|
|
66
|
+
});
|
|
67
|
+
committedHashes = new Map(treeOutput
|
|
68
|
+
.split("\n")
|
|
69
|
+
.map((line) => line.match(/^\d+ blob ([0-9a-f]+)\t(.+)$/))
|
|
70
|
+
.filter((m) => m !== null)
|
|
71
|
+
.map((m) => [m[2], m[1]]));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return allNotBackedUp(files);
|
|
75
|
+
}
|
|
76
|
+
let sourceHashes;
|
|
77
|
+
try {
|
|
78
|
+
const hashOutput = execFileSync("git", ["hash-object", "--", ...files], {
|
|
79
|
+
encoding: "utf-8",
|
|
80
|
+
});
|
|
81
|
+
sourceHashes = hashOutput.trim().split("\n");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return allNotBackedUp(files);
|
|
85
|
+
}
|
|
86
|
+
return files.reduce((acc, file, i) => {
|
|
87
|
+
const repoRelPath = path.relative(STAGING_DIR, getStagedPath(file, machine));
|
|
88
|
+
const committedHash = committedHashes.get(repoRelPath);
|
|
89
|
+
if (!committedHash) {
|
|
90
|
+
acc.notBackedUp.push(file);
|
|
91
|
+
}
|
|
92
|
+
else if (committedHash === sourceHashes[i]) {
|
|
93
|
+
acc.backedUp.push(file);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
acc.modified.push(file);
|
|
97
|
+
}
|
|
98
|
+
return acc;
|
|
99
|
+
}, { backedUp: [], modified: [], notBackedUp: [] });
|
|
100
|
+
}
|
|
101
|
+
function repoReadme(repository) {
|
|
102
|
+
return `# Backdot Backup
|
|
37
103
|
|
|
38
104
|
This repository contains dotfiles backed up automatically using [backdot](https://github.com/sorenlouv/backdot).
|
|
39
105
|
|
|
40
|
-
##
|
|
41
|
-
|
|
42
|
-
Install backdot:
|
|
43
|
-
|
|
44
|
-
\`\`\`bash
|
|
45
|
-
npm install -g backdot
|
|
46
|
-
\`\`\`
|
|
47
|
-
|
|
48
|
-
Restore files from this backup:
|
|
106
|
+
## Restore
|
|
49
107
|
|
|
50
108
|
\`\`\`bash
|
|
51
|
-
backdot --restore
|
|
109
|
+
npx backdot --restore ${repository}
|
|
52
110
|
\`\`\`
|
|
53
111
|
|
|
54
112
|
For full documentation, configuration options, and scheduling, see the [official README](https://github.com/sorenlouv/backdot).
|
|
55
113
|
`;
|
|
56
|
-
|
|
57
|
-
|
|
114
|
+
}
|
|
115
|
+
export function writeRepoReadme(repository) {
|
|
116
|
+
fs.writeFileSync(path.join(STAGING_DIR, "README.md"), repoReadme(repository));
|
|
58
117
|
logger.info("Wrote README.md to staging directory");
|
|
59
118
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backdot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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,18 +21,23 @@
|
|
|
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",
|
|
27
29
|
"format": "prettier --write src/",
|
|
28
30
|
"format:check": "prettier --check src/",
|
|
29
|
-
"test": "vitest run",
|
|
31
|
+
"test": "vitest run --exclude src/e2e.test.ts",
|
|
32
|
+
"test:e2e": "npm run build && vitest run src/e2e.test.ts",
|
|
30
33
|
"test:watch": "vitest"
|
|
31
34
|
},
|
|
32
35
|
"dependencies": {
|
|
33
36
|
"@inquirer/prompts": "^8.3.0",
|
|
37
|
+
"chalk": "^5.6.2",
|
|
34
38
|
"fast-glob": "^3.3.3",
|
|
35
39
|
"ora": "^9.3.0",
|
|
40
|
+
"p-retry": "^7.1.1",
|
|
36
41
|
"simple-git": "^3.32.2",
|
|
37
42
|
"winston": "^3.19.0",
|
|
38
43
|
"zod": "^4.3.6"
|