backdot 1.6.1 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -14
- package/dist/cli.js +36 -80
- package/dist/commands/backup.js +66 -4
- package/dist/commands/history.js +9 -6
- package/dist/commands/init.js +6 -5
- package/dist/commands/restore.d.ts +4 -4
- package/dist/commands/restore.js +54 -33
- package/dist/commands/schedule.js +14 -0
- package/dist/commands/status.js +47 -12
- package/dist/commitUrl.js +5 -4
- package/dist/config.d.ts +2 -1
- package/dist/config.js +15 -13
- package/dist/crypto/encryption.d.ts +8 -0
- package/dist/crypto/encryption.js +54 -0
- package/dist/crypto/password.d.ts +11 -0
- package/dist/crypto/password.js +74 -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 +1 -1
- package/dist/git.js +38 -35
- package/dist/launchd.js +12 -9
- package/dist/password.d.ts +11 -0
- package/dist/password.js +74 -0
- package/dist/repoVisibility.d.ts +15 -0
- package/dist/repoVisibility.js +58 -0
- package/dist/resolveFiles.js +2 -2
- package/dist/staging.d.ts +9 -3
- package/dist/staging.js +86 -38
- package/package.json +2 -9
package/dist/commitUrl.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const PROVIDERS = {
|
|
2
|
-
"github.com": (
|
|
3
|
-
"gitlab.com": (
|
|
4
|
-
"bitbucket.org": (
|
|
2
|
+
"github.com": (repoPath, sha) => `https://github.com/${repoPath}/commit/${sha}`,
|
|
3
|
+
"gitlab.com": (repoPath, sha) => `https://gitlab.com/${repoPath}/-/commit/${sha}`,
|
|
4
|
+
"bitbucket.org": (repoPath, sha) => `https://bitbucket.org/${repoPath}/commits/${sha}`,
|
|
5
5
|
};
|
|
6
6
|
export function getCommitUrl(remoteUrl, sha) {
|
|
7
7
|
for (const [host, buildUrl] of Object.entries(PROVIDERS)) {
|
|
@@ -9,7 +9,8 @@ export function getCommitUrl(remoteUrl, sha) {
|
|
|
9
9
|
if (idx === -1) {
|
|
10
10
|
continue;
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
const separatorLength = 1; // skip the ":" (SSH) or "/" (HTTPS) after the hostname
|
|
13
|
+
let repoPath = remoteUrl.slice(idx + host.length + separatorLength).trim();
|
|
13
14
|
if (repoPath.endsWith(".git")) {
|
|
14
15
|
repoPath = repoPath.slice(0, -4);
|
|
15
16
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export declare const CONFIG_PATH: string;
|
|
3
|
-
export declare function expandTilde(
|
|
3
|
+
export declare function expandTilde(pattern: string): string;
|
|
4
4
|
declare const ConfigSchema: z.ZodObject<{
|
|
5
5
|
repository: z.ZodString;
|
|
6
6
|
machine: z.ZodString;
|
|
7
7
|
paths: z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
|
|
8
|
+
encrypt: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
8
9
|
}, z.core.$strip>;
|
|
9
10
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
10
11
|
export declare function loadConfig(): Config;
|
package/dist/config.js
CHANGED
|
@@ -3,14 +3,15 @@ import path from "node:path";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
export const CONFIG_PATH = path.join(os.homedir(), ".backdot.json");
|
|
6
|
-
export function expandTilde(
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
export function expandTilde(pattern) {
|
|
7
|
+
// fast-glob uses "!" for negation patterns — preserve the prefix, expand the rest
|
|
8
|
+
if (pattern.startsWith("!")) {
|
|
9
|
+
return "!" + expandTilde(pattern.slice(1));
|
|
9
10
|
}
|
|
10
|
-
if (
|
|
11
|
-
return path.join(os.homedir(),
|
|
11
|
+
if (pattern.startsWith("~/") || pattern === "~") {
|
|
12
|
+
return path.join(os.homedir(), pattern.slice(1));
|
|
12
13
|
}
|
|
13
|
-
return
|
|
14
|
+
return pattern;
|
|
14
15
|
}
|
|
15
16
|
const ConfigSchema = z.object({
|
|
16
17
|
repository: z.string().min(1),
|
|
@@ -18,30 +19,31 @@ const ConfigSchema = z.object({
|
|
|
18
19
|
paths: z
|
|
19
20
|
.array(z.string().min(1).transform(expandTilde))
|
|
20
21
|
.min(1, '"paths" must be a non-empty array'),
|
|
22
|
+
encrypt: z.boolean().optional().default(false),
|
|
21
23
|
});
|
|
22
24
|
export function loadConfig() {
|
|
23
25
|
if (!fs.existsSync(CONFIG_PATH)) {
|
|
24
|
-
throw new Error(`Config file not found: ${CONFIG_PATH}\n Run "backdot
|
|
26
|
+
throw new Error(`Config file not found: ${CONFIG_PATH}\n Run "backdot init" to create it.`);
|
|
25
27
|
}
|
|
26
|
-
let
|
|
28
|
+
let rawJson;
|
|
27
29
|
try {
|
|
28
|
-
|
|
30
|
+
rawJson = fs.readFileSync(CONFIG_PATH, "utf-8");
|
|
29
31
|
}
|
|
30
32
|
catch {
|
|
31
33
|
throw new Error(`Failed to read config file: ${CONFIG_PATH}`);
|
|
32
34
|
}
|
|
33
35
|
let parsed;
|
|
34
36
|
try {
|
|
35
|
-
parsed = JSON.parse(
|
|
37
|
+
parsed = JSON.parse(rawJson);
|
|
36
38
|
}
|
|
37
39
|
catch {
|
|
38
40
|
throw new Error(`Invalid JSON in config file: ${CONFIG_PATH}`);
|
|
39
41
|
}
|
|
40
42
|
const result = ConfigSchema.safeParse(parsed);
|
|
41
43
|
if (!result.success) {
|
|
42
|
-
const messages = result.error.issues.map((
|
|
43
|
-
const
|
|
44
|
-
return ` - ${
|
|
44
|
+
const messages = result.error.issues.map((issue) => {
|
|
45
|
+
const field = issue.path.length > 0 ? `"${issue.path.join(".")}"` : "config";
|
|
46
|
+
return ` - ${field}: ${issue.message}`;
|
|
45
47
|
});
|
|
46
48
|
throw new Error(`Invalid config in ${CONFIG_PATH}:\n${messages.join("\n")}`);
|
|
47
49
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface DerivedKey {
|
|
2
|
+
password: string;
|
|
3
|
+
salt: Buffer;
|
|
4
|
+
key: Buffer;
|
|
5
|
+
}
|
|
6
|
+
export declare function deriveKey(password: string, salt?: Buffer): DerivedKey;
|
|
7
|
+
export declare function encrypt(plaintext: Buffer, derivedKey: DerivedKey): Buffer;
|
|
8
|
+
export declare function decrypt(encrypted: Buffer, derivedKey: DerivedKey): Buffer;
|
|
@@ -0,0 +1,54 @@
|
|
|
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(password, salt) {
|
|
19
|
+
const resolvedSalt = salt ?? crypto.randomBytes(SALT_LENGTH);
|
|
20
|
+
const key = crypto.scryptSync(password, resolvedSalt, KEY_LENGTH, SCRYPT_PARAMS);
|
|
21
|
+
return { password, 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.password, 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(encrypted, derivedKey) {
|
|
37
|
+
if (encrypted.length < OVERHEAD) {
|
|
38
|
+
throw new Error("Data is too short to be encrypted content.");
|
|
39
|
+
}
|
|
40
|
+
let offset = 0;
|
|
41
|
+
const salt = encrypted.subarray(offset, (offset += SALT_LENGTH));
|
|
42
|
+
const iv = encrypted.subarray(offset, (offset += IV_LENGTH));
|
|
43
|
+
const authTag = encrypted.subarray(offset, (offset += AUTH_TAG_LENGTH));
|
|
44
|
+
const ciphertext = encrypted.subarray(offset);
|
|
45
|
+
const key = deriveKeyForSalt(derivedKey, salt);
|
|
46
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
47
|
+
decipher.setAuthTag(authTag);
|
|
48
|
+
try {
|
|
49
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new Error("Decryption failed — wrong password or corrupted data.");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -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>;
|
|
@@ -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
|
+
}
|
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,7 +8,7 @@ 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
14
|
export declare function friendlyGitError(raw: string, repository: string): string;
|
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);
|
|
@@ -56,18 +58,19 @@ export async function getCurrentBranch(git) {
|
|
|
56
58
|
return (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
57
59
|
}
|
|
58
60
|
export function friendlyGitError(raw, repository) {
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
const normalizedMessage = raw.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
76
|
return raw;
|
|
@@ -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 {
|
|
@@ -152,8 +155,8 @@ export async function gitCommitAndPush() {
|
|
|
152
155
|
throw gitError(err, remoteUrl);
|
|
153
156
|
}
|
|
154
157
|
logger.info(`Committed and pushed: ${message}`);
|
|
155
|
-
const
|
|
158
|
+
const commitHash = (await git.revparse(["HEAD"])).trim();
|
|
156
159
|
const remoteUrl = (await git.remote(["get-url", "origin"])) ?? "";
|
|
157
|
-
const commitUrl = getCommitUrl(remoteUrl,
|
|
160
|
+
const commitUrl = getCommitUrl(remoteUrl, commitHash);
|
|
158
161
|
return { commitUrl };
|
|
159
162
|
}
|
package/dist/launchd.js
CHANGED
|
@@ -12,8 +12,8 @@ function escapeXml(s) {
|
|
|
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,12 +28,12 @@ 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>
|
|
35
35
|
<string>${escapeXml(scriptPath)}</string>
|
|
36
|
-
<string
|
|
36
|
+
<string>backup</string>
|
|
37
37
|
</array>
|
|
38
38
|
<key>WorkingDirectory</key>
|
|
39
39
|
<string>${escapeXml(workingDir)}</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" });
|
|
@@ -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>;
|