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 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 pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
20
- return pkg.version ?? "unknown";
19
+ const packageJson = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
20
+ return packageJson.version ?? "unknown";
21
21
  }
22
22
  catch {
23
23
  return "unknown";
@@ -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
- passwordWasInteractive = result.interactive;
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
- spinner.fail("Backup failed");
74
- throw new Error("Password does not match the existing backup.");
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);
@@ -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((m) => ({ name: m, value: m })),
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 baseDir = machineDir(machine);
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(baseDir)) {
78
+ if (!fs.existsSync(machineStagingDir)) {
79
79
  spinner.stop();
80
- const available = listMachines();
81
- if (available.length > 0) {
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: ${available.join(", ")}\n`);
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 stagedFiles = listFilesRecursively(baseDir);
91
- logger.info(`Found ${pluralize(stagedFiles.length, "file")} in backup repository`);
92
- if (stagedFiles.length === 0) {
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 = stagedFiles.map((stagedFilePath) => {
98
- let relativePath = path.relative(baseDir, stagedFilePath);
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: stagedFilePath, dest: path.join(HOME, relativePath), rel: relativePath };
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.rel, value: file, checked: true });
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.rel, value: file, checked: false });
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 ~/.backdot.key,\n` +
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
- for (const [host, buildUrl] of Object.entries(PROVIDERS)) {
8
- const idx = remoteUrl.indexOf(host);
9
- if (idx === -1) {
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
- return null;
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 parsed;
35
+ let parsedJson;
36
36
  try {
37
- parsed = JSON.parse(rawJson);
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(parsed);
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
- password: string;
3
+ passwordHash: string;
3
4
  salt: Buffer;
4
5
  key: Buffer;
5
6
  }
6
- export declare function deriveKey(password: string, salt?: Buffer): DerivedKey;
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(encrypted: Buffer, derivedKey: DerivedKey): Buffer;
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(password, salt) {
18
+ export function deriveKey(passwordHash, salt) {
19
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 };
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.password, salt, KEY_LENGTH, SCRYPT_PARAMS);
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(encrypted, derivedKey) {
37
- if (encrypted.length < OVERHEAD) {
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 = 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);
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
- interactive: boolean;
8
+ source: PasswordSource;
8
9
  }
9
10
  export declare function resolvePassword(): Promise<PasswordResult>;
10
- export declare function confirmPassword(password: string): Promise<void>;
11
+ export declare function confirmPassword(hashedPassword: string): Promise<void>;
11
12
  export declare function offerToSaveKeyFile(password: string): Promise<void>;
@@ -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, interactive: false };
39
+ return { password: hashPassword(envPassword), source: "env" };
36
40
  }
37
41
  const filePassword = readKeyFile();
38
42
  if (filePassword) {
39
- return { password: filePassword, interactive: false };
43
+ return { password: filePassword, source: "file" };
40
44
  }
41
45
  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.');
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, interactive: true };
52
+ return { password: hashPassword(enteredPassword), source: "prompt" };
49
53
  }
50
- export async function confirmPassword(password) {
54
+ export async function confirmPassword(hashedPassword) {
51
55
  const confirmedPassword = await passwordPrompt({ message: "Confirm password:" });
52
- if (confirmedPassword !== password) {
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: "Save password to ~/.backdot.key for automated backups?",
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(raw: string, repository: string): string;
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(raw, repository) {
61
- const normalizedMessage = raw.toLowerCase();
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 raw;
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 log = await git.log({ maxCount: limit });
120
- return log.all.map((entry) => ({
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(s) {
9
- return s
8
+ function escapeXml(text) {
9
+ return text
10
10
  .replace(/&/g, "&amp;")
11
11
  .replace(/</g, "&lt;")
12
12
  .replace(/>/g, "&gt;")
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 escaped = escapeAppleScript(message);
12
- const titleEscaped = escapeAppleScript(title);
11
+ const escapedMessage = escapeAppleScript(message);
12
+ const escapedTitle = escapeAppleScript(title);
13
13
  try {
14
14
  execFileSync("osascript", [
15
15
  "-e",
16
- `display notification "${escaped}" with title "${titleEscaped}" subtitle "Scheduled backup failed"`,
16
+ `display notification "${escapedMessage}" with title "${escapedTitle}" subtitle "Scheduled backup failed"`,
17
17
  ], { stdio: "pipe" });
18
18
  }
19
19
  catch (err) {
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- const KNOWN_HOSTS = ["github.com", "gitlab.com", "bitbucket.org"];
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
- 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}`;
12
+ const parsed = extractRepoPath(repository);
13
+ if (!parsed) {
14
+ return null;
22
15
  }
23
- return null;
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 compareFilesEncrypted(files, machine, remoteBlobHashes, derivedKey);
96
+ return compareFilesToRemote(files, machine, remoteBlobHashes, ENC_SUFFIX, (file, remoteBlobHash) => {
97
+ try {
98
+ const blobContent = execFileSync("git", ["cat-file", "blob", remoteBlobHash], {
99
+ cwd: STAGING_DIR,
100
+ maxBuffer: 50 * 1024 * 1024,
101
+ });
102
+ const decrypted = decrypt(blobContent, derivedKey);
103
+ const localContent = fs.readFileSync(file);
104
+ return decrypted.equals(localContent);
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ });
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
- return files.reduce((result, file, i) => {
110
- const repoRelPath = path.relative(STAGING_DIR, getStagedPath(file, machine));
111
- const remoteBlobHash = remoteBlobHashes.get(repoRelPath);
112
- if (!remoteBlobHash) {
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 compareFilesEncrypted(files, machine, remoteBlobHashes, derivedKey) {
127
+ function compareFilesToRemote(files, machine, remoteBlobHashes, pathSuffix, matchesRemote) {
125
128
  const result = { backedUp: [], modified: [], notBackedUp: [] };
126
129
  for (const file of files) {
127
- const repoRelPath = path.relative(STAGING_DIR, getStagedPath(file, machine)) + ENC_SUFFIX;
128
- const remoteBlobHash = remoteBlobHashes.get(repoRelPath);
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
- try {
134
- const blobContent = execFileSync("git", ["cat-file", "blob", remoteBlobHash], {
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
- catch {
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.0",
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
- "release": "./scripts/release.sh",
30
- "start": "node dist/cli.js",
29
+ "fmt": "prettier --write src/",
30
+ "fmt:check": "prettier --check src/",
31
31
  "lint": "eslint src/",
32
32
  "lint:fix": "eslint src/ --fix",
33
- "format": "prettier --write src/",
34
- "format:check": "prettier --check src/",
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:watch": "vitest",
40
- "prepare": "husky"
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",