backdot 1.7.0 → 1.8.1
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 -0
- package/dist/cli.js +6 -4
- package/dist/commands/backup.js +75 -4
- package/dist/commands/history.js +9 -6
- package/dist/commands/init.js +3 -2
- package/dist/commands/restore.js +60 -39
- package/dist/commands/schedule.js +14 -0
- package/dist/commands/status.js +46 -11
- package/dist/commitUrl.js +12 -14
- package/dist/config.d.ts +2 -1
- package/dist/config.js +16 -14
- package/dist/crypto/encryption.d.ts +9 -0
- package/dist/crypto/encryption.js +57 -0
- package/dist/crypto/password.d.ts +12 -0
- package/dist/crypto/password.js +78 -0
- package/dist/crypto.d.ts +13 -0
- package/dist/crypto.js +129 -0
- package/dist/encryption.d.ts +2 -0
- package/dist/encryption.js +39 -0
- package/dist/git.d.ts +2 -2
- package/dist/git.js +43 -41
- package/dist/launchd.js +13 -10
- package/dist/notify.js +3 -3
- package/dist/password.d.ts +11 -0
- package/dist/password.js +74 -0
- package/dist/repoVisibility.d.ts +15 -0
- package/dist/repoVisibility.js +51 -0
- package/dist/resolveFiles.js +2 -2
- package/dist/staging.d.ts +9 -3
- package/dist/staging.js +77 -38
- package/dist/utils.d.ts +8 -0
- package/dist/utils.js +20 -0
- package/package.json +9 -17
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type RepoVisibility = "public" | "private" | "unknown";
|
|
2
|
+
/**
|
|
3
|
+
* Converts an SSH or HTTPS repo URL to a credential-free HTTPS URL
|
|
4
|
+
* for known hosts. Returns `null` for unrecognized hosts.
|
|
5
|
+
*
|
|
6
|
+
* Examples:
|
|
7
|
+
* git@github.com:user/repo.git → https://github.com/user/repo.git
|
|
8
|
+
* https://github.com/user/repo → https://github.com/user/repo.git
|
|
9
|
+
*/
|
|
10
|
+
export declare function toHttpsUrl(repository: string): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* Checks whether `repository` is publicly readable by attempting an
|
|
13
|
+
* anonymous `git ls-remote` over HTTPS with all credential helpers disabled.
|
|
14
|
+
*/
|
|
15
|
+
export declare function checkRepoVisibility(repository: string): Promise<RepoVisibility>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { extractRepoPath } from "./utils.js";
|
|
3
|
+
/**
|
|
4
|
+
* Converts an SSH or HTTPS repo URL to a credential-free HTTPS URL
|
|
5
|
+
* for known hosts. Returns `null` for unrecognized hosts.
|
|
6
|
+
*
|
|
7
|
+
* Examples:
|
|
8
|
+
* git@github.com:user/repo.git → https://github.com/user/repo.git
|
|
9
|
+
* https://github.com/user/repo → https://github.com/user/repo.git
|
|
10
|
+
*/
|
|
11
|
+
export function toHttpsUrl(repository) {
|
|
12
|
+
const parsed = extractRepoPath(repository);
|
|
13
|
+
if (!parsed) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return `https://${parsed.host}/${parsed.repoPath}.git`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Checks whether `repository` is publicly readable by attempting an
|
|
20
|
+
* anonymous `git ls-remote` over HTTPS with all credential helpers disabled.
|
|
21
|
+
*/
|
|
22
|
+
export async function checkRepoVisibility(repository) {
|
|
23
|
+
const httpsUrl = toHttpsUrl(repository);
|
|
24
|
+
if (!httpsUrl) {
|
|
25
|
+
return "unknown";
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
await execGitLsRemote(httpsUrl);
|
|
29
|
+
return "public";
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return "private";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function execGitLsRemote(url) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const child = execFile("git", ["-c", "credential.helper=", "ls-remote", "--quiet", url], {
|
|
38
|
+
timeout: 10_000,
|
|
39
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
40
|
+
}, (error) => {
|
|
41
|
+
if (error) {
|
|
42
|
+
reject(error);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
resolve();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
// Don't let stdin keep the process alive
|
|
49
|
+
child.stdin?.end();
|
|
50
|
+
});
|
|
51
|
+
}
|
package/dist/resolveFiles.js
CHANGED
|
@@ -20,8 +20,8 @@ const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
|
|
|
20
20
|
* Skips entries that fail resolution and logs warnings.
|
|
21
21
|
*/
|
|
22
22
|
export function resolveFiles(config) {
|
|
23
|
-
const
|
|
24
|
-
return
|
|
23
|
+
const deduplicatedPaths = uniq(resolveGlobs(config.paths));
|
|
24
|
+
return deduplicatedPaths.filter((filePath) => {
|
|
25
25
|
try {
|
|
26
26
|
fs.accessSync(filePath, fs.constants.R_OK);
|
|
27
27
|
const stat = fs.statSync(filePath);
|
package/dist/staging.d.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { STAGING_DIR, STAGING_GIT_DIR, machineDir } from "./paths.js";
|
|
2
|
+
import { type DerivedKey } from "./crypto/encryption.js";
|
|
2
3
|
export { STAGING_DIR, STAGING_GIT_DIR, machineDir };
|
|
3
4
|
export declare function getStagedPath(filePath: string, machine: string): string;
|
|
4
5
|
export declare function cleanStaging(machine: string): void;
|
|
5
|
-
export declare function copyToStaging(files: string[], machine: string): void;
|
|
6
|
+
export declare function copyToStaging(files: string[], machine: string, derivedKey?: DerivedKey): void;
|
|
6
7
|
export interface ComparisonResult {
|
|
7
8
|
backedUp: string[];
|
|
8
9
|
modified: string[];
|
|
9
10
|
notBackedUp: string[];
|
|
10
11
|
error?: string;
|
|
11
12
|
}
|
|
12
|
-
export declare function compareFiles(
|
|
13
|
-
|
|
13
|
+
export declare function compareFiles(opts: {
|
|
14
|
+
files: string[];
|
|
15
|
+
machine: string;
|
|
16
|
+
repository: string;
|
|
17
|
+
derivedKey?: DerivedKey;
|
|
18
|
+
}): Promise<ComparisonResult>;
|
|
19
|
+
export declare function writeRepoReadme(repository: string, encrypted?: boolean): void;
|
package/dist/staging.js
CHANGED
|
@@ -7,42 +7,55 @@ import { logger } from "./log.js";
|
|
|
7
7
|
import { errorMessage, pluralize } from "./utils.js";
|
|
8
8
|
import { ensureRemoteUrl, getCurrentBranch, gitError } from "./git.js";
|
|
9
9
|
import { STAGING_DIR, STAGING_GIT_DIR, machineDir } from "./paths.js";
|
|
10
|
+
import { encrypt, decrypt } from "./crypto/encryption.js";
|
|
11
|
+
import { KEY_FILE_PATH, ENC_SUFFIX } from "./crypto/password.js";
|
|
10
12
|
export { STAGING_DIR, STAGING_GIT_DIR, machineDir };
|
|
11
13
|
const HOME = os.homedir();
|
|
12
14
|
export function getStagedPath(filePath, machine) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
const relativePath = path.relative(HOME, filePath);
|
|
16
|
+
// Files outside HOME (e.g. /etc/foo) produce a relative path starting with "..",
|
|
17
|
+
// which would escape the machine dir. Use the absolute path minus the leading "/" instead.
|
|
18
|
+
const pathWithinMachineDir = relativePath.startsWith("..") ? filePath.slice(1) : relativePath;
|
|
19
|
+
return path.join(machineDir(machine), pathWithinMachineDir);
|
|
16
20
|
}
|
|
17
21
|
export function cleanStaging(machine) {
|
|
18
|
-
const
|
|
19
|
-
if (!fs.existsSync(
|
|
22
|
+
const machineStagingDir = machineDir(machine);
|
|
23
|
+
if (!fs.existsSync(machineStagingDir)) {
|
|
20
24
|
return;
|
|
21
25
|
}
|
|
22
|
-
fs.rmSync(
|
|
26
|
+
fs.rmSync(machineStagingDir, { recursive: true, force: true });
|
|
23
27
|
logger.info(`Cleaned staging directory for machine "${machine}"`);
|
|
24
28
|
}
|
|
25
|
-
export function copyToStaging(files, machine) {
|
|
26
|
-
const
|
|
27
|
-
fs.mkdirSync(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
export function copyToStaging(files, machine, derivedKey) {
|
|
30
|
+
const machineStagingDir = machineDir(machine);
|
|
31
|
+
fs.mkdirSync(machineStagingDir, { recursive: true });
|
|
32
|
+
const filesExcludingKeyFile = files.filter((f) => path.resolve(f) !== path.resolve(KEY_FILE_PATH));
|
|
33
|
+
let copiedCount = 0;
|
|
34
|
+
for (const filePath of filesExcludingKeyFile) {
|
|
35
|
+
const stagedPath = getStagedPath(filePath, machine);
|
|
36
|
+
const destinationPath = derivedKey ? stagedPath + ENC_SUFFIX : stagedPath;
|
|
31
37
|
try {
|
|
32
|
-
fs.mkdirSync(path.dirname(
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
39
|
+
if (derivedKey) {
|
|
40
|
+
const plaintext = fs.readFileSync(filePath);
|
|
41
|
+
fs.writeFileSync(destinationPath, encrypt(plaintext, derivedKey));
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
fs.copyFileSync(filePath, destinationPath);
|
|
45
|
+
}
|
|
46
|
+
copiedCount++;
|
|
35
47
|
}
|
|
36
48
|
catch {
|
|
37
|
-
logger.warn(`Failed to copy: ${filePath} -> ${
|
|
49
|
+
logger.warn(`Failed to copy: ${filePath} -> ${destinationPath}`);
|
|
38
50
|
}
|
|
39
51
|
}
|
|
40
|
-
logger.info(`Copied ${pluralize(
|
|
52
|
+
logger.info(`Copied ${pluralize(copiedCount, "file")} to staging`);
|
|
41
53
|
}
|
|
42
54
|
function failedComparisonResult(err) {
|
|
43
55
|
return { backedUp: [], modified: [], notBackedUp: [], error: errorMessage(err) };
|
|
44
56
|
}
|
|
45
|
-
export async function compareFiles(
|
|
57
|
+
export async function compareFiles(opts) {
|
|
58
|
+
const { files, machine, repository, derivedKey } = opts;
|
|
46
59
|
if (files.length === 0) {
|
|
47
60
|
return { backedUp: [], modified: [], notBackedUp: [] };
|
|
48
61
|
}
|
|
@@ -64,52 +77,78 @@ export async function compareFiles(files, machine, repository) {
|
|
|
64
77
|
catch (err) {
|
|
65
78
|
return failedComparisonResult(err);
|
|
66
79
|
}
|
|
67
|
-
let
|
|
80
|
+
let remoteBlobHashes;
|
|
68
81
|
try {
|
|
69
82
|
const treeOutput = execFileSync("git", ["ls-tree", "-r", `origin/${branch}`, `${machine}/`], {
|
|
70
83
|
encoding: "utf-8",
|
|
71
84
|
cwd: STAGING_DIR,
|
|
72
85
|
});
|
|
73
|
-
|
|
86
|
+
remoteBlobHashes = new Map(treeOutput
|
|
74
87
|
.split("\n")
|
|
75
88
|
.map((line) => line.match(/^\d+ blob ([0-9a-f]+)\t(.+)$/))
|
|
76
|
-
.filter((
|
|
77
|
-
.map((
|
|
89
|
+
.filter((match) => match !== null)
|
|
90
|
+
.map((match) => [match[2], match[1]]));
|
|
78
91
|
}
|
|
79
92
|
catch (err) {
|
|
80
93
|
return failedComparisonResult(err);
|
|
81
94
|
}
|
|
82
|
-
|
|
95
|
+
if (derivedKey) {
|
|
96
|
+
return compareFilesToRemote(files, machine, remoteBlobHashes, ENC_SUFFIX, (file, remoteBlobHash) => {
|
|
97
|
+
try {
|
|
98
|
+
const blobContent = execFileSync("git", ["cat-file", "blob", remoteBlobHash], {
|
|
99
|
+
cwd: STAGING_DIR,
|
|
100
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
101
|
+
});
|
|
102
|
+
const decrypted = decrypt(blobContent, derivedKey);
|
|
103
|
+
const localContent = fs.readFileSync(file);
|
|
104
|
+
return decrypted.equals(localContent);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
let localFileHashes;
|
|
83
112
|
try {
|
|
84
113
|
const hashOutput = execFileSync("git", ["hash-object", "--stdin-paths"], {
|
|
85
114
|
encoding: "utf-8",
|
|
86
115
|
input: files.join("\n") + "\n",
|
|
87
116
|
});
|
|
88
|
-
|
|
117
|
+
localFileHashes = hashOutput.trim().split("\n");
|
|
89
118
|
}
|
|
90
119
|
catch (err) {
|
|
91
120
|
return failedComparisonResult(err);
|
|
92
121
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
122
|
+
const localHashByFile = new Map(files.map((file, i) => [file, localFileHashes[i]]));
|
|
123
|
+
return compareFilesToRemote(files, machine, remoteBlobHashes, "", (file, remoteBlobHash) => {
|
|
124
|
+
return remoteBlobHash === localHashByFile.get(file);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function compareFilesToRemote(files, machine, remoteBlobHashes, pathSuffix, matchesRemote) {
|
|
128
|
+
const result = { backedUp: [], modified: [], notBackedUp: [] };
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
const repoRelativePath = path.relative(STAGING_DIR, getStagedPath(file, machine)) + pathSuffix;
|
|
131
|
+
const remoteBlobHash = remoteBlobHashes.get(repoRelativePath);
|
|
132
|
+
if (!remoteBlobHash) {
|
|
133
|
+
result.notBackedUp.push(file);
|
|
98
134
|
}
|
|
99
|
-
else if (
|
|
100
|
-
|
|
135
|
+
else if (matchesRemote(file, remoteBlobHash)) {
|
|
136
|
+
result.backedUp.push(file);
|
|
101
137
|
}
|
|
102
138
|
else {
|
|
103
|
-
|
|
139
|
+
result.modified.push(file);
|
|
104
140
|
}
|
|
105
|
-
|
|
106
|
-
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
107
143
|
}
|
|
108
|
-
function
|
|
144
|
+
function generateReadmeContent(repository, encrypted) {
|
|
145
|
+
const encryptionNote = encrypted
|
|
146
|
+
? "\n> **Note:** Files in this repository are encrypted. You will need the backup password to restore.\n"
|
|
147
|
+
: "";
|
|
109
148
|
return `# Backdot Backup
|
|
110
149
|
|
|
111
150
|
This repository contains files backed up automatically using [backdot](https://github.com/sorenlouv/backdot).
|
|
112
|
-
|
|
151
|
+
${encryptionNote}
|
|
113
152
|
## Restore
|
|
114
153
|
|
|
115
154
|
\`\`\`bash
|
|
@@ -119,7 +158,7 @@ npx backdot restore ${repository}
|
|
|
119
158
|
For full documentation, configuration options, and scheduling, see the [official README](https://github.com/sorenlouv/backdot).
|
|
120
159
|
`;
|
|
121
160
|
}
|
|
122
|
-
export function writeRepoReadme(repository) {
|
|
123
|
-
fs.writeFileSync(path.join(STAGING_DIR, "README.md"),
|
|
161
|
+
export function writeRepoReadme(repository, encrypted = false) {
|
|
162
|
+
fs.writeFileSync(path.join(STAGING_DIR, "README.md"), generateReadmeContent(repository, encrypted));
|
|
124
163
|
logger.info("Wrote README.md to staging directory");
|
|
125
164
|
}
|
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.
|
|
3
|
+
"version": "1.8.1",
|
|
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,18 +26,18 @@
|
|
|
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": "husky",
|
|
34
|
+
"release": "./scripts/release.sh",
|
|
35
|
+
"start": "node dist/cli.js",
|
|
35
36
|
"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
37
|
"test:all": "npm run build && vitest run",
|
|
39
|
-
"test:
|
|
40
|
-
"
|
|
38
|
+
"test:e2e": "npm run build && vitest run src/e2e.test.ts",
|
|
39
|
+
"test:integration": "vitest run src/git.integration.test.ts",
|
|
40
|
+
"test:watch": "vitest"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@inquirer/prompts": "^8.3.0",
|
|
@@ -53,20 +53,12 @@
|
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=18"
|
|
55
55
|
},
|
|
56
|
-
"lint-staged": {
|
|
57
|
-
"*.{ts,js}": [
|
|
58
|
-
"prettier --write",
|
|
59
|
-
"eslint --fix"
|
|
60
|
-
],
|
|
61
|
-
"*.{json,md,yml}": "prettier --write"
|
|
62
|
-
},
|
|
63
56
|
"devDependencies": {
|
|
64
57
|
"@eslint/js": "^10.0.1",
|
|
65
58
|
"@types/node": "^22.13.5",
|
|
66
59
|
"eslint": "^10.0.2",
|
|
67
60
|
"eslint-config-prettier": "^10.1.8",
|
|
68
61
|
"husky": "^9.1.7",
|
|
69
|
-
"lint-staged": "^16.2.7",
|
|
70
62
|
"prettier": "^3.8.1",
|
|
71
63
|
"typescript": "^5.7.3",
|
|
72
64
|
"typescript-eslint": "^8.56.1",
|