backdot 1.8.2 → 1.8.4

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.
@@ -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.2",
3
+ "version": "1.8.4",
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",