backdot 1.7.0 → 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.
@@ -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
+ }
@@ -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,2 @@
1
+ export declare function encrypt(plaintext: Buffer, password: string): Buffer;
2
+ export declare function decrypt(encrypted: Buffer, password: string): Buffer;
@@ -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, maxLen?: number): string;
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, maxLen = 250) {
10
- const unique = (paths) => uniq(paths.map((f) => path.basename(f)));
11
- const removed = unique(changes.deleted);
12
- const added = unique(changes.created);
13
- const modified = unique([...changes.modified, ...changes.renamed.map((r) => r.to)]);
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((c) => c.files.length > 0);
18
+ ].filter((category) => category.files.length > 0);
19
19
  if (categories.length === 0) {
20
- return "backup"; // fallback commit message when no changes are categorized
20
+ return "backup";
21
21
  }
22
- function format(cat, listFiles) {
22
+ function formatCategory(category, listFiles) {
23
23
  if (listFiles) {
24
- return `${cat.label}: ${cat.files.join(", ")}`;
24
+ return `${category.label}: ${category.files.join(", ")}`;
25
25
  }
26
- return `${cat.label}: ${pluralize(cat.files.length, "file")}`;
26
+ return `${category.label}: ${pluralize(category.files.length, "file")}`;
27
27
  }
28
- function build(listFlags) {
29
- return categories.map((cat, i) => format(cat, listFlags[i])).join("; ");
28
+ function joinCategories(showFileNames) {
29
+ return categories.map((category, i) => formatCategory(category, showFileNames[i])).join("; ");
30
30
  }
31
- const flags = categories.map(() => true);
32
- let msg = build(flags);
33
- if (msg.length <= maxLen) {
34
- return msg;
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((c) => c.label === label);
38
- if (idx !== -1 && flags[idx]) {
39
- flags[idx] = false;
40
- msg = build(flags);
41
- if (msg.length <= maxLen) {
42
- return msg;
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 msg.slice(0, maxLen - 3) + "...";
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 msg = raw.toLowerCase();
60
- if (msg.includes("not found") ||
61
- msg.includes("does not exist") ||
62
- msg.includes("does not appear to be a git repository")) {
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 (msg.includes("authentication failed") || msg.includes("could not read username")) {
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 (msg.includes("could not resolve host") ||
69
- msg.includes("connection refused") ||
70
- msg.includes("connection timed out")) {
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 target = commit ?? `origin/${await getCurrentBranch(git)}`;
86
- await git.reset(["--hard", target]);
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 sha = (await git.revparse(["HEAD"])).trim();
158
+ const commitHash = (await git.revparse(["HEAD"])).trim();
156
159
  const remoteUrl = (await git.remote(["get-url", "origin"])) ?? "";
157
- const commitUrl = getCommitUrl(remoteUrl, sha);
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, "&gt;")
13
13
  .replace(/"/g, "&quot;");
14
14
  }
15
- const LABEL = "com.backdot.daemon";
16
- const PLIST_PATH = path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
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>${LABEL}</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", LABEL], { encoding: "utf-8", stdio: "pipe" });
62
- return output.includes(LABEL);
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 dir = path.dirname(PLIST_PATH);
72
- if (!fs.existsSync(dir)) {
73
- fs.mkdirSync(dir, { recursive: true });
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>;
@@ -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
+ }
@@ -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,58 @@
1
+ import { execFile } from "node:child_process";
2
+ const KNOWN_HOSTS = ["github.com", "gitlab.com", "bitbucket.org"];
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
+ for (const host of KNOWN_HOSTS) {
13
+ const idx = repository.indexOf(host);
14
+ if (idx === -1) {
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}`;
22
+ }
23
+ return null;
24
+ }
25
+ /**
26
+ * Checks whether `repository` is publicly readable by attempting an
27
+ * anonymous `git ls-remote` over HTTPS with all credential helpers disabled.
28
+ */
29
+ export async function checkRepoVisibility(repository) {
30
+ const httpsUrl = toHttpsUrl(repository);
31
+ if (!httpsUrl) {
32
+ return "unknown";
33
+ }
34
+ try {
35
+ await execGitLsRemote(httpsUrl);
36
+ return "public";
37
+ }
38
+ catch {
39
+ return "private";
40
+ }
41
+ }
42
+ function execGitLsRemote(url) {
43
+ return new Promise((resolve, reject) => {
44
+ const child = execFile("git", ["-c", "credential.helper=", "ls-remote", "--quiet", url], {
45
+ timeout: 10_000,
46
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
47
+ }, (error) => {
48
+ if (error) {
49
+ reject(error);
50
+ }
51
+ else {
52
+ resolve();
53
+ }
54
+ });
55
+ // Don't let stdin keep the process alive
56
+ child.stdin?.end();
57
+ });
58
+ }
@@ -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 unique = uniq(resolveGlobs(config.paths));
24
- return unique.filter((filePath) => {
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(files: string[], machine: string, repository: string): Promise<ComparisonResult>;
13
- export declare function writeRepoReadme(repository: string): void;
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;