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,57 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
const ALGORITHM = "aes-256-gcm";
|
|
3
|
+
const SALT_LENGTH = 32;
|
|
4
|
+
const IV_LENGTH = 12;
|
|
5
|
+
const AUTH_TAG_LENGTH = 16;
|
|
6
|
+
const KEY_LENGTH = 32;
|
|
7
|
+
// Higher N = harder to brute-force but slower to encrypt/decrypt (~300ms).
|
|
8
|
+
// 2^17 is the OWASP minimum recommendation for password-based key derivation.
|
|
9
|
+
const SCRYPT_N = 2 ** 17;
|
|
10
|
+
const SCRYPT_R = 8;
|
|
11
|
+
const SCRYPT_PARAMS = {
|
|
12
|
+
N: SCRYPT_N,
|
|
13
|
+
r: SCRYPT_R,
|
|
14
|
+
p: 1,
|
|
15
|
+
maxmem: 128 * SCRYPT_N * SCRYPT_R * 2, // must exceed 128*N*r
|
|
16
|
+
};
|
|
17
|
+
const OVERHEAD = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;
|
|
18
|
+
export function deriveKey(passwordHash, salt) {
|
|
19
|
+
const resolvedSalt = salt ?? crypto.randomBytes(SALT_LENGTH);
|
|
20
|
+
const key = crypto.scryptSync(passwordHash, resolvedSalt, KEY_LENGTH, SCRYPT_PARAMS);
|
|
21
|
+
return { passwordHash, salt: resolvedSalt, key };
|
|
22
|
+
}
|
|
23
|
+
function deriveKeyForSalt(derivedKey, salt) {
|
|
24
|
+
if (derivedKey.salt.equals(salt)) {
|
|
25
|
+
return derivedKey.key;
|
|
26
|
+
}
|
|
27
|
+
return crypto.scryptSync(derivedKey.passwordHash, salt, KEY_LENGTH, SCRYPT_PARAMS);
|
|
28
|
+
}
|
|
29
|
+
export function encrypt(plaintext, derivedKey) {
|
|
30
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
31
|
+
const cipher = crypto.createCipheriv(ALGORITHM, derivedKey.key, iv);
|
|
32
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
33
|
+
const authTag = cipher.getAuthTag();
|
|
34
|
+
return Buffer.concat([derivedKey.salt, iv, authTag, ciphertext]);
|
|
35
|
+
}
|
|
36
|
+
export function decrypt(encryptedPayload, derivedKey) {
|
|
37
|
+
if (encryptedPayload.length < OVERHEAD) {
|
|
38
|
+
throw new Error("Data is too short to be encrypted content.");
|
|
39
|
+
}
|
|
40
|
+
let offset = 0;
|
|
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);
|
|
48
|
+
const key = deriveKeyForSalt(derivedKey, salt);
|
|
49
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
50
|
+
decipher.setAuthTag(authTag);
|
|
51
|
+
try {
|
|
52
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Error("Decryption failed — wrong password or corrupted data.");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const KEY_FILE_PATH: string;
|
|
2
|
+
export declare const ENC_SUFFIX = ".encrypted";
|
|
3
|
+
export declare function checkKeyFilePermissions(): void;
|
|
4
|
+
export declare function saveKeyFile(password: string): void;
|
|
5
|
+
export type PasswordSource = "env" | "file" | "prompt";
|
|
6
|
+
export interface PasswordResult {
|
|
7
|
+
password: string;
|
|
8
|
+
source: PasswordSource;
|
|
9
|
+
}
|
|
10
|
+
export declare function resolvePassword(): Promise<PasswordResult>;
|
|
11
|
+
export declare function confirmPassword(hashedPassword: string): Promise<void>;
|
|
12
|
+
export declare function offerToSaveKeyFile(password: string): Promise<void>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { password as passwordPrompt, confirm } from "@inquirer/prompts";
|
|
6
|
+
export const KEY_FILE_PATH = path.join(os.homedir(), ".backdot.key");
|
|
7
|
+
export const ENC_SUFFIX = ".encrypted";
|
|
8
|
+
function hashPassword(password) {
|
|
9
|
+
return crypto.createHash("sha256").update(password).digest("hex");
|
|
10
|
+
}
|
|
11
|
+
export function checkKeyFilePermissions() {
|
|
12
|
+
if (process.platform === "win32") {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (!fs.existsSync(KEY_FILE_PATH)) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const stat = fs.statSync(KEY_FILE_PATH);
|
|
19
|
+
const mode = stat.mode & 0o777;
|
|
20
|
+
const isAccessibleByGroupOrOthers = (mode & 0o077) !== 0;
|
|
21
|
+
if (isAccessibleByGroupOrOthers) {
|
|
22
|
+
throw new Error(`Key file ${KEY_FILE_PATH} has overly permissive permissions (${mode.toString(8)}).\n` +
|
|
23
|
+
` Run: chmod 600 ${KEY_FILE_PATH}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function saveKeyFile(password) {
|
|
27
|
+
fs.writeFileSync(KEY_FILE_PATH, password + "\n", { mode: 0o600 });
|
|
28
|
+
}
|
|
29
|
+
function readKeyFile() {
|
|
30
|
+
if (!fs.existsSync(KEY_FILE_PATH)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
checkKeyFilePermissions();
|
|
34
|
+
return fs.readFileSync(KEY_FILE_PATH, "utf-8").trimEnd();
|
|
35
|
+
}
|
|
36
|
+
export async function resolvePassword() {
|
|
37
|
+
const envPassword = process.env.BACKDOT_PASSWORD;
|
|
38
|
+
if (envPassword) {
|
|
39
|
+
return { password: hashPassword(envPassword), source: "env" };
|
|
40
|
+
}
|
|
41
|
+
const filePassword = readKeyFile();
|
|
42
|
+
if (filePassword) {
|
|
43
|
+
return { password: filePassword, source: "file" };
|
|
44
|
+
}
|
|
45
|
+
if (!process.stdin.isTTY) {
|
|
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.`);
|
|
47
|
+
}
|
|
48
|
+
const enteredPassword = await passwordPrompt({ message: "Enter encryption password:" });
|
|
49
|
+
if (!enteredPassword) {
|
|
50
|
+
throw new Error("No password provided.");
|
|
51
|
+
}
|
|
52
|
+
return { password: hashPassword(enteredPassword), source: "prompt" };
|
|
53
|
+
}
|
|
54
|
+
export async function confirmPassword(hashedPassword) {
|
|
55
|
+
const confirmedPassword = await passwordPrompt({ message: "Confirm password:" });
|
|
56
|
+
if (hashPassword(confirmedPassword) !== hashedPassword) {
|
|
57
|
+
throw new Error("Passwords do not match.");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function offerToSaveKeyFile(password) {
|
|
61
|
+
if (!process.stdin.isTTY) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (fs.existsSync(KEY_FILE_PATH)) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (process.env.BACKDOT_PASSWORD) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const save = await confirm({
|
|
71
|
+
message: `Save password to ${KEY_FILE_PATH} for automated backups?`,
|
|
72
|
+
default: true,
|
|
73
|
+
});
|
|
74
|
+
if (save) {
|
|
75
|
+
saveKeyFile(password);
|
|
76
|
+
console.log(` Created ${KEY_FILE_PATH} (permissions: 600)`);
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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>;
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { password as passwordPrompt, confirm } from "@inquirer/prompts";
|
|
6
|
+
// File-format signature identifying backdot-encrypted files
|
|
7
|
+
const MAGIC = Buffer.from("BDOT");
|
|
8
|
+
const VERSION = 0x01;
|
|
9
|
+
const HEADER_SIZE = MAGIC.length + 1; // 5 bytes: magic + version
|
|
10
|
+
const SALT_SIZE = 32;
|
|
11
|
+
const IV_SIZE = 12;
|
|
12
|
+
const TAG_SIZE = 16;
|
|
13
|
+
const KEY_SIZE = 32;
|
|
14
|
+
export const KEY_FILE_PATH = path.join(os.homedir(), ".backdot.key");
|
|
15
|
+
export const ENC_SUFFIX = ".encrypted";
|
|
16
|
+
function deriveKey(password, salt) {
|
|
17
|
+
return crypto.scryptSync(password, salt, KEY_SIZE, { N: 2 ** 14, r: 8, p: 1 });
|
|
18
|
+
}
|
|
19
|
+
export function encryptBuffer(plaintext, password) {
|
|
20
|
+
const salt = crypto.randomBytes(SALT_SIZE);
|
|
21
|
+
const iv = crypto.randomBytes(IV_SIZE);
|
|
22
|
+
const key = deriveKey(password, salt);
|
|
23
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
24
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
25
|
+
const tag = cipher.getAuthTag();
|
|
26
|
+
return Buffer.concat([MAGIC, Buffer.from([VERSION]), salt, iv, tag, encrypted]);
|
|
27
|
+
}
|
|
28
|
+
export function decryptBuffer(data, password) {
|
|
29
|
+
if (!isEncrypted(data)) {
|
|
30
|
+
throw new Error("File is not encrypted (missing BDOT header).");
|
|
31
|
+
}
|
|
32
|
+
const minSize = HEADER_SIZE + SALT_SIZE + IV_SIZE + TAG_SIZE;
|
|
33
|
+
if (data.length < minSize) {
|
|
34
|
+
throw new Error("Encrypted file is too short or corrupted.");
|
|
35
|
+
}
|
|
36
|
+
let offset = HEADER_SIZE;
|
|
37
|
+
const salt = data.subarray(offset, offset + SALT_SIZE);
|
|
38
|
+
offset += SALT_SIZE;
|
|
39
|
+
const iv = data.subarray(offset, offset + IV_SIZE);
|
|
40
|
+
offset += IV_SIZE;
|
|
41
|
+
const tag = data.subarray(offset, offset + TAG_SIZE);
|
|
42
|
+
offset += TAG_SIZE;
|
|
43
|
+
const ciphertext = data.subarray(offset);
|
|
44
|
+
const key = deriveKey(password, salt);
|
|
45
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
46
|
+
decipher.setAuthTag(tag);
|
|
47
|
+
try {
|
|
48
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error("Decryption failed — wrong password or corrupted file.");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function isEncrypted(data) {
|
|
55
|
+
return (data.length >= HEADER_SIZE &&
|
|
56
|
+
data[0] === MAGIC[0] &&
|
|
57
|
+
data[1] === MAGIC[1] &&
|
|
58
|
+
data[2] === MAGIC[2] &&
|
|
59
|
+
data[3] === MAGIC[3] &&
|
|
60
|
+
data[4] === VERSION);
|
|
61
|
+
}
|
|
62
|
+
export function checkKeyFilePermissions() {
|
|
63
|
+
if (process.platform === "win32") {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!fs.existsSync(KEY_FILE_PATH)) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const stat = fs.statSync(KEY_FILE_PATH);
|
|
70
|
+
const mode = stat.mode & 0o777;
|
|
71
|
+
const isAccessibleByGroupOrOthers = (mode & 0o077) !== 0;
|
|
72
|
+
if (isAccessibleByGroupOrOthers) {
|
|
73
|
+
throw new Error(`Key file ${KEY_FILE_PATH} has overly permissive permissions (${mode.toString(8)}).\n` +
|
|
74
|
+
` Run: chmod 600 ${KEY_FILE_PATH}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function saveKeyFile(password) {
|
|
78
|
+
fs.writeFileSync(KEY_FILE_PATH, password + "\n", { mode: 0o600 });
|
|
79
|
+
}
|
|
80
|
+
function readKeyFile() {
|
|
81
|
+
if (!fs.existsSync(KEY_FILE_PATH)) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
checkKeyFilePermissions();
|
|
85
|
+
return fs.readFileSync(KEY_FILE_PATH, "utf-8").trimEnd();
|
|
86
|
+
}
|
|
87
|
+
export async function resolvePassword() {
|
|
88
|
+
const envPassword = process.env.BACKDOT_PASSWORD;
|
|
89
|
+
if (envPassword) {
|
|
90
|
+
return { password: envPassword, interactive: false };
|
|
91
|
+
}
|
|
92
|
+
const filePassword = readKeyFile();
|
|
93
|
+
if (filePassword) {
|
|
94
|
+
return { password: filePassword, interactive: false };
|
|
95
|
+
}
|
|
96
|
+
if (!process.stdin.isTTY) {
|
|
97
|
+
throw new Error('Encryption is enabled but no password found.\n Run "backdot backup" interactively to create ~/.backdot.key, or set BACKDOT_PASSWORD.');
|
|
98
|
+
}
|
|
99
|
+
const enteredPassword = await passwordPrompt({ message: "Enter encryption password:" });
|
|
100
|
+
if (!enteredPassword) {
|
|
101
|
+
throw new Error("No password provided.");
|
|
102
|
+
}
|
|
103
|
+
return { password: enteredPassword, interactive: true };
|
|
104
|
+
}
|
|
105
|
+
export async function confirmPassword(password) {
|
|
106
|
+
const confirmedPassword = await passwordPrompt({ message: "Confirm password:" });
|
|
107
|
+
if (confirmedPassword !== password) {
|
|
108
|
+
throw new Error("Passwords do not match.");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export async function offerToSaveKeyFile(password) {
|
|
112
|
+
if (!process.stdin.isTTY) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (fs.existsSync(KEY_FILE_PATH)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (process.env.BACKDOT_PASSWORD) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const save = await confirm({
|
|
122
|
+
message: "Save password to ~/.backdot.key for automated backups?",
|
|
123
|
+
default: true,
|
|
124
|
+
});
|
|
125
|
+
if (save) {
|
|
126
|
+
saveKeyFile(password);
|
|
127
|
+
console.log(` Created ${KEY_FILE_PATH} (permissions: 600)`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
const ALGORITHM = "aes-256-gcm";
|
|
3
|
+
const SALT_LENGTH = 32;
|
|
4
|
+
const IV_LENGTH = 12;
|
|
5
|
+
const AUTH_TAG_LENGTH = 16;
|
|
6
|
+
const KEY_LENGTH = 32;
|
|
7
|
+
const SCRYPT_PARAMS = { N: 2 ** 14, r: 8, p: 1 };
|
|
8
|
+
const OVERHEAD = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;
|
|
9
|
+
function deriveKey(password, salt) {
|
|
10
|
+
return crypto.scryptSync(password, salt, KEY_LENGTH, SCRYPT_PARAMS);
|
|
11
|
+
}
|
|
12
|
+
export function encrypt(plaintext, password) {
|
|
13
|
+
const salt = crypto.randomBytes(SALT_LENGTH);
|
|
14
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
15
|
+
const key = deriveKey(password, salt);
|
|
16
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
17
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
18
|
+
const authTag = cipher.getAuthTag();
|
|
19
|
+
return Buffer.concat([salt, iv, authTag, ciphertext]);
|
|
20
|
+
}
|
|
21
|
+
export function decrypt(encrypted, password) {
|
|
22
|
+
if (encrypted.length < OVERHEAD) {
|
|
23
|
+
throw new Error("Data is too short to be encrypted content.");
|
|
24
|
+
}
|
|
25
|
+
let offset = 0;
|
|
26
|
+
const salt = encrypted.subarray(offset, (offset += SALT_LENGTH));
|
|
27
|
+
const iv = encrypted.subarray(offset, (offset += IV_LENGTH));
|
|
28
|
+
const authTag = encrypted.subarray(offset, (offset += AUTH_TAG_LENGTH));
|
|
29
|
+
const ciphertext = encrypted.subarray(offset);
|
|
30
|
+
const key = deriveKey(password, salt);
|
|
31
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
32
|
+
decipher.setAuthTag(authTag);
|
|
33
|
+
try {
|
|
34
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error("Decryption failed — wrong password or corrupted data.");
|
|
38
|
+
}
|
|
39
|
+
}
|
package/dist/git.d.ts
CHANGED
|
@@ -8,10 +8,10 @@ interface FileChangeSummary {
|
|
|
8
8
|
to: string;
|
|
9
9
|
}>;
|
|
10
10
|
}
|
|
11
|
-
export declare function buildCommitMessage(changes: FileChangeSummary,
|
|
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
|
@@ -6,44 +6,46 @@ import { logger } from "./log.js";
|
|
|
6
6
|
import { STAGING_DIR, STAGING_GIT_DIR } from "./paths.js";
|
|
7
7
|
import { getCommitUrl } from "./commitUrl.js";
|
|
8
8
|
import { errorMessage, pluralize, uniq } from "./utils.js";
|
|
9
|
-
export function buildCommitMessage(changes,
|
|
10
|
-
const
|
|
11
|
-
const removed =
|
|
12
|
-
const added =
|
|
13
|
-
const modified =
|
|
9
|
+
export function buildCommitMessage(changes, maxLength = 250) {
|
|
10
|
+
const uniqueBasenames = (paths) => uniq(paths.map((filePath) => path.basename(filePath)));
|
|
11
|
+
const removed = uniqueBasenames(changes.deleted);
|
|
12
|
+
const added = uniqueBasenames(changes.created);
|
|
13
|
+
const modified = uniqueBasenames([...changes.modified, ...changes.renamed.map((r) => r.to)]);
|
|
14
14
|
const categories = [
|
|
15
15
|
{ label: "removed", files: removed },
|
|
16
16
|
{ label: "added", files: added },
|
|
17
17
|
{ label: "modified", files: modified },
|
|
18
|
-
].filter((
|
|
18
|
+
].filter((category) => category.files.length > 0);
|
|
19
19
|
if (categories.length === 0) {
|
|
20
|
-
return "backup";
|
|
20
|
+
return "backup";
|
|
21
21
|
}
|
|
22
|
-
function
|
|
22
|
+
function formatCategory(category, listFiles) {
|
|
23
23
|
if (listFiles) {
|
|
24
|
-
return `${
|
|
24
|
+
return `${category.label}: ${category.files.join(", ")}`;
|
|
25
25
|
}
|
|
26
|
-
return `${
|
|
26
|
+
return `${category.label}: ${pluralize(category.files.length, "file")}`;
|
|
27
27
|
}
|
|
28
|
-
function
|
|
29
|
-
return categories.map((
|
|
28
|
+
function joinCategories(showFileNames) {
|
|
29
|
+
return categories.map((category, i) => formatCategory(category, showFileNames[i])).join("; ");
|
|
30
30
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
// If the message exceeds maxLength, progressively replace file lists with
|
|
32
|
+
// counts, starting with the least important category.
|
|
33
|
+
const showFileNames = categories.map(() => true);
|
|
34
|
+
let message = joinCategories(showFileNames);
|
|
35
|
+
if (message.length <= maxLength) {
|
|
36
|
+
return message;
|
|
35
37
|
}
|
|
36
38
|
for (const label of ["modified", "added", "removed"]) {
|
|
37
|
-
const idx = categories.findIndex((
|
|
38
|
-
if (idx !== -1 &&
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
return
|
|
39
|
+
const idx = categories.findIndex((category) => category.label === label);
|
|
40
|
+
if (idx !== -1 && showFileNames[idx]) {
|
|
41
|
+
showFileNames[idx] = false;
|
|
42
|
+
message = joinCategories(showFileNames);
|
|
43
|
+
if (message.length <= maxLength) {
|
|
44
|
+
return message;
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
|
-
return
|
|
48
|
+
return message.slice(0, maxLength - 3) + "...";
|
|
47
49
|
}
|
|
48
50
|
export async function ensureRemoteUrl(repository) {
|
|
49
51
|
const git = simpleGit(STAGING_DIR);
|
|
@@ -55,22 +57,23 @@ export async function ensureRemoteUrl(repository) {
|
|
|
55
57
|
export async function getCurrentBranch(git) {
|
|
56
58
|
return (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
57
59
|
}
|
|
58
|
-
export function friendlyGitError(
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
export function friendlyGitError(rawErrorMessage, repository) {
|
|
61
|
+
const normalizedMessage = rawErrorMessage.toLowerCase();
|
|
62
|
+
if (normalizedMessage.includes("not found") ||
|
|
63
|
+
normalizedMessage.includes("does not exist") ||
|
|
64
|
+
normalizedMessage.includes("does not appear to be a git repository")) {
|
|
63
65
|
return `Repository "${repository}" not found. Check the URL and that you have access.`;
|
|
64
66
|
}
|
|
65
|
-
if (
|
|
67
|
+
if (normalizedMessage.includes("authentication failed") ||
|
|
68
|
+
normalizedMessage.includes("could not read username")) {
|
|
66
69
|
return `Authentication failed for "${repository}". Check your credentials or SSH key.`;
|
|
67
70
|
}
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
if (normalizedMessage.includes("could not resolve host") ||
|
|
72
|
+
normalizedMessage.includes("connection refused") ||
|
|
73
|
+
normalizedMessage.includes("connection timed out")) {
|
|
71
74
|
return "Could not connect to remote host. Check your internet connection.";
|
|
72
75
|
}
|
|
73
|
-
return
|
|
76
|
+
return rawErrorMessage;
|
|
74
77
|
}
|
|
75
78
|
export function gitError(err, repository) {
|
|
76
79
|
const raw = errorMessage(err);
|
|
@@ -82,8 +85,8 @@ export async function gitPull(repository, commit) {
|
|
|
82
85
|
const git = simpleGit(STAGING_DIR);
|
|
83
86
|
await ensureRemoteUrl(repository);
|
|
84
87
|
await git.fetch("origin");
|
|
85
|
-
const
|
|
86
|
-
await git.reset(["--hard",
|
|
88
|
+
const resetTarget = commit ?? `origin/${await getCurrentBranch(git)}`;
|
|
89
|
+
await git.reset(["--hard", resetTarget]);
|
|
87
90
|
await git.clean(CleanOptions.FORCE, ["-d"]);
|
|
88
91
|
}
|
|
89
92
|
else {
|
|
@@ -113,8 +116,8 @@ export async function gitPull(repository, commit) {
|
|
|
113
116
|
}
|
|
114
117
|
export async function gitLog(limit = 20) {
|
|
115
118
|
const git = simpleGit(STAGING_DIR);
|
|
116
|
-
const
|
|
117
|
-
return
|
|
119
|
+
const logResult = await git.log({ maxCount: limit });
|
|
120
|
+
return logResult.all.map((entry) => ({
|
|
118
121
|
hash: entry.hash,
|
|
119
122
|
date: entry.date,
|
|
120
123
|
message: entry.message,
|
|
@@ -130,6 +133,7 @@ export async function gitCommitAndPush() {
|
|
|
130
133
|
}
|
|
131
134
|
const message = buildCommitMessage(status);
|
|
132
135
|
await git.commit(message);
|
|
136
|
+
const remoteUrl = ((await git.remote(["get-url", "origin"])) ?? "").trim();
|
|
133
137
|
try {
|
|
134
138
|
await pRetry(async () => git.push(["-u", "origin", "HEAD"]), {
|
|
135
139
|
retries: 5,
|
|
@@ -148,12 +152,10 @@ export async function gitCommitAndPush() {
|
|
|
148
152
|
});
|
|
149
153
|
}
|
|
150
154
|
catch (err) {
|
|
151
|
-
const remoteUrl = ((await git.remote(["get-url", "origin"])) ?? "").trim();
|
|
152
155
|
throw gitError(err, remoteUrl);
|
|
153
156
|
}
|
|
154
157
|
logger.info(`Committed and pushed: ${message}`);
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
const commitUrl = getCommitUrl(remoteUrl, sha);
|
|
158
|
+
const commitHash = (await git.revparse(["HEAD"])).trim();
|
|
159
|
+
const commitUrl = getCommitUrl(remoteUrl, commitHash);
|
|
158
160
|
return { commitUrl };
|
|
159
161
|
}
|
package/dist/launchd.js
CHANGED
|
@@ -5,15 +5,15 @@ 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, ">")
|
|
13
13
|
.replace(/"/g, """);
|
|
14
14
|
}
|
|
15
|
-
const
|
|
16
|
-
const PLIST_PATH = path.join(os.homedir(), "Library", "LaunchAgents", `${
|
|
15
|
+
const LAUNCHD_JOB_LABEL = "com.backdot.daemon";
|
|
16
|
+
const PLIST_PATH = path.join(os.homedir(), "Library", "LaunchAgents", `${LAUNCHD_JOB_LABEL}.plist`);
|
|
17
17
|
function getScriptPath() {
|
|
18
18
|
const currentDir = path.dirname(new URL(import.meta.url).pathname);
|
|
19
19
|
return path.resolve(currentDir, "cli.js");
|
|
@@ -28,7 +28,7 @@ function buildPlist() {
|
|
|
28
28
|
<plist version="1.0">
|
|
29
29
|
<dict>
|
|
30
30
|
<key>Label</key>
|
|
31
|
-
<string>${
|
|
31
|
+
<string>${LAUNCHD_JOB_LABEL}</string>
|
|
32
32
|
<key>ProgramArguments</key>
|
|
33
33
|
<array>
|
|
34
34
|
<string>${escapeXml(nodePath)}</string>
|
|
@@ -58,8 +58,11 @@ export function isScheduled() {
|
|
|
58
58
|
return false;
|
|
59
59
|
}
|
|
60
60
|
try {
|
|
61
|
-
const output = execFileSync("launchctl", ["list",
|
|
62
|
-
|
|
61
|
+
const output = execFileSync("launchctl", ["list", LAUNCHD_JOB_LABEL], {
|
|
62
|
+
encoding: "utf-8",
|
|
63
|
+
stdio: "pipe",
|
|
64
|
+
});
|
|
65
|
+
return output.includes(LAUNCHD_JOB_LABEL);
|
|
63
66
|
}
|
|
64
67
|
catch {
|
|
65
68
|
return false;
|
|
@@ -68,9 +71,9 @@ export function isScheduled() {
|
|
|
68
71
|
export function setupLaunchd() {
|
|
69
72
|
const spinner = ora("Installing schedule").start();
|
|
70
73
|
const plistContent = buildPlist();
|
|
71
|
-
const
|
|
72
|
-
if (!fs.existsSync(
|
|
73
|
-
fs.mkdirSync(
|
|
74
|
+
const launchAgentsDir = path.dirname(PLIST_PATH);
|
|
75
|
+
if (!fs.existsSync(launchAgentsDir)) {
|
|
76
|
+
fs.mkdirSync(launchAgentsDir, { recursive: true });
|
|
74
77
|
}
|
|
75
78
|
try {
|
|
76
79
|
execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
|
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) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const KEY_FILE_PATH: string;
|
|
2
|
+
export declare const ENC_SUFFIX = ".encrypted";
|
|
3
|
+
export declare function checkKeyFilePermissions(): void;
|
|
4
|
+
export declare function saveKeyFile(password: string): void;
|
|
5
|
+
export interface PasswordResult {
|
|
6
|
+
password: string;
|
|
7
|
+
interactive: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function resolvePassword(): Promise<PasswordResult>;
|
|
10
|
+
export declare function confirmPassword(password: string): Promise<void>;
|
|
11
|
+
export declare function offerToSaveKeyFile(password: string): Promise<void>;
|
package/dist/password.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { password as passwordPrompt, confirm } from "@inquirer/prompts";
|
|
5
|
+
export const KEY_FILE_PATH = path.join(os.homedir(), ".backdot.key");
|
|
6
|
+
export const ENC_SUFFIX = ".encrypted";
|
|
7
|
+
export function checkKeyFilePermissions() {
|
|
8
|
+
if (process.platform === "win32") {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
if (!fs.existsSync(KEY_FILE_PATH)) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const stat = fs.statSync(KEY_FILE_PATH);
|
|
15
|
+
const mode = stat.mode & 0o777;
|
|
16
|
+
const isAccessibleByGroupOrOthers = (mode & 0o077) !== 0;
|
|
17
|
+
if (isAccessibleByGroupOrOthers) {
|
|
18
|
+
throw new Error(`Key file ${KEY_FILE_PATH} has overly permissive permissions (${mode.toString(8)}).\n` +
|
|
19
|
+
` Run: chmod 600 ${KEY_FILE_PATH}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function saveKeyFile(password) {
|
|
23
|
+
fs.writeFileSync(KEY_FILE_PATH, password + "\n", { mode: 0o600 });
|
|
24
|
+
}
|
|
25
|
+
function readKeyFile() {
|
|
26
|
+
if (!fs.existsSync(KEY_FILE_PATH)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
checkKeyFilePermissions();
|
|
30
|
+
return fs.readFileSync(KEY_FILE_PATH, "utf-8").trimEnd();
|
|
31
|
+
}
|
|
32
|
+
export async function resolvePassword() {
|
|
33
|
+
const envPassword = process.env.BACKDOT_PASSWORD;
|
|
34
|
+
if (envPassword) {
|
|
35
|
+
return { password: envPassword, interactive: false };
|
|
36
|
+
}
|
|
37
|
+
const filePassword = readKeyFile();
|
|
38
|
+
if (filePassword) {
|
|
39
|
+
return { password: filePassword, interactive: false };
|
|
40
|
+
}
|
|
41
|
+
if (!process.stdin.isTTY) {
|
|
42
|
+
throw new Error('Encryption is enabled but no password found.\n Run "backdot backup" interactively to create ~/.backdot.key, or set BACKDOT_PASSWORD.');
|
|
43
|
+
}
|
|
44
|
+
const enteredPassword = await passwordPrompt({ message: "Enter encryption password:" });
|
|
45
|
+
if (!enteredPassword) {
|
|
46
|
+
throw new Error("No password provided.");
|
|
47
|
+
}
|
|
48
|
+
return { password: enteredPassword, interactive: true };
|
|
49
|
+
}
|
|
50
|
+
export async function confirmPassword(password) {
|
|
51
|
+
const confirmedPassword = await passwordPrompt({ message: "Confirm password:" });
|
|
52
|
+
if (confirmedPassword !== password) {
|
|
53
|
+
throw new Error("Passwords do not match.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function offerToSaveKeyFile(password) {
|
|
57
|
+
if (!process.stdin.isTTY) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (fs.existsSync(KEY_FILE_PATH)) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (process.env.BACKDOT_PASSWORD) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const save = await confirm({
|
|
67
|
+
message: "Save password to ~/.backdot.key for automated backups?",
|
|
68
|
+
default: true,
|
|
69
|
+
});
|
|
70
|
+
if (save) {
|
|
71
|
+
saveKeyFile(password);
|
|
72
|
+
console.log(` Created ${KEY_FILE_PATH} (permissions: 600)`);
|
|
73
|
+
}
|
|
74
|
+
}
|