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.
package/dist/staging.js CHANGED
@@ -7,42 +7,55 @@ import { logger } from "./log.js";
7
7
  import { errorMessage, pluralize } from "./utils.js";
8
8
  import { ensureRemoteUrl, getCurrentBranch, gitError } from "./git.js";
9
9
  import { STAGING_DIR, STAGING_GIT_DIR, machineDir } from "./paths.js";
10
+ import { encrypt, decrypt } from "./crypto/encryption.js";
11
+ import { KEY_FILE_PATH, ENC_SUFFIX } from "./crypto/password.js";
10
12
  export { STAGING_DIR, STAGING_GIT_DIR, machineDir };
11
13
  const HOME = os.homedir();
12
14
  export function getStagedPath(filePath, machine) {
13
- const rel = path.relative(HOME, filePath);
14
- const destRel = rel.startsWith("..") ? filePath.slice(1) : rel;
15
- return path.join(machineDir(machine), destRel);
15
+ const relativePath = path.relative(HOME, filePath);
16
+ // Files outside HOME (e.g. /etc/foo) produce a relative path starting with "..",
17
+ // which would escape the machine dir. Use the absolute path minus the leading "/" instead.
18
+ const pathWithinMachineDir = relativePath.startsWith("..") ? filePath.slice(1) : relativePath;
19
+ return path.join(machineDir(machine), pathWithinMachineDir);
16
20
  }
