backdot 1.8.3 → 1.8.5

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 ADDED
@@ -0,0 +1,90 @@
1
+ <p align="center">
2
+ <img src="logo-small.png" alt="backdot" width="400" />
3
+ </p>
4
+
5
+ <h1 align="center">backdot</h1>
6
+
7
+ <p align="center">Automated backup of important files (configs, dotfiles) to your own private Git repo.</p>
8
+
9
+ ## Getting started
10
+
11
+ ```bash
12
+ npm install -g backdot
13
+ backdot init
14
+ ```
15
+
16
+ This creates `~/.backdot/config.json` with sensible defaults and walks you through setup. Open the config file and set your repository URL and the files you want backed up:
17
+
18
+ ```json
19
+ {
20
+ "repository": "git@github.com:USERNAME/backdot-backup.git",
21
+ "machine": "my-work-laptop",
22
+ "paths": ["~/.zshrc", "~/.oh-my-zsh/custom/*.zsh", "~/.ssh/config", "~/.npmrc"]
23
+ }
24
+ ```
25
+
26
+ Run your first backup:
27
+
28
+ ```bash
29
+ backdot backup
30
+ ```
31
+
32
+ or configure the backport process to run automatically (daily at 2am)
33
+
34
+ ```bash
35
+ backdot schedule
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ | Key | Description |
41
+ | ------- | ----------------------------------------------------------------------------------------------- |
42
+ | `paths` | Glob patterns matching files. To back up a whole directory, use a trailing `/**` (e.g. `~/.config/nvim/**`) — a bare directory path matches nothing. |
43
+
44
+ Prefix a pattern with `!` to exclude matching files:
45
+
46
+ ```json
47
+ {
48
+ "paths": ["~/.config/ghostty/**", "!~/.config/ghostty/crash-reports/**"]
49
+ }
50
+ ```
51
+
52
+ ## Encryption
53
+
54
+ To encrypt files before they are pushed to the remote repo, add `"encrypt": true` to your config.
55
+
56
+ On first backup you'll be prompted for a password and offered to save it to `~/.backdot/encryption.key` so that future backups do not prompt for a password.
57
+
58
+ For non-interactive backups (the scheduled job, CI, etc.) you can supply the password via the `BACKDOT_PASSWORD` environment variable instead of the key file.
59
+
60
+ ## Post-restore hook
61
+
62
+ Add a `~/.backdot/post-restore` shell script which will be executed after `backdot restore`to install packages, clone repos, etc. It's backed up automatically.
63
+
64
+ ## Commands
65
+
66
+ | Command | Description |
67
+ | ------------------------------ | ---------------------------------------------- |
68
+ | `init` | Set up backdot for the first time |
69
+ | `backup` | Run a backup now |
70
+ | `restore` | Restore latest backup from the configured repo |
71
+ | `restore <url>` | Restore from a specific repo URL |
72
+ | `restore [url] --commit <sha>` | Restore from a specific backup commit |
73
+ | `restore [url] --machine <name>` | Restore a specific machine non-interactively |
74
+ | `restore [url] --yes` (`-y`) | Restore new files non-interactively (skips existing files) |
75
+ | `history [url]` | Browse and restore a previous backup |
76
+ | `schedule` | Schedule automatic daily backup (Mac-only) |
77
+ | `unschedule` | Unschedule the daily backup |
78
+ | `status` | Show schedule and resolved file list |
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ npm install
84
+ npm run build
85
+ npm start
86
+ ```
87
+
88
+ ## License
89
+
90
+ MIT
@@ -54,6 +54,14 @@ export async function backup() {
54
54
  "Backing up to a public repo would expose sensitive files.\n" +
55
55
  "Make the repository private, then try again.");
56
56
  }
57
+ if (visibility === "unverifiable" && !process.env.BACKDOT_ALLOW_UNVERIFIED_VISIBILITY) {
58
+ spinner.fail("Backup refused — could not verify repository visibility");
59
+ throw new Error(`Could not verify whether "${config.repository}" is private — the anonymous ` +
60
+ "visibility check failed (network error, timeout, or blocked HTTPS access).\n" +
61
+ "Backup is refused so files are never pushed to a repository that might be public.\n" +
62
+ "Confirm the repository is private and retry. If HTTPS is blocked in your " +
63
+ "environment, set BACKDOT_ALLOW_UNVERIFIED_VISIBILITY=1 to override.");
64
+ }
57
65
  spinner.text = "Resolving files";
58
66
  const userFiles = resolveFiles(config);
59
67
  logger.info(`Resolved ${pluralize(userFiles.length, "file")}`);
@@ -22,6 +22,10 @@ export async function history(repoUrl) {
22
22
  console.log("\n No backup history found.\n");
23
23
  return;
24
24
  }
25
+ if (!process.stdin.isTTY) {
26
+ throw new Error("Browsing history is interactive.\n" +
27
+ " Use `restore --commit <sha>` to restore a specific backup non-interactively.");
28
+ }
25
29
  const selectedCommitHash = await select({
26
30
  message: "Select a backup to restore from:",
27
31
  loop: false,
@@ -132,6 +132,11 @@ export async function restore({ repoUrl, commit, skipExisting, machine: machineO
132
132
  }
133
133
  }
134
134
  else {
135
+ if (!process.stdin.isTTY) {
136
+ throw new Error("Selecting files to restore is interactive.\n" +
137
+ " Re-run with --yes to restore new files non-interactively (existing files are skipped),\n" +
138
+ " or run in a terminal to choose which files to restore.");
139
+ }
135
140
  const choices = [];
136
141
  if (newFiles.length > 0) {
137
142
  choices.push(new Separator(`── New files (${newFiles.length}) ──`));
@@ -6,7 +6,7 @@ import { loadConfig, CONFIG_PATH } from "../config.js";
6
6
  import { resolveFiles } from "../resolveFiles.js";
7
7
  import { compareFiles } from "../staging.js";
8
8
  import { isScheduled } from "../launchd.js";
9
- import { pluralize } from "../utils.js";
9
+ import { pluralize, errorMessage } from "../utils.js";
10
10
  import { checkRepoVisibility } from "../repoVisibility.js";
11
11
  import { deriveKey } from "../crypto/encryption.js";
12
12
  import { resolvePassword } from "../crypto/password.js";
@@ -20,6 +20,8 @@ function formatVisibility(visibility) {
20
20
  return chalk.red.bold("public (backup disabled)");
21
21
  case "private":
22
22
  return chalk.green("private");
23
+ case "unverifiable":
24
+ return chalk.yellow("could not verify (network error)");
23
25
  case "unknown":
24
26
  return chalk.yellow("unknown (could not verify)");
25
27
  }
@@ -66,17 +68,23 @@ export async function status() {
66
68
  }
67
69
  const files = [...userFiles, CONFIG_PATH];
68
70
  spinner.text = "Comparing with remote backup";
69
- const { backedUp, modified, notBackedUp, remoteIsEmpty, error } = await compareFiles({
70
- files,
71
- machine: config.machine,
72
- repository: config.repository,
73
- resolveKey,
74
- });
75
- spinner.stop();
76
- if (error) {
77
- console.log(chalk.yellow(` Could not fetch status: ${error}`));
71
+ let comparison;
72
+ try {
73
+ comparison = await compareFiles({
74
+ files,
75
+ machine: config.machine,
76
+ repository: config.repository,
77
+ resolveKey,
78
+ });
79
+ }
80
+ catch (err) {
81
+ spinner.stop();
82
+ console.log(chalk.yellow(` Could not fetch status: ${errorMessage(err)}`));
83
+ console.log();
78
84
  return;
79
85
  }
86
+ spinner.stop();
87
+ const { backedUp, modified, notBackedUp, remoteIsEmpty } = comparison;
80
88
  if (remoteIsEmpty) {
81
89
  console.log(` No backup yet — showing what ${chalk.bold("backdot backup")} would back up.`);
82
90
  console.log();
@@ -1,4 +1,4 @@
1
- export type RepoVisibility = "public" | "private" | "unknown";
1
+ export type RepoVisibility = "public" | "private" | "unknown" | "unverifiable";
2
2
  /**
3
3
  * Converts an SSH or HTTPS repo URL to a credential-free HTTPS URL
4
4
  * for known hosts. Returns `null` for unrecognized hosts.
@@ -9,7 +9,21 @@ export type RepoVisibility = "public" | "private" | "unknown";
9
9
  */
