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.
- package/dist/commands/backup.js +8 -0
- package/dist/commands/history.js +4 -0
- package/dist/commands/restore.js +5 -0
- package/dist/commands/status.js +18 -10
- package/dist/repoVisibility.d.ts +17 -3
- package/dist/repoVisibility.js +39 -23
- package/dist/staging.d.ts +0 -1
- package/dist/staging.js +26 -40
- package/package.json +1 -1
package/dist/commands/backup.js
CHANGED
|
@@ -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")}`);
|
package/dist/commands/history.js
CHANGED
|
@@ -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,
|
package/dist/commands/restore.js
CHANGED
|
@@ -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}) ──`));
|
package/dist/commands/status.js
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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();
|
package/dist/repoVisibility.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
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>;
|
package/dist/repoVisibility.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 =
|
|
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);
|