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.
@@ -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;
package/dist/staging.js CHANGED
@@ -7,47 +7,60 @@ 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
  }
49
62
  if (!fs.existsSync(STAGING_GIT_DIR)) {
50
- return failedComparisonResult(new Error("Backup repository not found. Run backdot --backup first."));
63
+ return failedComparisonResult(new Error('Backup repository not found. Run "backdot backup" first.'));
51
64
  }
52
65
  const git = simpleGit(STAGING_DIR);
53
66
  try {
@@ -64,62 +77,97 @@ 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
116
- npx backdot --restore ${repository}
164
+ npx backdot restore ${repository}
117
165
  \`\`\`
118
166
 
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.6.1",
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",
@@ -41,6 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@inquirer/prompts": "^8.3.0",
44
+ "cac": "^7.0.0",
44
45
  "chalk": "^5.6.2",
45
46
  "fast-glob": "^3.3.3",
46
47
  "ora": "^9.3.0",
@@ -52,20 +53,12 @@
52
53
  "engines": {
53
54
  "node": ">=18"
54
55
  },
55
- "lint-staged": {
56
- "*.{ts,js}": [
57
- "prettier --write",
58
- "eslint --fix"
59
- ],
60
- "*.{json,md,yml}": "prettier --write"
61
- },
62
56
  "devDependencies": {
63
57
  "@eslint/js": "^10.0.1",
64
58
  "@types/node": "^22.13.5",
65
59
  "eslint": "^10.0.2",
66
60
  "eslint-config-prettier": "^10.1.8",
67
61
  "husky": "^9.1.7",
68
- "lint-staged": "^16.2.7",
69
62
  "prettier": "^3.8.1",
70
63
  "typescript": "^5.7.3",
71
64
  "typescript-eslint": "^8.56.1",