backdot 1.8.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/dist/cli.js +2 -2
- package/dist/commands/backup.js +18 -9
- package/dist/commands/restore.js +14 -14
- package/dist/commands/schedule.js +1 -1
- package/dist/commitUrl.js +9 -12
- package/dist/config.js +3 -3
- package/dist/crypto/encryption.d.ts +4 -3
- package/dist/crypto/encryption.js +13 -10
- package/dist/crypto/password.d.ts +3 -2
- package/dist/crypto/password.js +11 -7
- package/dist/git.d.ts +1 -1
- package/dist/git.js +6 -7
- package/dist/launchd.js +2 -2
- package/dist/notify.js +3 -3
- package/dist/repoVisibility.js +5 -12
- package/dist/staging.js +24 -33
- package/dist/utils.d.ts +8 -0
- package/dist/utils.js +20 -0
- package/package.json +9 -9
package/dist/cli.js
CHANGED
|
@@ -16,8 +16,8 @@ import { sendNotification } from "./notify.js";
|
|
|
16
16
|
function getVersion() {
|
|
17
17
|
const pkgPath = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../package.json");
|
|
18
18
|
try {
|
|
19
|
-
const
|
|
20
|
-
return
|
|
19
|
+
const packageJson = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
20
|
+
return packageJson.version ?? "unknown";
|
|
21
21
|
}
|
|
22
22
|
catch {
|
|
23
23
|
return "unknown";
|
package/dist/commands/backup.js
CHANGED
|
@@ -8,6 +8,7 @@ import { gitPull, gitCommitAndPush } from "../git.js";
|
|
|
8
8
|
import { logger } from "../log.js";
|
|
9
9
|
import { pluralize } from "../utils.js";
|
|
10
10
|
import { checkRepoVisibility } from "../repoVisibility.js";
|
|
11
|
+
import { confirm } from "@inquirer/prompts";
|
|
11
12
|
import { decrypt, deriveKey } from "../crypto/encryption.js";
|
|
12
13
|
import { resolvePassword, offerToSaveKeyFile, confirmPassword, ENC_SUFFIX, } from "../crypto/password.js";
|
|
13
14
|
function findEncryptedFile(dir) {
|
|
@@ -35,11 +36,12 @@ export async function backup() {
|
|
|
35
36
|
logger.info(`Machine: ${config.machine}`);
|
|
36
37
|
let password;
|
|
37
38
|
let derivedKey;
|
|
38
|
-
let passwordWasInteractive = false;
|
|
39
39
|
if (config.encrypt) {
|
|
40
40
|
const result = await resolvePassword();
|
|
41
41
|
password = result.password;
|
|
42
|
-
|
|
42
|
+
if (result.source === "prompt") {
|
|
43
|
+
await confirmPassword(password);
|
|
44
|
+
}
|
|
43
45
|
derivedKey = deriveKey(password);
|
|
44
46
|
}
|
|
45
47
|
const spinner = ora("Checking repository visibility").start();
|
|
@@ -70,15 +72,22 @@ export async function backup() {
|
|
|
70
72
|
decrypt(encryptedContent, derivedKey);
|
|
71
73
|
}
|
|
72
74
|
catch {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
if (!process.stdin.isTTY) {
|
|
76
|
+
spinner.fail("Backup failed");
|
|
77
|
+
throw new Error("Password does not match the existing backup.\n" +
|
|
78
|
+
"Run interactively to re-encrypt with a new password.");
|
|
79
|
+
}
|
|
80
|
+
spinner.stop();
|
|
81
|
+
const shouldReEncrypt = await confirm({
|
|
82
|
+
message: "Password does not match the existing backup. Re-encrypt all files with the new password?",
|
|
83
|
+
default: false,
|
|
84
|
+
});
|
|
85
|
+
if (!shouldReEncrypt) {
|
|
86
|
+
throw new Error("Backup aborted.");
|
|
87
|
+
}
|
|
88
|
+
spinner.start();
|
|
75
89
|
}
|
|
76
90
|
}
|
|
77
|
-
else if (passwordWasInteractive) {
|
|
78
|
-
spinner.stop();
|
|
79
|
-
await confirmPassword(password);
|
|
80
|
-
spinner.start();
|
|
81
|
-
}
|
|
82
91
|
}
|
|
83
92
|
spinner.text = `Copying ${pluralize(files.length, "file")} to staging`;
|
|
84
93
|
cleanStaging(config.machine);
|
package/dist/commands/restore.js
CHANGED
|
@@ -55,7 +55,7 @@ async function resolveRepoAndMachine(repoUrl, commit) {
|
|
|
55
55
|
machine = await select({
|
|
56
56
|
message: "Multiple machines found. Which one do you want to restore?",
|
|
57
57
|
loop: false,
|
|
58
|
-
choices: machines.map((
|
|
58
|
+
choices: machines.map((machine) => ({ name: machine, value: machine })),
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
61
|
return { repository: repoUrl, machine };
|
|
@@ -64,7 +64,7 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
|
|
|
64
64
|
logger.info("Starting restore");
|
|
65
65
|
const { repository, machine } = await resolveRepoAndMachine(repoUrl, commit);
|
|
66
66
|
const spinner = ora("Fetching latest backup").start();
|
|
67
|
-
const
|
|
67
|
+
const machineStagingDir = machineDir(machine);
|
|
68
68
|
try {
|
|
69
69
|
if (!repoUrl) {
|
|
70
70
|
await gitPull(repository, commit);
|
|
@@ -75,31 +75,31 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
|
|
|
75
75
|
throw err;
|
|
76
76
|
}
|
|
77
77
|
spinner.text = "Resolving files";
|
|
78
|
-
if (!fs.existsSync(
|
|
78
|
+
if (!fs.existsSync(machineStagingDir)) {
|
|
79
79
|
spinner.stop();
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
80
|
+
const availableMachines = listMachines();
|
|
81
|
+
if (availableMachines.length > 0) {
|
|
82
82
|
console.log(`\n No backup found for machine "${machine}".`);
|
|
83
|
-
console.log(` Available machines: ${
|
|
83
|
+
console.log(` Available machines: ${availableMachines.join(", ")}\n`);
|
|
84
84
|
}
|
|
85
85
|
else {
|
|
86
86
|
console.log(`\n No backup found for machine "${machine}". The repository is empty.\n`);
|
|
87
87
|
}
|
|
88
88
|
return;
|
|
89
89
|
}
|
|
90
|
-
const
|
|
91
|
-
logger.info(`Found ${pluralize(
|
|
92
|
-
if (
|
|
90
|
+
const backupFiles = listFilesRecursively(machineStagingDir);
|
|
91
|
+
logger.info(`Found ${pluralize(backupFiles.length, "file")} in backup repository`);
|
|
92
|
+
if (backupFiles.length === 0) {
|
|
93
93
|
spinner.stop();
|
|
94
94
|
console.log("No files found in backup repository.");
|
|
95
95
|
return;
|
|
96
96
|
}
|
|
97
|
-
const fileMappings =
|
|
98
|
-
let relativePath = path.relative(
|
|
97
|
+
const fileMappings = backupFiles.map((backupFilePath) => {
|
|
98
|
+
let relativePath = path.relative(machineStagingDir, backupFilePath);
|
|
99
99
|
if (relativePath.endsWith(ENC_SUFFIX)) {
|
|
100
100
|
relativePath = relativePath.slice(0, -ENC_SUFFIX.length);
|
|
101
101
|
}
|
|
102
|
-
return { src:
|
|
102
|
+
return { src: backupFilePath, dest: path.join(HOME, relativePath), relativePath };
|
|
103
103
|
});
|
|
104
104
|
const filesAlreadyOnDisk = fileMappings.filter((file) => fs.existsSync(file.dest));
|
|
105
105
|
const newFiles = fileMappings.filter((file) => !fs.existsSync(file.dest));
|
|
@@ -119,13 +119,13 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
|
|
|
119
119
|
if (newFiles.length > 0) {
|
|
120
120
|
choices.push(new Separator(`── New files (${newFiles.length}) ──`));
|
|
121
121
|
for (const file of newFiles) {
|
|
122
|
-
choices.push({ name: file.
|
|
122
|
+
choices.push({ name: file.relativePath, value: file, checked: true });
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
if (filesAlreadyOnDisk.length > 0) {
|
|
126
126
|
choices.push(new Separator(`── Existing files — will overwrite (${filesAlreadyOnDisk.length}) ──`));
|
|
127
127
|
for (const file of filesAlreadyOnDisk) {
|
|
128
|
-
choices.push({ name: file.
|
|
128
|
+
choices.push({ name: file.relativePath, value: file, checked: false });
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
filesToRestore = await checkbox({
|
|
@@ -14,7 +14,7 @@ export function schedule() {
|
|
|
14
14
|
try {
|
|
15
15
|
const config = loadConfig();
|
|
16
16
|
if (config.encrypt && !fs.existsSync(KEY_FILE_PATH)) {
|
|
17
|
-
console.log(chalk.yellow(` Encryption is enabled. Run ${chalk.bold("backdot backup")} once to create
|
|
17
|
+
console.log(chalk.yellow(` Encryption is enabled. Run ${chalk.bold("backdot backup")} once to create ${KEY_FILE_PATH},\n` +
|
|
18
18
|
` or set ${chalk.bold("BACKDOT_PASSWORD")} in your environment.\n`));
|
|
19
19
|
}
|
|
20
20
|
}
|
package/dist/commitUrl.js
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
1
|
+
import { extractRepoPath } from "./utils.js";
|
|
1
2
|
const PROVIDERS = {
|
|
2
3
|
"github.com": (repoPath, sha) => `https://github.com/${repoPath}/commit/${sha}`,
|
|
3
4
|
"gitlab.com": (repoPath, sha) => `https://gitlab.com/${repoPath}/-/commit/${sha}`,
|
|
4
5
|
"bitbucket.org": (repoPath, sha) => `https://bitbucket.org/${repoPath}/commits/${sha}`,
|
|
5
6
|
};
|
|
6
7
|
export function getCommitUrl(remoteUrl, sha) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
continue;
|
|
11
|
-
}
|
|
12
|
-
const separatorLength = 1; // skip the ":" (SSH) or "/" (HTTPS) after the hostname
|
|
13
|
-
let repoPath = remoteUrl.slice(idx + host.length + separatorLength).trim();
|
|
14
|
-
if (repoPath.endsWith(".git")) {
|
|
15
|
-
repoPath = repoPath.slice(0, -4);
|
|
16
|
-
}
|
|
17
|
-
return buildUrl(repoPath, sha);
|
|
8
|
+
const parsed = extractRepoPath(remoteUrl);
|
|
9
|
+
if (!parsed) {
|
|
10
|
+
return null;
|
|
18
11
|
}
|
|
19
|
-
|
|
12
|
+
const buildUrl = PROVIDERS[parsed.host];
|
|
13
|
+
if (!buildUrl) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return buildUrl(parsed.repoPath, sha);
|
|
20
17
|
}
|
package/dist/config.js
CHANGED
|
@@ -32,14 +32,14 @@ export function loadConfig() {
|
|
|
32
32
|
catch {
|
|
33
33
|
throw new Error(`Failed to read config file: ${CONFIG_PATH}`);
|
|
34
34
|
}
|
|
35
|
-
let
|
|
35
|
+
let parsedJson;
|
|
36
36
|
try {
|
|
37
|
-
|
|
37
|
+
parsedJson = JSON.parse(rawJson);
|
|
38
38
|
}
|
|
39
39
|
catch {
|
|
40
40
|
throw new Error(`Invalid JSON in config file: ${CONFIG_PATH}`);
|
|
41
41
|
}
|
|
42
|
-
const result = ConfigSchema.safeParse(
|
|
42
|
+
const result = ConfigSchema.safeParse(parsedJson);
|
|
43
43
|
if (!result.success) {
|
|
44
44
|
const messages = result.error.issues.map((issue) => {
|
|
45
45
|
const field = issue.path.length > 0 ? `"${issue.path.join(".")}"` : "config";
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
/** An encryption key derived from the user's password via scrypt. */
|
|
1
2
|
export interface DerivedKey {
|
|
2
|
-
|
|
3
|
+
passwordHash: string;
|
|
3
4
|
salt: Buffer;
|
|
4
5
|
key: Buffer;
|
|
5
6
|
}
|
|
6
|
-
export declare function deriveKey(
|
|
7
|
+
export declare function deriveKey(passwordHash: string, salt?: Buffer): DerivedKey;
|
|
7
8
|
export declare function encrypt(plaintext: Buffer, derivedKey: DerivedKey): Buffer;
|
|
8
|
-
export declare function decrypt(
|
|
9
|
+
export declare function decrypt(encryptedPayload: Buffer, derivedKey: DerivedKey): Buffer;
|
|
@@ -15,16 +15,16 @@ const SCRYPT_PARAMS = {
|
|
|
15
15
|
maxmem: 128 * SCRYPT_N * SCRYPT_R * 2, // must exceed 128*N*r
|
|
16
16
|
};
|
|
17
17
|
const OVERHEAD = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;
|
|
18
|
-
export function deriveKey(
|
|
18
|
+
export function deriveKey(passwordHash, salt) {
|
|
19
19
|
const resolvedSalt = salt ?? crypto.randomBytes(SALT_LENGTH);
|
|
20
|
-
const key = crypto.scryptSync(
|
|
21
|
-
return {
|
|
20
|
+
const key = crypto.scryptSync(passwordHash, resolvedSalt, KEY_LENGTH, SCRYPT_PARAMS);
|
|
21
|
+
return { passwordHash, salt: resolvedSalt, key };
|
|
22
22
|
}
|
|
23
23
|
function deriveKeyForSalt(derivedKey, salt) {
|
|
24
24
|
if (derivedKey.salt.equals(salt)) {
|
|
25
25
|
return derivedKey.key;
|
|
26
26
|
}
|
|
27
|
-
return crypto.scryptSync(derivedKey.
|
|
27
|
+
return crypto.scryptSync(derivedKey.passwordHash, salt, KEY_LENGTH, SCRYPT_PARAMS);
|
|
28
28
|
}
|
|
29
29
|
export function encrypt(plaintext, derivedKey) {
|
|
30
30
|
const iv = crypto.randomBytes(IV_LENGTH);
|
|
@@ -33,15 +33,18 @@ export function encrypt(plaintext, derivedKey) {
|
|
|
33
33
|
const authTag = cipher.getAuthTag();
|
|
34
34
|
return Buffer.concat([derivedKey.salt, iv, authTag, ciphertext]);
|
|
35
35
|
}
|
|
36
|
-
export function decrypt(
|
|
37
|
-
if (
|
|
36
|
+
export function decrypt(encryptedPayload, derivedKey) {
|
|
37
|
+
if (encryptedPayload.length < OVERHEAD) {
|
|
38
38
|
throw new Error("Data is too short to be encrypted content.");
|
|
39
39
|
}
|
|
40
40
|
let offset = 0;
|
|
41
|
-
const salt =
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
41
|
+
const salt = encryptedPayload.subarray(offset, offset + SALT_LENGTH);
|
|
42
|
+
offset += SALT_LENGTH;
|
|
43
|
+
const iv = encryptedPayload.subarray(offset, offset + IV_LENGTH);
|
|
44
|
+
offset += IV_LENGTH;
|
|
45
|
+
const authTag = encryptedPayload.subarray(offset, offset + AUTH_TAG_LENGTH);
|
|
46
|
+
offset += AUTH_TAG_LENGTH;
|
|
47
|
+
const ciphertext = encryptedPayload.subarray(offset);
|
|
45
48
|
const key = deriveKeyForSalt(derivedKey, salt);
|
|
46
49
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
47
50
|
decipher.setAuthTag(authTag);
|
|
@@ -2,10 +2,11 @@ export declare const KEY_FILE_PATH: string;
|
|
|
2
2
|
export declare const ENC_SUFFIX = ".encrypted";
|
|
3
3
|
export declare function checkKeyFilePermissions(): void;
|
|
4
4
|
export declare function saveKeyFile(password: string): void;
|
|
5
|
+
export type PasswordSource = "env" | "file" | "prompt";
|
|
5
6
|
export interface PasswordResult {
|
|
6
7
|
password: string;
|
|
7
|
-
|
|
8
|
+
source: PasswordSource;
|
|
8
9
|
}
|
|
9
10
|
export declare function resolvePassword(): Promise<PasswordResult>;
|
|
10
|
-
export declare function confirmPassword(
|
|
11
|
+
export declare function confirmPassword(hashedPassword: string): Promise<void>;
|
|
11
12
|
export declare function offerToSaveKeyFile(password: string): Promise<void>;
|
package/dist/crypto/password.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import { password as passwordPrompt, confirm } from "@inquirer/prompts";
|
|
5
6
|
export const KEY_FILE_PATH = path.join(os.homedir(), ".backdot.key");
|
|
6
7
|
export const ENC_SUFFIX = ".encrypted";
|
|
8
|
+
function hashPassword(password) {
|
|
9
|
+
return crypto.createHash("sha256").update(password).digest("hex");
|
|
10
|
+
}
|
|
7
11
|
export function checkKeyFilePermissions() {
|
|
8
12
|
if (process.platform === "win32") {
|
|
9
13
|
return;
|
|
@@ -32,24 +36,24 @@ function readKeyFile() {
|
|
|
32
36
|
export async function resolvePassword() {
|
|
33
37
|
const envPassword = process.env.BACKDOT_PASSWORD;
|
|
34
38
|
if (envPassword) {
|
|
35
|
-
return { password: envPassword,
|
|
39
|
+
return { password: hashPassword(envPassword), source: "env" };
|
|
36
40
|
}
|
|
37
41
|
const filePassword = readKeyFile();
|
|
38
42
|
if (filePassword) {
|
|
39
|
-
return { password: filePassword,
|
|
43
|
+
return { password: filePassword, source: "file" };
|
|
40
44
|
}
|
|
41
45
|
if (!process.stdin.isTTY) {
|
|
42
|
-
throw new Error(
|
|
46
|
+
throw new Error(`Encryption is enabled but no password found.\n Run "backdot backup" interactively to create ${KEY_FILE_PATH}, or set BACKDOT_PASSWORD.`);
|
|
43
47
|
}
|
|
44
48
|
const enteredPassword = await passwordPrompt({ message: "Enter encryption password:" });
|
|
45
49
|
if (!enteredPassword) {
|
|
46
50
|
throw new Error("No password provided.");
|
|
47
51
|
}
|
|
48
|
-
return { password: enteredPassword,
|
|
52
|
+
return { password: hashPassword(enteredPassword), source: "prompt" };
|
|
49
53
|
}
|
|
50
|
-
export async function confirmPassword(
|
|
54
|
+
export async function confirmPassword(hashedPassword) {
|
|
51
55
|
const confirmedPassword = await passwordPrompt({ message: "Confirm password:" });
|
|
52
|
-
if (confirmedPassword !==
|
|
56
|
+
if (hashPassword(confirmedPassword) !== hashedPassword) {
|
|
53
57
|
throw new Error("Passwords do not match.");
|
|
54
58
|
}
|
|
55
59
|
}
|
|
@@ -64,7 +68,7 @@ export async function offerToSaveKeyFile(password) {
|
|
|
64
68
|
return;
|
|
65
69
|
}
|
|
66
70
|
const save = await confirm({
|
|
67
|
-
message:
|
|
71
|
+
message: `Save password to ${KEY_FILE_PATH} for automated backups?`,
|
|
68
72
|
default: true,
|
|
69
73
|
});
|
|
70
74
|
if (save) {
|
package/dist/git.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ interface FileChangeSummary {
|
|
|
11
11
|
export declare function buildCommitMessage(changes: FileChangeSummary, maxLength?: number): string;
|
|
12
12
|
export declare function ensureRemoteUrl(repository: string): Promise<void>;
|
|
13
13
|
export declare function getCurrentBranch(git: SimpleGit): Promise<string>;
|
|
14
|
-
export declare function friendlyGitError(
|
|
14
|
+
export declare function friendlyGitError(rawErrorMessage: string, repository: string): string;
|
|
15
15
|
export declare function gitError(err: unknown, repository: string): Error;
|
|
16
16
|
export declare function gitPull(repository: string, commit?: string): Promise<void>;
|
|
17
17
|
export declare function gitLog(limit?: number): Promise<Array<{
|
package/dist/git.js
CHANGED
|
@@ -57,8 +57,8 @@ export async function ensureRemoteUrl(repository) {
|
|
|
57
57
|
export async function getCurrentBranch(git) {
|
|
58
58
|
return (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
59
59
|
}
|
|
60
|
-
export function friendlyGitError(
|
|
61
|
-
const normalizedMessage =
|
|
60
|
+
export function friendlyGitError(rawErrorMessage, repository) {
|
|
61
|
+
const normalizedMessage = rawErrorMessage.toLowerCase();
|
|
62
62
|
if (normalizedMessage.includes("not found") ||
|
|
63
63
|
normalizedMessage.includes("does not exist") ||
|
|
64
64
|
normalizedMessage.includes("does not appear to be a git repository")) {
|
|
@@ -73,7 +73,7 @@ export function friendlyGitError(raw, repository) {
|
|
|
73
73
|
normalizedMessage.includes("connection timed out")) {
|
|
74
74
|
return "Could not connect to remote host. Check your internet connection.";
|
|
75
75
|
}
|
|
76
|
-
return
|
|
76
|
+
return rawErrorMessage;
|
|
77
77
|
}
|
|
78
78
|
export function gitError(err, repository) {
|
|
79
79
|
const raw = errorMessage(err);
|
|
@@ -116,8 +116,8 @@ export async function gitPull(repository, commit) {
|
|
|
116
116
|
}
|
|
117
117
|
export async function gitLog(limit = 20) {
|
|
118
118
|
const git = simpleGit(STAGING_DIR);
|
|
119
|
-
const
|
|
120
|
-
return
|
|
119
|
+
const logResult = await git.log({ maxCount: limit });
|
|
120
|
+
return logResult.all.map((entry) => ({
|
|
121
121
|
hash: entry.hash,
|
|
122
122
|
date: entry.date,
|
|
123
123
|
message: entry.message,
|
|
@@ -133,6 +133,7 @@ export async function gitCommitAndPush() {
|
|
|
133
133
|
}
|
|
134
134
|
const message = buildCommitMessage(status);
|
|
135
135
|
await git.commit(message);
|
|
136
|
+
const remoteUrl = ((await git.remote(["get-url", "origin"])) ?? "").trim();
|
|
136
137
|
try {
|
|
137
138
|
await pRetry(async () => git.push(["-u", "origin", "HEAD"]), {
|
|
138
139
|
retries: 5,
|
|
@@ -151,12 +152,10 @@ export async function gitCommitAndPush() {
|
|
|
151
152
|
});
|
|
152
153
|
}
|
|
153
154
|
catch (err) {
|
|
154
|
-
const remoteUrl = ((await git.remote(["get-url", "origin"])) ?? "").trim();
|
|
155
155
|
throw gitError(err, remoteUrl);
|
|
156
156
|
}
|
|
157
157
|
logger.info(`Committed and pushed: ${message}`);
|
|
158
158
|
const commitHash = (await git.revparse(["HEAD"])).trim();
|
|
159
|
-
const remoteUrl = (await git.remote(["get-url", "origin"])) ?? "";
|
|
160
159
|
const commitUrl = getCommitUrl(remoteUrl, commitHash);
|
|
161
160
|
return { commitUrl };
|
|
162
161
|
}
|
package/dist/launchd.js
CHANGED
|
@@ -5,8 +5,8 @@ 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
|
-
function escapeXml(
|
|
9
|
-
return
|
|
8
|
+
function escapeXml(text) {
|
|
9
|
+
return text
|
|
10
10
|
.replace(/&/g, "&")
|
|
11
11
|
.replace(/</g, "<")
|
|
12
12
|
.replace(/>/g, ">")
|
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/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
|
package/dist/staging.js
CHANGED
|
@@ -93,7 +93,20 @@ export async function compareFiles(opts) {
|
|
|
93
93
|
return failedComparisonResult(err);
|
|
94
94
|
}
|
|
95
95
|
if (derivedKey) {
|
|
96
|
-
return
|
|
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
|
+
});
|
|
97
110
|
}
|
|
98
111
|
let localFileHashes;
|
|
99
112
|
try {
|
|
@@ -106,45 +119,23 @@ export async function compareFiles(opts) {
|
|
|
106
119
|
catch (err) {
|
|
107
120
|
return failedComparisonResult(err);
|
|
108
121
|
}
|
|
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: [] });
|
|
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
|
+
});
|
|
123
126
|
}
|
|
124
|
-
function
|
|
127
|
+
function compareFilesToRemote(files, machine, remoteBlobHashes, pathSuffix, matchesRemote) {
|
|
125
128
|
const result = { backedUp: [], modified: [], notBackedUp: [] };
|
|
126
129
|
for (const file of files) {
|
|
127
|
-
const
|
|
128
|
-
const remoteBlobHash = remoteBlobHashes.get(
|
|
130
|
+
const repoRelativePath = path.relative(STAGING_DIR, getStagedPath(file, machine)) + pathSuffix;
|
|
131
|
+
const remoteBlobHash = remoteBlobHashes.get(repoRelativePath);
|
|
129
132
|
if (!remoteBlobHash) {
|
|
130
133
|
result.notBackedUp.push(file);
|
|
131
|
-
continue;
|
|
132
134
|
}
|
|
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
|
-
}
|
|
135
|
+
else if (matchesRemote(file, remoteBlobHash)) {
|
|
136
|
+
result.backedUp.push(file);
|
|
146
137
|
}
|
|
147
|
-
|
|
138
|
+
else {
|
|
148
139
|
result.modified.push(file);
|
|
149
140
|
}
|
|
150
141
|
}
|
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.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",
|