10
10
  export declare function toHttpsUrl(repository: string): string | null;
11
11
  /**
12
- * Checks whether `repository` is publicly readable by attempting an
13
- * anonymous `git ls-remote` over HTTPS with all credential helpers disabled.
12
+ * Determines whether `repository` is publicly readable by sending an anonymous
13
+ * (no-credentials) request to git's smart-HTTP advertised-refs endpoint and
14
+ * classifying the HTTP status code — a documented, stable signal:
15
+ *
16
+ * - 200 → refs are served anonymously → "public"
17
+ * - 401/403/404 → host refuses anonymous reads (private, or
18
+ * hidden-as-missing) → "private"
19
+ * - anything else (3xx, 5xx, …) or no response at all (DNS error,
20
+ * timeout, TLS/proxy failure) → "unverifiable"
21
+ *
22
+ * Verified 2026-06: github.com & gitlab.com public → 200, private → 401;
23
+ * bitbucket.org returns 401 even for public repos, so a public Bitbucket repo
24
+ * reads as private here — it is not anonymously readable over git either way.
25
+ * The status code does not depend on the request's User-Agent.
26
+ *
27
+ * Unknown hosts have no HTTPS form to probe and return "unknown".
14
28
  */
15
29
  export declare function checkRepoVisibility(repository: string): Promise<RepoVisibility>;
