backdot 1.8.0 → 1.8.2
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/dist/cli.js +19 -6
- package/dist/commands/backup.js +23 -9
- package/dist/commands/init.js +2 -0
- package/dist/commands/restore.d.ts +3 -2
- package/dist/commands/restore.js +54 -32
- package/dist/commands/schedule.js +1 -1
- package/dist/commands/status.js +16 -4
- package/dist/commitUrl.js +9 -12
- package/dist/config.d.ts +2 -2
- package/dist/config.js +5 -4
- package/dist/crypto/encryption.d.ts +4 -3
- package/dist/crypto/encryption.js +13 -10
- package/dist/crypto/password.d.ts +6 -3
- package/dist/crypto/password.js +13 -10
- package/dist/git.d.ts +1 -1
- package/dist/git.js +6 -7
- package/dist/launchd.js +7 -8
- package/dist/log.js +2 -5
- package/dist/notify.js +3 -3
- package/dist/paths.d.ts +6 -0
- package/dist/paths.js +8 -1
- package/dist/postRestoreHook.d.ts +7 -0
- package/dist/postRestoreHook.js +35 -0
- package/dist/repoVisibility.js +11 -13
- package/dist/staging.d.ts +15 -1
- package/dist/staging.js +74 -46
- package/dist/utils.d.ts +8 -0
- package/dist/utils.js +20 -0
- package/package.json +21 -21
- package/README.md +0 -82
- package/dist/crypto.d.ts +0 -13
- package/dist/crypto.js +0 -129
- package/dist/encryption.d.ts +0 -2
- package/dist/encryption.js +0 -39
- package/dist/errorMessage.d.ts +0 -1
- package/dist/errorMessage.js +0 -3
- package/dist/password.d.ts +0 -11
- package/dist/password.js +0 -74
- package/dist/plist.d.ts +0 -3
- package/dist/plist.js +0 -112
- package/dist/resolve.d.ts +0 -6
- package/dist/resolve.js +0 -44
package/dist/launchd.js
CHANGED
|
@@ -5,8 +5,9 @@ import os from "node:os";
|
|
|
5
5
|
import ora from "ora";
|
|
6
6
|
import { logger } from "./log.js";
|
|
7
7
|
import { errorMessage } from "./utils.js";
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
import { LOG_DIR, LAUNCHD_LOG_PATH } from "./paths.js";
|
|
9
|
+
function escapeXml(text) {
|
|
10
|
+
return text
|
|
10
11
|
.replace(/&/g, "&")
|
|
11
12
|
.replace(/</g, "<")
|
|
12
13
|
.replace(/>/g, ">")
|
|
@@ -22,7 +23,6 @@ function buildPlist() {
|
|
|
22
23
|
const nodePath = process.execPath;
|
|
23
24
|
const scriptPath = getScriptPath();
|
|
24
25
|
const workingDir = path.dirname(scriptPath);
|
|
25
|
-
const logPath = path.join(os.homedir(), ".backdot", "launchd.log");
|
|
26
26
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
27
27
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
28
28
|
<plist version="1.0">
|
|
@@ -45,9 +45,9 @@ function buildPlist() {
|
|
|
45
45
|
<integer>0</integer>
|
|
46
46
|
</dict>
|
|
47
47
|
<key>StandardOutPath</key>
|
|
48
|
-
<string>${escapeXml(
|
|
48
|
+
<string>${escapeXml(LAUNCHD_LOG_PATH)}</string>
|
|
49
49
|
<key>StandardErrorPath</key>
|
|
50
|
-
<string>${escapeXml(
|
|
50
|
+
<string>${escapeXml(LAUNCHD_LOG_PATH)}</string>
|
|
51
51
|
<key>RunAtLoad</key>
|
|
52
52
|
<false/>
|
|
53
53
|
</dict>
|
|
@@ -72,9 +72,8 @@ export function setupLaunchd() {
|
|
|
72
72
|
const spinner = ora("Installing schedule").start();
|
|
73
73
|
const plistContent = buildPlist();
|
|
74
74
|
const launchAgentsDir = path.dirname(PLIST_PATH);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
75
|
+
fs.mkdirSync(launchAgentsDir, { recursive: true });
|
|
76
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
78
77
|
try {
|
|
79
78
|
execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
|
|
80
79
|
}
|
package/dist/log.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import os from "node:os";
|
|
4
2
|
import winston from "winston";
|
|
5
|
-
|
|
6
|
-
const LOG_FILE = path.join(LOG_DIR, "backup.log");
|
|
3
|
+
import { LOG_DIR, CLI_LOG_PATH } from "./paths.js";
|
|
7
4
|
let _logger;
|
|
8
5
|
function getLogger() {
|
|
9
6
|
if (!_logger) {
|
|
@@ -11,7 +8,7 @@ function getLogger() {
|
|
|
11
8
|
_logger = winston.createLogger({
|
|
12
9
|
level: "info",
|
|
13
10
|
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:
|
|
11
|
+
transports: [new winston.transports.File({ filename: CLI_LOG_PATH })],
|
|
15
12
|
});
|
|
16
13
|
}
|
|
17
14
|
return _logger;
|
package/dist/notify.js
CHANGED
|
@@ -8,12 +8,12 @@ export function sendNotification(title, message) {
|
|
|
8
8
|
if (process.platform !== "darwin") {
|
|
9
9
|
return;
|
|
10
10
|
}
|
|
11
|
-
const
|
|
12
|
-
const
|
|
11
|
+
const escapedMessage = escapeAppleScript(message);
|
|
12
|
+
const escapedTitle = escapeAppleScript(title);
|
|
13
13
|
try {
|
|
14
14
|
execFileSync("osascript", [
|
|
15
15
|
"-e",
|
|
16
|
-
`display notification "${
|
|
16
|
+
`display notification "${escapedMessage}" with title "${escapedTitle}" subtitle "Scheduled backup failed"`,
|
|
17
17
|
], { stdio: "pipe" });
|
|
18
18
|
}
|
|
19
19
|
catch (err) {
|
package/dist/paths.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export declare const CONFIG_PATH: string;
|
|
2
|
+
export declare const KEY_FILE_PATH: string;
|
|
3
|
+
export declare const POST_RESTORE_HOOK_PATH: string;
|
|
1
4
|
export declare const STAGING_DIR: string;
|
|
2
5
|
export declare const STAGING_GIT_DIR: string;
|
|
6
|
+
export declare const LOG_DIR: string;
|
|
7
|
+
export declare const CLI_LOG_PATH: string;
|
|
8
|
+
export declare const LAUNCHD_LOG_PATH: string;
|
|
3
9
|
export declare function machineDir(machine: string): string;
|
package/dist/paths.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
const HOME = os.homedir();
|
|
4
|
-
|
|
4
|
+
const BACKDOT_DIR = path.join(HOME, ".backdot");
|
|
5
|
+
export const CONFIG_PATH = path.join(BACKDOT_DIR, "config.json");
|
|
6
|
+
export const KEY_FILE_PATH = path.join(BACKDOT_DIR, "encryption.key");
|
|
7
|
+
export const POST_RESTORE_HOOK_PATH = path.join(BACKDOT_DIR, "post-restore");
|
|
8
|
+
export const STAGING_DIR = path.join(BACKDOT_DIR, "repo");
|
|
5
9
|
export const STAGING_GIT_DIR = path.join(STAGING_DIR, ".git");
|
|
10
|
+
export const LOG_DIR = path.join(BACKDOT_DIR, "logs");
|
|
11
|
+
export const CLI_LOG_PATH = path.join(LOG_DIR, "cli.log");
|
|
12
|
+
export const LAUNCHD_LOG_PATH = path.join(LOG_DIR, "launchd.log");
|
|
6
13
|
export function machineDir(machine) {
|
|
7
14
|
return path.join(STAGING_DIR, machine);
|
|
8
15
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the restored `~/.backdot/post-restore` script (a POSIX `sh` script) so a
|
|
3
|
+
* restored machine can provision itself. The caller decides when to invoke this
|
|
4
|
+
* (only when the hook was actually among the restored files). A non-zero exit is
|
|
5
|
+
* surfaced as an error — never swallowed — but files have already been restored.
|
|
6
|
+
*/
|
|
7
|
+
export declare function runPostRestoreHook(): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { logger } from "./log.js";
|
|
4
|
+
import { errorMessage } from "./utils.js";
|
|
5
|
+
import { POST_RESTORE_HOOK_PATH } from "./paths.js";
|
|
6
|
+
/**
|
|
7
|
+
* Runs the restored `~/.backdot/post-restore` script (a POSIX `sh` script) so a
|
|
8
|
+
* restored machine can provision itself. The caller decides when to invoke this
|
|
9
|
+
* (only when the hook was actually among the restored files). A non-zero exit is
|
|
10
|
+
* surfaced as an error — never swallowed — but files have already been restored.
|
|
11
|
+
*/
|
|
12
|
+
export function runPostRestoreHook() {
|
|
13
|
+
if (process.platform === "win32") {
|
|
14
|
+
console.log(" Post-restore hook found, but hooks are not supported on Windows. Skipping.\n");
|
|
15
|
+
logger.warn("Post-restore hook skipped (not supported on Windows)");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
console.log(` Running post-restore hook (${POST_RESTORE_HOOK_PATH})…\n`);
|
|
19
|
+
logger.info("Running post-restore hook");
|
|
20
|
+
try {
|
|
21
|
+
execFileSync("/bin/sh", [POST_RESTORE_HOOK_PATH], {
|
|
22
|
+
cwd: os.homedir(),
|
|
23
|
+
stdio: "inherit",
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
logger.error(`Post-restore hook failed: ${errorMessage(err)}`);
|
|
28
|
+
const exitCode = err.status;
|
|
29
|
+
const exitNote = typeof exitCode === "number" ? ` (exit code ${exitCode})` : "";
|
|
30
|
+
throw new Error(`Files were restored, but the post-restore hook failed${exitNote}.\n` +
|
|
31
|
+
` Fix ${POST_RESTORE_HOOK_PATH} and re-run, or run it manually.`, { cause: err });
|
|
32
|
+
}
|
|
33
|
+
console.log();
|
|
34
|
+
logger.info("Post-restore hook completed");
|
|
35
|
+
}
|
package/dist/repoVisibility.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
|
|
2
|
+
import { extractRepoPath } from "./utils.js";
|
|
3
3
|
/**
|
|
4
4
|
* Converts an SSH or HTTPS repo URL to a credential-free HTTPS URL
|
|
5
5
|
* for known hosts. Returns `null` for unrecognized hosts.
|
|
@@ -9,18 +9,11 @@ const KNOWN_HOSTS = ["github.com", "gitlab.com", "bitbucket.org"];
|
|
|
9
9
|
* https://github.com/user/repo → https://github.com/user/repo.git
|
|
10
10
|
*/
|
|
11
11
|
export function toHttpsUrl(repository) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
continue;
|
|
16
|
-
}
|
|
17
|
-
let repoPath = repository.slice(idx + host.length + 1).trim();
|
|
18
|
-
if (!repoPath.endsWith(".git")) {
|
|
19
|
-
repoPath += ".git";
|
|
20
|
-
}
|
|
21
|
-
return `https://${host}/${repoPath}`;
|
|
12
|
+
const parsed = extractRepoPath(repository);
|
|
13
|
+
if (!parsed) {
|
|
14
|
+
return null;
|
|
22
15
|
}
|
|
23
|
-
return
|
|
16
|
+
return `https://${parsed.host}/${parsed.repoPath}.git`;
|
|
24
17
|
}
|
|
25
18
|
/**
|
|
26
19
|
* Checks whether `repository` is publicly readable by attempting an
|
|
@@ -43,7 +36,12 @@ function execGitLsRemote(url) {
|
|
|
43
36
|
return new Promise((resolve, reject) => {
|
|
44
37
|
const child = execFile("git", ["-c", "credential.helper=", "ls-remote", "--quiet", url], {
|
|
45
38
|
timeout: 10_000,
|
|
46
|
-
env: {
|
|
39
|
+
env: {
|
|
40
|
+
...process.env,
|
|
41
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
42
|
+
GIT_ASKPASS: undefined,
|
|
43
|
+
SSH_ASKPASS: undefined,
|
|
44
|
+
},
|
|
47
45
|
}, (error) => {
|
|
48
46
|
if (error) {
|
|
49
47
|
reject(error);
|
package/dist/staging.d.ts
CHANGED
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
import { STAGING_DIR, STAGING_GIT_DIR, machineDir } from "./paths.js";
|
|
2
2
|
import { type DerivedKey } from "./crypto/encryption.js";
|
|
3
3
|
export { STAGING_DIR, STAGING_GIT_DIR, machineDir };
|
|
4
|
+
export declare const HOME_NAMESPACE = "home";
|
|
5
|
+
export declare const ROOT_NAMESPACE = "root";
|
|
4
6
|
export declare function getStagedPath(filePath: string, machine: string): string;
|
|
7
|
+
export interface RestoreTarget {
|
|
8
|
+
/** Absolute path the file is restored to on this machine. */
|
|
9
|
+
destination: string;
|
|
10
|
+
/** Path shown in the restore picker (e.g. "~/.zshrc" or "/etc/hosts"). */
|
|
11
|
+
displayPath: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Inverse of getStagedPath: maps a path relative to the machine dir
|
|
15
|
+
* (e.g. "home/.zshrc" or "root/etc/hosts") back to its restore destination.
|
|
16
|
+
*/
|
|
17
|
+
export declare function getRestoreTarget(machineRelativePath: string): RestoreTarget;
|
|
5
18
|
export declare function cleanStaging(machine: string): void;
|
|
6
19
|
export declare function copyToStaging(files: string[], machine: string, derivedKey?: DerivedKey): void;
|
|
7
20
|
export interface ComparisonResult {
|
|
8
21
|
backedUp: string[];
|
|
9
22
|
modified: string[];
|
|
10
23
|
notBackedUp: string[];
|
|
24
|
+
remoteIsEmpty?: boolean;
|
|
11
25
|
error?: string;
|
|
12
26
|
}
|
|
13
27
|
export declare function compareFiles(opts: {
|
|
14
28
|
files: string[];
|
|
15
29
|
machine: string;
|
|
16
30
|
repository: string;
|
|
17
|
-
|
|
31
|
+
resolveKey?: () => Promise<DerivedKey>;
|
|
18
32
|
}): Promise<ComparisonResult>;
|
|
19
33
|
export declare function writeRepoReadme(repository: string, encrypted?: boolean): void;
|
package/dist/staging.js
CHANGED
|
@@ -11,20 +11,44 @@ import { encrypt, decrypt } from "./crypto/encryption.js";
|
|
|
11
11
|
import { KEY_FILE_PATH, ENC_SUFFIX } from "./crypto/password.js";
|
|
12
12
|
export { STAGING_DIR, STAGING_GIT_DIR, machineDir };
|
|
13
13
|
const HOME = os.homedir();
|
|
14
|
+
// Backed-up files live under one of two namespaces inside the machine dir, which
|
|
15
|
+
// keeps the layout lossless: HOME files restore relative to the restoring machine's
|
|
16
|
+
// own home (portable across machines), while files elsewhere restore to their
|
|
17
|
+
// original absolute path.
|
|
18
|
+
export const HOME_NAMESPACE = "home";
|
|
19
|
+
export const ROOT_NAMESPACE = "root";
|
|
20
|
+
function isOutsideHome(filePath) {
|
|
21
|
+
const relativeToHome = path.relative(HOME, filePath);
|
|
22
|
+
return relativeToHome.startsWith("..") || path.isAbsolute(relativeToHome);
|
|
23
|
+
}
|
|
14
24
|
export function getStagedPath(filePath, machine) {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const pathWithinMachineDir = relativePath.startsWith("..") ? filePath.slice(1) : relativePath;
|
|
25
|
+
const pathWithinMachineDir = isOutsideHome(filePath)
|
|
26
|
+
? path.join(ROOT_NAMESPACE, filePath.slice(1)) // strip leading "/" so it stays inside the machine dir
|
|
27
|
+
: path.join(HOME_NAMESPACE, path.relative(HOME, filePath));
|
|
19
28
|
return path.join(machineDir(machine), pathWithinMachineDir);
|
|
20
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Inverse of getStagedPath: maps a path relative to the machine dir
|
|
32
|
+
* (e.g. "home/.zshrc" or "root/etc/hosts") back to its restore destination.
|
|
33
|
+
*/
|
|
34
|
+
export function getRestoreTarget(machineRelativePath) {
|
|
35
|
+
const [namespace, ...rest] = machineRelativePath.split(path.sep);
|
|
36
|
+
const subPath = rest.join(path.sep);
|
|
37
|
+
if (namespace === ROOT_NAMESPACE) {
|
|
38
|
+
const destination = path.join("/", subPath);
|
|
39
|
+
return { destination, displayPath: destination };
|
|
40
|
+
}
|
|
41
|
+
return { destination: path.join(HOME, subPath), displayPath: path.join("~", subPath) };
|
|
42
|
+
}
|
|
21
43
|
export function cleanStaging(machine) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
44
|
+
// Remove only the backdot-managed namespaces so user-authored files in the
|
|
45
|
+
// machine dir (e.g. a hand-written README.md with restore notes) survive
|
|
46
|
+
// across backups. home/ and root/ are still fully rebuilt, so a backup
|
|
47
|
+
// remains a complete snapshot of the configured files.
|
|
48
|
+
for (const namespace of [HOME_NAMESPACE, ROOT_NAMESPACE]) {
|
|
49
|
+
fs.rmSync(path.join(machineDir(machine), namespace), { recursive: true, force: true });
|
|
25
50
|
}
|
|
26
|
-
|
|
27
|
-
logger.info(`Cleaned staging directory for machine "${machine}"`);
|
|
51
|
+
logger.info(`Cleaned staging namespaces for machine "${machine}"`);
|
|
28
52
|
}
|
|
29
53
|
export function copyToStaging(files, machine, derivedKey) {
|
|
30
54
|
const machineStagingDir = machineDir(machine);
|
|
@@ -54,13 +78,18 @@ export function copyToStaging(files, machine, derivedKey) {
|
|
|
54
78
|
function failedComparisonResult(err) {
|
|
55
79
|
return { backedUp: [], modified: [], notBackedUp: [], error: errorMessage(err) };
|
|
56
80
|
}
|
|
81
|
+
// This machine has no snapshot in the remote yet, so every file counts as "not
|
|
82
|
+
// backed up". Lets `status` preview what a first backup would push instead of erroring.
|
|
83
|
+
function emptyRemoteResult(files) {
|
|
84
|
+
return { backedUp: [], modified: [], notBackedUp: files, remoteIsEmpty: true };
|
|
85
|
+
}
|
|
57
86
|
export async function compareFiles(opts) {
|
|
58
|
-
const { files, machine, repository,
|
|
87
|
+
const { files, machine, repository, resolveKey } = opts;
|
|
59
88
|
if (files.length === 0) {
|
|
60
89
|
return { backedUp: [], modified: [], notBackedUp: [] };
|
|
61
90
|
}
|
|
62
91
|
if (!fs.existsSync(STAGING_GIT_DIR)) {
|
|
63
|
-
return
|
|
92
|
+
return emptyRemoteResult(files);
|
|
64
93
|
}
|
|
65
94
|
const git = simpleGit(STAGING_DIR);
|
|
66
95
|
try {
|
|
@@ -92,8 +121,27 @@ export async function compareFiles(opts) {
|
|
|
92
121
|
catch (err) {
|
|
93
122
|
return failedComparisonResult(err);
|
|
94
123
|
}
|
|
95
|
-
|
|
96
|
-
|
|
124
|
+
// Repo reachable but this machine has nothing backed up yet (empty repo, or it
|
|
125
|
+
// only holds other machines). Treat as a pre-backup preview.
|
|
126
|
+
if (remoteBlobHashes.size === 0) {
|
|
127
|
+
return emptyRemoteResult(files);
|
|
128
|
+
}
|
|
129
|
+
if (resolveKey) {
|
|
130
|
+
const derivedKey = await resolveKey();
|
|
131
|
+
return compareFilesToRemote(files, machine, remoteBlobHashes, ENC_SUFFIX, (file, remoteBlobHash) => {
|
|
132
|
+
try {
|
|
133
|
+
const blobContent = execFileSync("git", ["cat-file", "blob", remoteBlobHash], {
|
|
134
|
+
cwd: STAGING_DIR,
|
|
135
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
136
|
+
});
|
|
137
|
+
const decrypted = decrypt(blobContent, derivedKey);
|
|
138
|
+
const localContent = fs.readFileSync(file);
|
|
139
|
+
return decrypted.equals(localContent);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
97
145
|
}
|
|
98
146
|
let localFileHashes;
|
|
99
147
|
try {
|
|
@@ -106,45 +154,23 @@ export async function compareFiles(opts) {
|
|
|
106
154
|
catch (err) {
|
|
107
155
|
return failedComparisonResult(err);
|
|
108
156
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
result.notBackedUp.push(file);
|
|
114
|
-
}
|
|
115
|
-
else if (remoteBlobHash === localFileHashes[i]) {
|
|
116
|
-
result.backedUp.push(file);
|
|
117
|
-
}
|
|
118
|
-
else {
|
|
119
|
-
result.modified.push(file);
|
|
120
|
-
}
|
|
121
|
-
return result;
|
|
122
|
-
}, { backedUp: [], modified: [], notBackedUp: [] });
|
|
157
|
+
const localHashByFile = new Map(files.map((file, i) => [file, localFileHashes[i]]));
|
|
158
|
+
return compareFilesToRemote(files, machine, remoteBlobHashes, "", (file, remoteBlobHash) => {
|
|
159
|
+
return remoteBlobHash === localHashByFile.get(file);
|
|
160
|
+
});
|
|
123
161
|
}
|
|
124
|
-
function
|
|
162
|
+
function compareFilesToRemote(files, machine, remoteBlobHashes, pathSuffix, matchesRemote) {
|
|
125
163
|
const result = { backedUp: [], modified: [], notBackedUp: [] };
|
|
126
164
|
for (const file of files) {
|
|
127
|
-
const
|
|
128
|
-
const remoteBlobHash = remoteBlobHashes.get(
|
|
165
|
+
const repoRelativePath = path.relative(STAGING_DIR, getStagedPath(file, machine)) + pathSuffix;
|
|
166
|
+
const remoteBlobHash = remoteBlobHashes.get(repoRelativePath);
|
|
129
167
|
if (!remoteBlobHash) {
|
|
130
168
|
result.notBackedUp.push(file);
|
|
131
|
-
continue;
|
|
132
169
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
cwd: STAGING_DIR,
|
|
136
|
-
maxBuffer: 50 * 1024 * 1024,
|
|
137
|
-
});
|
|
138
|
-
const decrypted = decrypt(blobContent, derivedKey);
|
|
139
|
-
const localContent = fs.readFileSync(file);
|
|
140
|
-
if (decrypted.equals(localContent)) {
|
|
141
|
-
result.backedUp.push(file);
|
|
142
|
-
}
|
|
143
|
-
else {
|
|
144
|
-
result.modified.push(file);
|
|
145
|
-
}
|
|
170
|
+
else if (matchesRemote(file, remoteBlobHash)) {
|
|
171
|
+
result.backedUp.push(file);
|
|
146
172
|
}
|
|
147
|
-
|
|
173
|
+
else {
|
|
148
174
|
result.modified.push(file);
|
|
149
175
|
}
|
|
150
176
|
}
|
|
@@ -161,9 +187,11 @@ ${encryptionNote}
|
|
|
161
187
|
## Restore
|
|
162
188
|
|
|
163
189
|
\`\`\`bash
|
|
164
|
-
npx backdot restore ${repository}
|
|
190
|
+
npx backdot restore ${repository} --machine <machine>
|
|
165
191
|
\`\`\`
|
|
166
192
|
|
|
193
|
+
Each machine is a top-level directory here; replace \`<machine>\` with the one you want to restore. A machine may also contain its own \`README.md\` with extra, machine-specific restore steps — backdot preserves it across backups.
|
|
194
|
+
|
|
167
195
|
For full documentation, configuration options, and scheduling, see the [official README](https://github.com/sorenlouv/backdot).
|
|
168
196
|
`;
|
|
169
197
|
}
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
export declare function errorMessage(err: unknown): string;
|
|
2
|
+
/**
|
|
3
|
+
* Extracts the repo path (e.g. "user/repo") from an SSH or HTTPS URL
|
|
4
|
+
* for known hosts. Returns `null` for unrecognized hosts.
|
|
5
|
+
*/
|
|
6
|
+
export declare function extractRepoPath(url: string): {
|
|
7
|
+
host: string;
|
|
8
|
+
repoPath: string;
|
|
9
|
+
} | null;
|
|
2
10
|
export declare function uniq<T>(items: T[]): T[];
|
|
3
11
|
export declare function pluralize(count: number, word: string): string;
|
package/dist/utils.js
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
export function errorMessage(err) {
|
|
2
2
|
return err instanceof Error ? err.message : String(err);
|
|
3
3
|
}
|
|
4
|
+
const KNOWN_HOSTS = ["github.com", "gitlab.com", "bitbucket.org"];
|
|
5
|
+
/**
|
|
6
|
+
* Extracts the repo path (e.g. "user/repo") from an SSH or HTTPS URL
|
|
7
|
+
* for known hosts. Returns `null` for unrecognized hosts.
|
|
8
|
+
*/
|
|
9
|
+
export function extractRepoPath(url) {
|
|
10
|
+
for (const host of KNOWN_HOSTS) {
|
|
11
|
+
const idx = url.indexOf(host);
|
|
12
|
+
if (idx === -1) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const separatorLength = 1; // skip ":" (SSH) or "/" (HTTPS) after hostname
|
|
16
|
+
let repoPath = url.slice(idx + host.length + separatorLength).trim();
|
|
17
|
+
if (repoPath.endsWith(".git")) {
|
|
18
|
+
repoPath = repoPath.slice(0, -4);
|
|
19
|
+
}
|
|
20
|
+
return { host, repoPath };
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
4
24
|
export function uniq(items) {
|
|
5
25
|
return [...new Set(items)];
|
|
6
26
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backdot",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.2",
|
|
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",
|
|
@@ -26,42 +26,42 @@
|
|
|
26
26
|
"scripts": {
|
|
27
27
|
"build": "tsc",
|
|
28
28
|
"build:watch": "tsc --watch",
|
|
29
|
-
"
|
|
30
|
-
"
|
|
29
|
+
"fmt": "prettier --write src/",
|
|
30
|
+
"fmt:check": "prettier --check src/",
|
|
31
31
|
"lint": "eslint src/",
|
|
32
32
|
"lint:fix": "eslint src/ --fix",
|
|
33
|
-
"
|
|
34
|
-
"
|
|
33
|
+
"prepare": "cd .. && husky",
|
|
34
|
+
"start": "node dist/cli.js",
|
|
35
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",
|
|
37
|
-
"test:e2e": "npm run build && vitest run src/e2e.test.ts",
|
|
38
36
|
"test:all": "npm run build && vitest run",
|
|
39
|
-
"test:
|
|
40
|
-
"
|
|
37
|
+
"test:e2e": "npm run build && vitest run src/e2e.test.ts",
|
|
38
|
+
"test:integration": "vitest run src/git.integration.test.ts",
|
|
39
|
+
"test:watch": "vitest"
|
|
41
40
|
},
|
|
42
41
|
"dependencies": {
|
|
43
|
-
"@inquirer/prompts": "^8.
|
|
42
|
+
"@inquirer/prompts": "^8.5.2",
|
|
44
43
|
"cac": "^7.0.0",
|
|
45
44
|
"chalk": "^5.6.2",
|
|
46
45
|
"fast-glob": "^3.3.3",
|
|
47
|
-
"ora": "^9.
|
|
48
|
-
"p-retry": "^
|
|
49
|
-
"simple-git": "^3.
|
|
46
|
+
"ora": "^9.4.0",
|
|
47
|
+
"p-retry": "^8.0.0",
|
|
48
|
+
"simple-git": "^3.36.0",
|
|
50
49
|
"winston": "^3.19.0",
|
|
51
|
-
"zod": "^4.3
|
|
50
|
+
"zod": "^4.4.3"
|
|
52
51
|
},
|
|
53
52
|
"engines": {
|
|
54
|
-
"node": ">=
|
|
53
|
+
"node": ">=24"
|
|
55
54
|
},
|
|
56
55
|
"devDependencies": {
|
|
57
56
|
"@eslint/js": "^10.0.1",
|
|
58
|
-
"@types/node": "^
|
|
59
|
-
"
|
|
57
|
+
"@types/node": "^25.9.3",
|
|
58
|
+
"esbuild": "^0.28.1",
|
|
59
|
+
"eslint": "^10.5.0",
|
|
60
60
|
"eslint-config-prettier": "^10.1.8",
|
|
61
61
|
"husky": "^9.1.7",
|
|
62
|
-
"prettier": "^3.8.
|
|
63
|
-
"typescript": "^
|
|
64
|
-
"typescript-eslint": "^8.
|
|
65
|
-
"vitest": "^4.
|
|
62
|
+
"prettier": "^3.8.4",
|
|
63
|
+
"typescript": "^6.0.3",
|
|
64
|
+
"typescript-eslint": "^8.61.0",
|
|
65
|
+
"vitest": "^4.1.8"
|
|
66
66
|
}
|
|
67
67
|
}
|
package/README.md
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
<p align="center">
|
|
2
|
-
<img src="logo-small.png" alt="backdot" width="400" />
|
|
3
|
-
</p>
|
|
4
|
-
|
|
5
|
-
<h1 align="center">backdot</h1>
|
|
6
|
-
|
|
7
|
-
<p align="center">Automated backup of important files (configs, dotfiles) to your own private Git repo.</p>
|
|
8
|
-
|
|
9
|
-
## Getting started
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
npm install -g backdot
|
|
13
|
-
backdot init
|
|
14
|
-
```
|
|
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:
|
|
17
|
-
|
|
18
|
-
```json
|
|
19
|
-
{
|
|
20
|
-
"repository": "git@github.com:USERNAME/backdot-backup.git",
|
|
21
|
-
"machine": "my-work-laptop",
|
|
22
|
-
"paths": ["~/.zshrc", "~/.oh-my-zsh/custom/*.zsh", "~/.ssh/config", "~/.npmrc"]
|
|
23
|
-
}
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Run your first backup:
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
backdot backup
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
or configure the backport process to run automatically (daily at 2am)
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
backdot schedule
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
## Configuration
|
|
39
|
-
|
|
40
|
-
| Key | Description |
|
|
41
|
-
| ------- | ------------------------------------------------------ |
|
|
42
|
-
| `paths` | Glob patterns matching individual files or directories |
|
|
43
|
-
|
|
44
|
-
Prefix a pattern with `!` to exclude matching files:
|
|
45
|
-
|
|
46
|
-
```json
|
|
47
|
-
{
|
|
48
|
-
"paths": ["~/.config/ghostty/**", "!~/.config/ghostty/crash-reports/**"]
|
|
49
|
-
}
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
## Encryption
|
|
53
|
-
|
|
54
|
-
To encrypt files before they are pushed to the remote repo, add `"encrypt": true` to your config.
|
|
55
|
-
|
|
56
|
-
On first backup you'll be prompted for a password and offered to save it to `~/.backdot.key` so that future backups do not prompt for a password.
|
|
57
|
-
|
|
58
|
-
## Commands
|
|
59
|
-
|
|
60
|
-
| Command | Description |
|
|
61
|
-
| ------------------------------ | ---------------------------------------------- |
|
|
62
|
-
| `init` | Set up backdot for the first time |
|
|
63
|
-
| `backup` | Run a backup now |
|
|
64
|
-
| `restore` | Restore latest backup from the configured repo |
|
|
65
|
-
| `restore <url>` | Restore from a specific repo URL |
|
|
66
|
-
| `restore [url] --commit <sha>` | Restore from a specific backup commit |
|
|
67
|
-
| `history [url]` | Browse and restore a previous backup |
|
|
68
|
-
| `schedule` | Schedule automatic daily backup (Mac-only) |
|
|
69
|
-
| `unschedule` | Unschedule the daily backup |
|
|
70
|
-
| `status` | Show schedule and resolved file list |
|
|
71
|
-
|
|
72
|
-
## Development
|
|
73
|
-
|
|
74
|
-
```bash
|
|
75
|
-
npm install
|
|
76
|
-
npm run build
|
|
77
|
-
npm start
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
## License
|
|
81
|
-
|
|
82
|
-
MIT
|
package/dist/crypto.d.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
export declare const KEY_FILE_PATH: string;
|
|
2
|
-
export declare const ENC_SUFFIX = ".encrypted";
|
|
3
|
-
export declare function encryptBuffer(plaintext: Buffer, password: string): Buffer;
|
|
4
|
-
export declare function decryptBuffer(data: Buffer, password: string): Buffer;
|
|
5
|
-
export declare function checkKeyFilePermissions(): void;
|
|
6
|
-
export declare function saveKeyFile(password: string): void;
|
|
7
|
-
export interface PasswordResult {
|
|
8
|
-
password: string;
|
|
9
|
-
interactive: boolean;
|
|
10
|
-
}
|
|
11
|
-
export declare function resolvePassword(): Promise<PasswordResult>;
|
|
12
|
-
export declare function confirmPassword(password: string): Promise<void>;
|
|
13
|
-
export declare function offerToSaveKeyFile(password: string): Promise<void>;
|