17
21
  export function cleanStaging(machine) {
18
- const dir = machineDir(machine);
19
- if (!fs.existsSync(dir)) {
22
+ const machineStagingDir = machineDir(machine);
23
+ if (!fs.existsSync(machineStagingDir)) {
20
24
  return;
21
25
  }
22
- fs.rmSync(dir, { recursive: true, force: true });
26
+ fs.rmSync(machineStagingDir, { recursive: true, force: true });
23
27
  logger.info(`Cleaned staging directory for machine "${machine}"`);
24
28
  }
25
- export function copyToStaging(files, machine) {
26
- const dir = machineDir(machine);
27
- fs.mkdirSync(dir, { recursive: true });
28
- let copied = 0;
29
- for (const filePath of files) {
30
- const dest = getStagedPath(filePath, machine);
29
+ export function copyToStaging(files, machine, derivedKey) {
30
+ const machineStagingDir = machineDir(machine);
31
+ fs.mkdirSync(machineStagingDir, { recursive: true });
32
+ const filesExcludingKeyFile = files.filter((f) => path.resolve(f) !== path.resolve(KEY_FILE_PATH));
33
+ let copiedCount = 0;
34
+ for (const filePath of filesExcludingKeyFile) {
35
+ const stagedPath = getStagedPath(filePath, machine);
36
+ const destinationPath = derivedKey ? stagedPath + ENC_SUFFIX : stagedPath;
31
37
  try {
32
- fs.mkdirSync(path.dirname(dest), { recursive: true });
33
- fs.copyFileSync(filePath, dest);
34
- copied++;
38
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
39
+ if (derivedKey) {
40
+ const plaintext = fs.readFileSync(filePath);
41
+ fs.writeFileSync(destinationPath, encrypt(plaintext, derivedKey));
42
+ }
43
+ else {
44
+ fs.copyFileSync(filePath, destinationPath);
45
+ }
46
+ copiedCount++;
35
47
  }
36
48
  catch {
37
- logger.warn(`Failed to copy: ${filePath} -> ${dest}`);
49
+ logger.warn(`Failed to copy: ${filePath} -> ${destinationPath}`);
38
50
  }
39
51
  }
40
- logger.info(`Copied ${pluralize(copied, "file")} to staging`);
52
+ logger.info(`Copied ${pluralize(copiedCount, "file")} to staging`);
41
53
  }
42
54
  function failedComparisonResult(err) {
43
55
  return { backedUp: [], modified: [], notBackedUp: [], error: errorMessage(err) };
44
56
  }
45
- export async function compareFiles(files, machine, repository) {
57
+ export async function compareFiles(opts) {
58
+ const { files, machine, repository, derivedKey } = opts;
46
59
  if (files.length === 0) {
47
60
  return { backedUp: [], modified: [], notBackedUp: [] };
48
61
  }
@@ -64,52 +77,87 @@ export async function compareFiles(files, machine, repository) {
64
77
  catch (err) {
65
78
  return failedComparisonResult(err);
66
79
  }
67
- let committedHashes;
80
+ let remoteBlobHashes;
68
81
  try {
69
82
  const treeOutput = execFileSync("git", ["ls-tree", "-r", `origin/${branch}`, `${machine}/`], {
70
83
  encoding: "utf-8",
71
84
  cwd: STAGING_DIR,
72
85
  });
73
- committedHashes = new Map(treeOutput
86
+ remoteBlobHashes = new Map(treeOutput
74
87
  .split("\n")
75
88
  .map((line) => line.match(/^\d+ blob ([0-9a-f]+)\t(.+)$/))
76
- .filter((m) => m !== null)
77
- .map((m) => [m[2], m[1]]));
89
+ .filter((match) => match !== null)
90
+ .map((match) => [match[2], match[1]]));
78
91
  }
79
92
  catch (err) {
80
93
  return failedComparisonResult(err);
81
94
  }
82
- let sourceHashes;
95
+ if (derivedKey) {
96
+ return compareFilesEncrypted(files, machine, remoteBlobHashes, derivedKey);
97
+ }
98
+ let localFileHashes;
83
99
  try {
84
100
  const hashOutput = execFileSync("git", ["hash-object", "--stdin-paths"], {
85
101
  encoding: "utf-8",
86
102
  input: files.join("\n") + "\n",
87
103
  });
88
- sourceHashes = hashOutput.trim().split("\n");
104
+ localFileHashes = hashOutput.trim().split("\n");
89
105
  }
90
106
  catch (err) {
91
107
  return failedComparisonResult(err);
92
108
  }
93
- return files.reduce((acc, file, i) => {
109
+ return files.reduce((result, file, i) => {
94
110
  const repoRelPath = path.relative(STAGING_DIR, getStagedPath(file, machine));
95
- const committedHash = committedHashes.get(repoRelPath);
96
- if (!committedHash) {
97
- acc.notBackedUp.push(file);
111
+ const remoteBlobHash = remoteBlobHashes.get(repoRelPath);
112
+ if (!remoteBlobHash) {
113
+ result.notBackedUp.push(file);
98
114
  }
99
- else if (committedHash === sourceHashes[i]) {
100
- acc.backedUp.push(file);
115
+ else if (remoteBlobHash === localFileHashes[i]) {
116
+ result.backedUp.push(file);
101
117
  }
102
118
  else {
103
- acc.modified.push(file);
119
+ result.modified.push(file);
104
120
  }
105
- return acc;
121
+ return result;
106
122
  }, { backedUp: [], modified: [], notBackedUp: [] });
107
123
  }
108
- function repoReadme(repository) {
124
+ function compareFilesEncrypted(files, machine, remoteBlobHashes, derivedKey) {
125
+ const result = { backedUp: [], modified: [], notBackedUp: [] };
126
+ for (const file of files) {
127
+ const repoRelPath = path.relative(STAGING_DIR, getStagedPath(file, machine)) + ENC_SUFFIX;
128
+ const remoteBlobHash = remoteBlobHashes.get(repoRelPath);
129
+ if (!remoteBlobHash) {
130
+ result.notBackedUp.push(file);
131
+ continue;
132
+ }
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
+ }
146
+ }
147
+ catch {
148
+ result.modified.push(file);
149
+ }
150
+ }
151
+ return result;
152
+ }
153
+ function generateReadmeContent(repository, encrypted) {
154
+ const encryptionNote = encrypted
155
+ ? "\n> **Note:** Files in this repository are encrypted. You will need the backup password to restore.\n"
156
+ : "";
109
157
  return `# Backdot Backup
110
158
 
111
159
  This repository contains files backed up automatically using [backdot](https://github.com/sorenlouv/backdot).
112
-
160
+ ${encryptionNote}
113
161
  ## Restore
114
162
 
115
163
  \`\`\`bash
@@ -119,7 +167,7 @@ npx backdot restore ${repository}
119
167
  For full documentation, configuration options, and scheduling, see the [official README](https://github.com/sorenlouv/backdot).
120
168
  `;
121
169
  }
122
- export function writeRepoReadme(repository) {
123
- fs.writeFileSync(path.join(STAGING_DIR, "README.md"), repoReadme(repository));
170
+ export function writeRepoReadme(repository, encrypted = false) {
171
+ fs.writeFileSync(path.join(STAGING_DIR, "README.md"), generateReadmeContent(repository, encrypted));
124
172
  logger.info("Wrote README.md to staging directory");
125
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backdot",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
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",
@@ -53,20 +53,12 @@
53
53
  "engines": {
54
54
  "node": ">=18"
55
55
  },
56
- "lint-staged": {
57
- "*.{ts,js}": [
58
- "prettier --write",
59
- "eslint --fix"
60
- ],
61
- "*.{json,md,yml}": "prettier --write"
62
- },
63
56
  "devDependencies": {
64
57
  "@eslint/js": "^10.0.1",
65
58
  "@types/node": "^22.13.5",
66
59
  "eslint": "^10.0.2",
67
60
  "eslint-config-prettier": "^10.1.8",
68
61
  "husky": "^9.1.7",
69
- "lint-staged": "^16.2.7",
70
62
  "prettier": "^3.8.1",
71
63
  "typescript": "^5.7.3",
72
64
  "typescript-eslint": "^8.56.1",