@@ -1,4 +1,4 @@
1
- import { execFile } from "node:child_process";
1
+ import https from "node:https";
2
2
  import { extractRepoPath } from "./utils.js";
3
3
  /**
4
4
  * Converts an SSH or HTTPS repo URL to a credential-free HTTPS URL
@@ -15,42 +15,58 @@ export function toHttpsUrl(repository) {
15
15
  }
16
16
  return `https://${parsed.host}/${parsed.repoPath}.git`;
17
17
  }
18
+ const PROBE_TIMEOUT_MS = 10_000;
18
19
  /**
19
- * Checks whether `repository` is publicly readable by attempting an
20
- * anonymous `git ls-remote` over HTTPS with all credential helpers disabled.
20
+ * Determines whether `repository` is publicly readable by sending an anonymous
21
+ * (no-credentials) request to git's smart-HTTP advertised-refs endpoint and
22
+ * classifying the HTTP status code — a documented, stable signal:
23
+ *
24
+ * - 200 → refs are served anonymously → "public"
25
+ * - 401/403/404 → host refuses anonymous reads (private, or
26
+ * hidden-as-missing) → "private"
27
+ * - anything else (3xx, 5xx, …) or no response at all (DNS error,
28
+ * timeout, TLS/proxy failure) → "unverifiable"
29
+ *
30
+ * Verified 2026-06: github.com & gitlab.com public → 200, private → 401;
31
+ * bitbucket.org returns 401 even for public repos, so a public Bitbucket repo
32
+ * reads as private here — it is not anonymously readable over git either way.
33
+ * The status code does not depend on the request's User-Agent.
34
+ *
35
+ * Unknown hosts have no HTTPS form to probe and return "unknown".
21
36
  */
22
37
  export async function checkRepoVisibility(repository) {
23
38
  const httpsUrl = toHttpsUrl(repository);
24
39
  if (!httpsUrl) {
25
40
  return "unknown";
26
41
  }
42
+ let status;
27
43
  try {
28
- await execGitLsRemote(httpsUrl);
29
- return "public";
44
+ status = await probeAnonymousRefsStatus(`${httpsUrl}/info/refs?service=git-upload-pack`);
30
45
  }
31
46
  catch {
47
+ // No usable response (DNS failure, timeout, TLS/proxy error): visibility is
48
+ // undetermined — never assume private.
49
+ return "unverifiable";
50
+ }
51
+ if (status === 200) {
52
+ return "public";
53
+ }
54
+ if (status === 401 || status === 403 || status === 404) {
32
55
  return "private";
33
56
  }
57
+ return "unverifiable";
34
58
  }
