backdot 1.2.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 +10 -8
- package/dist/cli.js +12 -3
- package/dist/config.d.ts +4 -16
- package/dist/config.js +6 -14
- package/dist/git.js +10 -1
- 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.js +6 -12
- package/dist/staging.js +25 -30
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -10,16 +10,17 @@
|
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npm install -g backdot
|
|
13
|
+
backdot --init
|
|
13
14
|
```
|
|
14
15
|
|
|
15
|
-
|
|
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:
|
|
16
17
|
|
|
17
18
|
```json
|
|
18
19
|
{
|
|
19
|
-
"repository": "git@github.com:USERNAME/
|
|
20
|
+
"repository": "git@github.com:USERNAME/backdot-backup.git",
|
|
20
21
|
"machine": "my-work-laptop",
|
|
21
|
-
"
|
|
22
|
-
"
|
|
22
|
+
"gitignored": ["~/my-project"],
|
|
23
|
+
"paths": ["~/.zshrc", "~/.oh-my-zsh/custom/*.zsh", "~/.ssh/config/config", "~/.npmrc"]
|
|
23
24
|
}
|
|
24
25
|
```
|
|
25
26
|
|
|
@@ -31,15 +32,16 @@ backdot --backup
|
|
|
31
32
|
|
|
32
33
|
## Configuration
|
|
33
34
|
|
|
34
|
-
| Key
|
|
35
|
-
|
|
|
36
|
-
| `
|
|
37
|
-
| `
|
|
35
|
+
| Key | Description |
|
|
36
|
+
| ------------ | ------------------------------------------------------ |
|
|
37
|
+
| `gitignored` | Directories to scan for gitignored files |
|
|
38
|
+
| `paths` | Glob patterns matching individual files or directories |
|
|
38
39
|
|
|
39
40
|
## Commands
|
|
40
41
|
|
|
41
42
|
| Command | Description |
|
|
42
43
|
| -------------- | ------------------------------------------------------ |
|
|
44
|
+
| `--init` | Set up backdot for the first time |
|
|
43
45
|
| `--backup` | Run a backup now |
|
|
44
46
|
| `--restore` | Restore files to their original locations |
|
|
45
47
|
| `--schedule` | Schedule automatic daily backup via launchd (Mac-only) |
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { resolveFiles } from "./resolve.js";
|
|
|
9
9
|
import { cleanStaging, copyToStaging, writeRepoReadme, compareFiles } from "./staging.js";
|
|
10
10
|
import { gitPull, gitCommitAndPush } from "./git.js";
|
|
11
11
|
import { restore } from "./restore.js";
|
|
12
|
+
import { init } from "./init.js";
|
|
12
13
|
import { setupLaunchd, uninstallLaunchd, isScheduled } from "./plist.js";
|
|
13
14
|
import { logger } from "./log.js";
|
|
14
15
|
function getVersion() {
|
|
@@ -28,7 +29,7 @@ async function backup() {
|
|
|
28
29
|
logger.info(`Machine: ${config.machine}`);
|
|
29
30
|
const spinner = ora("Resolving files").start();
|
|
30
31
|
try {
|
|
31
|
-
const userFiles = resolveFiles(config
|
|
32
|
+
const userFiles = resolveFiles(config);
|
|
32
33
|
logger.info(`Resolved ${userFiles.length} file(s)`);
|
|
33
34
|
if (userFiles.length === 0) {
|
|
34
35
|
spinner.info("No files resolved, nothing to back up");
|
|
@@ -69,7 +70,7 @@ async function status() {
|
|
|
69
70
|
console.log(` Machine: ${config.machine}`);
|
|
70
71
|
console.log();
|
|
71
72
|
const spinner = ora("Resolving files").start();
|
|
72
|
-
const userFiles = resolveFiles(config
|
|
73
|
+
const userFiles = resolveFiles(config);
|
|
73
74
|
if (userFiles.length === 0) {
|
|
74
75
|
spinner.warn("No files resolved. Check your ~/.backdot.json entries.");
|
|
75
76
|
console.log();
|
|
@@ -123,7 +124,10 @@ async function main() {
|
|
|
123
124
|
const args = process.argv.slice(2);
|
|
124
125
|
const command = args[0];
|
|
125
126
|
try {
|
|
126
|
-
if (command === "--
|
|
127
|
+
if (command === "--init") {
|
|
128
|
+
init();
|
|
129
|
+
}
|
|
130
|
+
else if (command === "--backup") {
|
|
127
131
|
await backup();
|
|
128
132
|
}
|
|
129
133
|
else if (command === "--schedule") {
|
|
@@ -149,6 +153,7 @@ async function main() {
|
|
|
149
153
|
console.log();
|
|
150
154
|
console.log(" Commands:");
|
|
151
155
|
console.log();
|
|
156
|
+
console.log(" --init Set up backdot for the first time");
|
|
152
157
|
console.log(" --backup Run a backup now");
|
|
153
158
|
console.log(" --restore [url] Restore files from the backup repo");
|
|
154
159
|
console.log(" --schedule Install daily backup schedule (macOS launchd)");
|
|
@@ -161,6 +166,10 @@ async function main() {
|
|
|
161
166
|
console.log();
|
|
162
167
|
process.exit(1);
|
|
163
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
|
+
}
|
|
164
173
|
}
|
|
165
174
|
}
|
|
166
175
|
catch (err) {
|
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.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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";
|
|
@@ -36,7 +37,15 @@ export async function gitCommitAndPush() {
|
|
|
36
37
|
const date = new Date().toISOString().split("T")[0];
|
|
37
38
|
const message = `Automated backup: ${date}`;
|
|
38
39
|
await git.commit(message);
|
|
39
|
-
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
|
+
});
|
|
40
49
|
logger.info(`Committed and pushed: ${message}`);
|
|
41
50
|
const sha = await git.revparse(["HEAD"]);
|
|
42
51
|
const remoteUrl = (await git.remote(["get-url", "origin"])) ?? "";
|
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.js
CHANGED
|
@@ -9,19 +9,13 @@ 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))
|
package/dist/staging.js
CHANGED
|
@@ -38,15 +38,17 @@ export function copyToStaging(files, machine) {
|
|
|
38
38
|
}
|
|
39
39
|
logger.info(`Copied ${copied} file(s) to staging`);
|
|
40
40
|
}
|
|
41
|
+
const allNotBackedUp = (files) => ({
|
|
42
|
+
backedUp: [],
|
|
43
|
+
modified: [],
|
|
44
|
+
notBackedUp: files,
|
|
45
|
+
});
|
|
41
46
|
export async function compareFiles(files, machine) {
|
|
42
|
-
const result = { backedUp: [], modified: [], notBackedUp: [] };
|
|
43
47
|
if (files.length === 0)
|
|
44
|
-
return
|
|
48
|
+
return { backedUp: [], modified: [], notBackedUp: [] };
|
|
45
49
|
const gitDir = path.join(STAGING_DIR, ".git");
|
|
46
|
-
if (!fs.existsSync(gitDir))
|
|
47
|
-
|
|
48
|
-
return result;
|
|
49
|
-
}
|
|
50
|
+
if (!fs.existsSync(gitDir))
|
|
51
|
+
return allNotBackedUp(files);
|
|
50
52
|
const git = simpleGit(STAGING_DIR);
|
|
51
53
|
await git.fetch("origin");
|
|
52
54
|
let branch;
|
|
@@ -54,27 +56,22 @@ export async function compareFiles(files, machine) {
|
|
|
54
56
|
branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
55
57
|
}
|
|
56
58
|
catch {
|
|
57
|
-
|
|
58
|
-
return result;
|
|
59
|
+
return allNotBackedUp(files);
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
+
let committedHashes;
|
|
61
62
|
try {
|
|
62
63
|
const treeOutput = execFileSync("git", ["ls-tree", "-r", `origin/${branch}`, `${machine}/`], {
|
|
63
64
|
encoding: "utf-8",
|
|
64
65
|
cwd: STAGING_DIR,
|
|
65
66
|
});
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
committedHashes.set(match[2], match[1]);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
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]]));
|
|
74
72
|
}
|
|
75
73
|
catch {
|
|
76
|
-
|
|
77
|
-
return result;
|
|
74
|
+
return allNotBackedUp(files);
|
|
78
75
|
}
|
|
79
76
|
let sourceHashes;
|
|
80
77
|
try {
|
|
@@ -84,24 +81,22 @@ export async function compareFiles(files, machine) {
|
|
|
84
81
|
sourceHashes = hashOutput.trim().split("\n");
|
|
85
82
|
}
|
|
86
83
|
catch {
|
|
87
|
-
|
|
88
|
-
return result;
|
|
84
|
+
return allNotBackedUp(files);
|
|
89
85
|
}
|
|
90
|
-
|
|
91
|
-
const repoRelPath = path.relative(STAGING_DIR, getStagedPath(
|
|
86
|
+
return files.reduce((acc, file, i) => {
|
|
87
|
+
const repoRelPath = path.relative(STAGING_DIR, getStagedPath(file, machine));
|
|
92
88
|
const committedHash = committedHashes.get(repoRelPath);
|
|
93
|
-
const sourceHash = sourceHashes[i];
|
|
94
89
|
if (!committedHash) {
|
|
95
|
-
|
|
90
|
+
acc.notBackedUp.push(file);
|
|
96
91
|
}
|
|
97
|
-
else if (committedHash ===
|
|
98
|
-
|
|
92
|
+
else if (committedHash === sourceHashes[i]) {
|
|
93
|
+
acc.backedUp.push(file);
|
|
99
94
|
}
|
|
100
95
|
else {
|
|
101
|
-
|
|
96
|
+
acc.modified.push(file);
|
|
102
97
|
}
|
|
103
|
-
|
|
104
|
-
|
|
98
|
+
return acc;
|
|
99
|
+
}, { backedUp: [], modified: [], notBackedUp: [] });
|
|
105
100
|
}
|
|
106
101
|
function repoReadme(repository) {
|
|
107
102
|
return `# Backdot Backup
|
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",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"lint:fix": "eslint src/ --fix",
|
|
29
29
|
"format": "prettier --write src/",
|
|
30
30
|
"format:check": "prettier --check src/",
|
|
31
|
-
"test": "vitest run",
|
|
31
|
+
"test": "vitest run --exclude src/e2e.test.ts",
|
|
32
|
+
"test:e2e": "npm run build && vitest run src/e2e.test.ts",
|
|
32
33
|
"test:watch": "vitest"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
"chalk": "^5.6.2",
|
|
37
38
|
"fast-glob": "^3.3.3",
|
|
38
39
|
"ora": "^9.3.0",
|
|
40
|
+
"p-retry": "^7.1.1",
|
|
39
41
|
"simple-git": "^3.32.2",
|
|
40
42
|
"winston": "^3.19.0",
|
|
41
43
|
"zod": "^4.3.6"
|