35
- function execGitLsRemote(url) {
59
+ /**
60
+ * Issues an anonymous GET to a smart-HTTP refs URL and resolves with the HTTP
61
+ * status code. Sends no credentials; rejects on connection failure or timeout.
62
+ */
63
+ function probeAnonymousRefsStatus(refsUrl) {
36
64
  return new Promise((resolve, reject) => {
37
- const child = execFile("git", ["-c", "credential.helper=", "ls-remote", "--quiet", url], {
38
- timeout: 10_000,
39
- env: {
40
- ...process.env,
41
- GIT_TERMINAL_PROMPT: "0",
42
- GIT_ASKPASS: undefined,
43
- SSH_ASKPASS: undefined,
44
- },
45
- }, (error) => {
46
- if (error) {
47
- reject(error);
48
- }
49
- else {
50
- resolve();
51
- }
65
+ const request = https.get(refsUrl, { headers: { "User-Agent": "backdot" }, timeout: PROBE_TIMEOUT_MS }, (response) => {
66
+ response.resume(); // drain the body so the socket is released
67
+ resolve(response.statusCode ?? 0);
52
68
  });
53
- // Don't let stdin keep the process alive
54
- child.stdin?.end();
69
+ request.on("timeout", () => request.destroy(new Error("Visibility probe timed out")));
70
+ request.on("error", reject);
55
71
  });
56
72
  }
package/dist/staging.d.ts CHANGED
@@ -22,7 +22,6 @@ export interface ComparisonResult {
22
22
  modified: string[];
23
23
  notBackedUp: string[];
24
24
  remoteIsEmpty?: boolean;
25
- error?: string;
26
25
  }
27
26
  export declare function compareFiles(opts: {
28
27
  files: string[];
package/dist/staging.js CHANGED
@@ -4,7 +4,7 @@ import os from "node:os";
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { simpleGit } from "simple-git";
6
6
  import { logger } from "./log.js";
7
- import { errorMessage, pluralize } from "./utils.js";
7
+ import { 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
10
  import { encrypt, decrypt } from "./crypto/encryption.js";
@@ -27,12 +27,19 @@ export function getStagedPath(filePath, machine) {
27
27
  : path.join(HOME_NAMESPACE, path.relative(HOME, filePath));
28
28
  return path.join(machineDir(machine), pathWithinMachineDir);
29
29
  }
30
+ /**
31
+ * git stores and reports repo paths with forward slashes on every platform, so
32
+ * normalize OS-native separators before comparing against `git ls-tree` output.
33
+ */
34
+ function toRepoRelativePath(stagedPath) {
35
+ return path.relative(STAGING_DIR, stagedPath).split(path.sep).join("/");
36
+ }
30
37
  /**
31
38
  * Inverse of getStagedPath: maps a path relative to the machine dir
32
39
  * (e.g. "home/.zshrc" or "root/etc/hosts") back to its restore destination.
33
40
  */
34
41
  export function getRestoreTarget(machineRelativePath) {
35
- const [namespace, ...rest] = machineRelativePath.split(path.sep);
42
+ const [namespace, ...rest] = machineRelativePath.split(/[/\\]/);
36
43
  const subPath = rest.join(path.sep);
37
44
  if (namespace === ROOT_NAMESPACE) {
38
45
  const destination = path.join("/", subPath);
@@ -75,9 +82,6 @@ export function copyToStaging(files, machine, derivedKey) {
75
82
  }
76
83
  logger.info(`Copied ${pluralize(copiedCount, "file")} to staging`);
77
84
  }
78
- function failedComparisonResult(err) {
79
- return { backedUp: [], modified: [], notBackedUp: [], error: errorMessage(err) };
80
- }
81
85
  // This machine has no snapshot in the remote yet, so every file counts as "not
82
86
  // backed up". Lets `status` preview what a first backup would push instead of erroring.
83
87
  function emptyRemoteResult(files) {
@@ -97,30 +101,18 @@ export async function compareFiles(opts) {
97
101
  await git.fetch("origin");
98
102
  }
99
103
  catch (err) {
100
- return failedComparisonResult(gitError(err, repository));
101
- }
102
- let branch;
103
- try {
104
- branch = await getCurrentBranch(git);
105
- }
106
- catch (err) {
107
- return failedComparisonResult(err);
108
- }
109
- let remoteBlobHashes;
110
- try {
111
- const treeOutput = execFileSync("git", ["ls-tree", "-r", `origin/${branch}`, `${machine}/`], {
112
- encoding: "utf-8",
113
- cwd: STAGING_DIR,
114
- });
115
- remoteBlobHashes = new Map(treeOutput
116
- .split("\n")
117
- .map((line) => line.match(/^\d+ blob ([0-9a-f]+)\t(.+)$/))
118
- .filter((match) => match !== null)
119
- .map((match) => [match[2], match[1]]));
120
- }
121
- catch (err) {
122
- return failedComparisonResult(err);
104
+ throw gitError(err, repository);
123
105
  }
106
+ const branch = await getCurrentBranch(git);
107
+ const treeOutput = execFileSync("git", ["ls-tree", "-r", `origin/${branch}`, `${machine}/`], {
108
+ encoding: "utf-8",
109
+ cwd: STAGING_DIR,
110
+ });
111
+ const remoteBlobHashes = new Map(treeOutput
112
+ .split("\n")
113
+ .map((line) => line.match(/^\d+ blob ([0-9a-f]+)\t(.+)$/))
114
+ .filter((match) => match !== null)
115
+ .map((match) => [match[2], match[1]]));
124
116
  // Repo reachable but this machine has nothing backed up yet (empty repo, or it
125
117
  // only holds other machines). Treat as a pre-backup preview.
126
118
  if (remoteBlobHashes.size === 0) {
@@ -143,17 +135,11 @@ export async function compareFiles(opts) {
143
135
  }
144
136
  });
145
137
  }
146
- let localFileHashes;
147
- try {
148
- const hashOutput = execFileSync("git", ["hash-object", "--stdin-paths"], {
149
- encoding: "utf-8",
150
- input: files.join("\n") + "\n",
151
- });
152
- localFileHashes = hashOutput.trim().split("\n");
153
- }
154
- catch (err) {
155
- return failedComparisonResult(err);
156
- }
138
+ const hashOutput = execFileSync("git", ["hash-object", "--stdin-paths"], {
139
+ encoding: "utf-8",
140
+ input: files.join("\n") + "\n",
141
+ });
142
+ const localFileHashes = hashOutput.trim().split("\n");
157
143
  const localHashByFile = new Map(files.map((file, i) => [file, localFileHashes[i]]));
158
144
  return compareFilesToRemote(files, machine, remoteBlobHashes, "", (file, remoteBlobHash) => {
159
145
  return remoteBlobHash === localHashByFile.get(file);
@@ -162,7 +148,7 @@ export async function compareFiles(opts) {
162
148
  function compareFilesToRemote(files, machine, remoteBlobHashes, pathSuffix, matchesRemote) {
163
149
  const result = { backedUp: [], modified: [], notBackedUp: [] };
164
150
  for (const file of files) {
165
- const repoRelativePath = path.relative(STAGING_DIR, getStagedPath(file, machine)) + pathSuffix;
151
+ const repoRelativePath = toRepoRelativePath(getStagedPath(file, machine)) + pathSuffix;
166
152
  const remoteBlobHash = remoteBlobHashes.get(repoRelativePath);
167
153
  if (!remoteBlobHash) {
168
154
  result.notBackedUp.push(file);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backdot",
3
- "version": "1.8.3",
3
+ "version": "1.8.5",
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",
@@ -30,7 +30,7 @@
30
30
  "fmt:check": "prettier --check src/",
31
31
  "lint": "eslint src/",
32
32
  "lint:fix": "eslint src/ --fix",
33
- "prepare": "cd .. && husky",
33
+ "prepare": "husky",
34
34
  "start": "node dist/cli.js",
35
35
  "test": "vitest run --exclude src/e2e.test.ts --exclude src/git.integration.test.ts",
36
36
  "test:all": "npm run build && vitest run",