backdot 1.7.0 → 1.8.1

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 CHANGED
@@ -49,6 +49,12 @@ Prefix a pattern with `!` to exclude matching files:
49
49
  }
50
50
  ```
51
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.key` so that future backups do not prompt for a password.
57
+
52
58
  ## Commands
53
59
 
54
60
  | Command | Description |
package/dist/cli.js CHANGED
@@ -16,8 +16,8 @@ import { sendNotification } from "./notify.js";
16
16
  function getVersion() {
17
17
  const pkgPath = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../package.json");
18
18
  try {
19
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
20
- return pkg.version ?? "unknown";
19
+ const packageJson = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
20
+ return packageJson.version ?? "unknown";
21
21
  }
22
22
  catch {
23
23
  return "unknown";
@@ -51,7 +51,8 @@ cli.command("", "").action(() => {
51
51
  console.log(` No config found. Run ${chalk.bold("backdot init")} to get started.\n`);
52
52
  }
53
53
  });
54
- cli.help();
54
+ // cac auto-adds a --help flag; remove its redundant section from help output
55
+ cli.help((sections) => sections.filter((s) => !s.body?.includes("--help")));
55
56
  cli.version(getVersion());
56
57
  async function main() {
57
58
  try {
@@ -62,7 +63,8 @@ async function main() {
62
63
  const msg = errorMessage(err);
63
64
  logger.error(msg);
64
65
  console.error(`\n Error: ${msg}\n`);
65
- if (!process.stdout.isTTY) {
66
+ const isRunningInBackground = !process.stdout.isTTY;
67
+ if (isRunningInBackground) {
66
68
  sendNotification("Backdot", `Backup failed: ${msg}`);
67
69
  }
68
70
  process.exit(1);
@@ -1,17 +1,59 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import ora from "ora";
2
4
  import { loadConfig, CONFIG_PATH } from "../config.js";
3
5
  import { resolveFiles } from "../resolveFiles.js";
4
- import { cleanStaging, copyToStaging, writeRepoReadme } from "../staging.js";
6
+ import { cleanStaging, copyToStaging, writeRepoReadme, machineDir } from "../staging.js";
5
7
  import { gitPull, gitCommitAndPush } from "../git.js";
6
8
  import { logger } from "../log.js";
7
9
  import { pluralize } from "../utils.js";
10
+ import { checkRepoVisibility } from "../repoVisibility.js";
11
+ import { confirm } from "@inquirer/prompts";
12
+ import { decrypt, deriveKey } from "../crypto/encryption.js";
13
+ import { resolvePassword, offerToSaveKeyFile, confirmPassword, ENC_SUFFIX, } from "../crypto/password.js";
14
+ function findEncryptedFile(dir) {
15
+ if (!fs.existsSync(dir)) {
16
+ return null;
17
+ }
18
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
19
+ const fullPath = path.join(dir, entry.name);
20
+ if (entry.isDirectory()) {
21
+ const encryptedFile = findEncryptedFile(fullPath);
22
+ if (encryptedFile) {
23
+ return encryptedFile;
24
+ }
25
+ }
26
+ else if (entry.name.endsWith(ENC_SUFFIX)) {
27
+ return fullPath;
28
+ }
29
+ }
30
+ return null;
31
+ }
8
32
  export async function backup() {
9
33
  logger.info("Starting backup");
10
34
  const config = loadConfig();
11
35
  logger.info(`Repository: ${config.repository}`);
12
36
  logger.info(`Machine: ${config.machine}`);
13
- const spinner = ora("Resolving files").start();
37
+ let password;
38
+ let derivedKey;
39
+ if (config.encrypt) {
40
+ const result = await resolvePassword();
41
+ password = result.password;
42
+ if (result.source === "prompt") {
43
+ await confirmPassword(password);
44
+ }
45
+ derivedKey = deriveKey(password);
46
+ }
47
+ const spinner = ora("Checking repository visibility").start();
14
48
  try {
49
+ const visibility = await checkRepoVisibility(config.repository);
50
+ if (visibility === "public") {
51
+ spinner.fail("Backup refused — repository is public");
52
+ throw new Error(`Repository "${config.repository}" is publicly accessible.\n` +
53
+ "Backing up to a public repo would expose sensitive files.\n" +
54
+ "Make the repository private, then try again.");
55
+ }
56
+ spinner.text = "Resolving files";
15
57
  const userFiles = resolveFiles(config);
16
58
  logger.info(`Resolved ${pluralize(userFiles.length, "file")}`);
17
59
  if (userFiles.length === 0) {
@@ -21,10 +63,36 @@ export async function backup() {
21
63
  const files = [...userFiles, CONFIG_PATH];
22
64
  spinner.text = "Syncing with remote";
23
65
  await gitPull(config.repository);
66
+ if (derivedKey) {
67
+ const existingEncryptedFile = findEncryptedFile(machineDir(config.machine));
68
+ if (existingEncryptedFile) {
69
+ spinner.text = "Verifying encryption password";
70
+ const encryptedContent = fs.readFileSync(existingEncryptedFile);
71
+ try {
72
+ decrypt(encryptedContent, derivedKey);
73
+ }
74
+ catch {
75
+ if (!process.stdin.isTTY) {
76
+ spinner.fail("Backup failed");
77
+ throw new Error("Password does not match the existing backup.\n" +
78
+ "Run interactively to re-encrypt with a new password.");
79
+ }
80
+ spinner.stop();
81
+ const shouldReEncrypt = await confirm({
82
+ message: "Password does not match the existing backup. Re-encrypt all files with the new password?",
83
+ default: false,
84
+ });
85
+ if (!shouldReEncrypt) {
86
+ throw new Error("Backup aborted.");
87
+ }
88
+ spinner.start();
89
+ }
90
+ }
91
+ }
24
92
  spinner.text = `Copying ${pluralize(files.length, "file")} to staging`;
25
93
  cleanStaging(config.machine);
26
- copyToStaging(files, config.machine);
27
- writeRepoReadme(config.repository);
94
+ copyToStaging(files, config.machine, derivedKey);
95
+ writeRepoReadme(config.repository, config.encrypt);
28
96
  spinner.text = "Pushing to remote";
29
97
  const result = await gitCommitAndPush();
30
98
  const successMsg = result?.commitUrl
@@ -37,5 +105,8 @@ export async function backup() {
37
105
  spinner.fail("Backup failed");
38
106
  throw err;
39
107
  }
108
+ if (password) {
109
+ await offerToSaveKeyFile(password);
110
+ }
40
111
  logger.info("Backup complete");
41
112
  }
@@ -22,14 +22,17 @@ export async function history(repoUrl) {
22
22
  console.log("\n No backup history found.\n");
23
23
  return;
24
24
  }
25
- const selectedCommit = await select({
25
+ const selectedCommitHash = await select({
26
26
  message: "Select a backup to restore from:",
27
27
  loop: false,
28
- choices: commits.map((c) => ({
29
- name: `${c.hash.slice(0, 7)} ${c.date.split("T")[0]} ${c.message}`,
30
- value: c.hash,
31
- })),
28
+ choices: commits.map((commit) => {
29
+ const dateOnly = commit.date.split("T")[0];
30
+ return {
31
+ name: `${commit.hash.slice(0, 7)} ${dateOnly} ${commit.message}`,
32
+ value: commit.hash,
33
+ };
34
+ }),
32
35
  });
33
36
  console.log();
34
- await restore({ repoUrl, commit: selectedCommit });
37
+ await restore({ repoUrl, commit: selectedCommitHash });
35
38
  }
@@ -17,7 +17,7 @@ function getMachineName() {
17
17
  }
18
18
  return os.hostname().replace(/\.(local|localdomain)$/, "");
19
19
  }
20
- const TEMPLATE = {
20
+ const DEFAULT_CONFIG = {
21
21
  repository: "git@github.com:USERNAME/backdot-backup.git",
22
22
  machine: getMachineName(),
23
23
  paths: ["~/.zshrc", "~/.gitconfig"],
@@ -42,10 +42,11 @@ export function init() {
42
42
  console.log(` ${chalk.yellow(`${CONFIG_PATH} already exists — skipping creation.`)}`);
43
43
  }
44
44
  else {
45
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(TEMPLATE, null, 2) + "\n");
45
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
46
46
  console.log(` Created ${CONFIG_PATH} with defaults.`);
47
47
  }
48
48
  console.log(" Open it and set your repository URL and files to back up.");
49
+ console.log(` To encrypt backups, add ${chalk.bold('"encrypt": true')} to the config.`);
49
50
  console.log();
50
51
  // Step 3
51
52
  console.log(chalk.bold(" Step 3 — Run your first backup"));
@@ -8,14 +8,16 @@ import { gitPull } from "../git.js";
8
8
  import { STAGING_DIR, machineDir } from "../staging.js";
9
9
  import { logger } from "../log.js";
10
10
  import { pluralize } from "../utils.js";
11
+ import { decrypt, deriveKey } from "../crypto/encryption.js";
12
+ import { resolvePassword, offerToSaveKeyFile, ENC_SUFFIX } from "../crypto/password.js";
11
13
  const HOME = os.homedir();
12
- function walkDir(dir) {
14
+ function listFilesRecursively(dir) {
13
15
  return fs
14
16
  .readdirSync(dir, { withFileTypes: true })
15
17
  .filter((entry) => entry.name !== ".git")
16
18
  .flatMap((entry) => {
17
- const full = path.join(dir, entry.name);
18
- return entry.isDirectory() ? walkDir(full) : [full];
19
+ const fullPath = path.join(dir, entry.name);
20
+ return entry.isDirectory() ? listFilesRecursively(fullPath) : [fullPath];
19
21
  });
20
22
  }
21
23
  function listMachines() {
@@ -24,8 +26,8 @@ function listMachines() {
24
26
  }
25
27
  return fs
26
28
  .readdirSync(STAGING_DIR, { withFileTypes: true })
27
- .filter((e) => e.isDirectory() && e.name !== ".git")
28
- .map((e) => e.name);
29
+ .filter((entry) => entry.isDirectory() && entry.name !== ".git")
30
+ .map((entry) => entry.name);
29
31
  }
30
32
  async function resolveRepoAndMachine(repoUrl, commit) {
31
33
  if (!repoUrl) {
@@ -53,7 +55,7 @@ async function resolveRepoAndMachine(repoUrl, commit) {
53
55
  machine = await select({
54
56
  message: "Multiple machines found. Which one do you want to restore?",
55
57
  loop: false,
56
- choices: machines.map((m) => ({ name: m, value: m })),
58
+ choices: machines.map((machine) => ({ name: machine, value: machine })),
57
59
  });
58
60
  }
59
61
  return { repository: repoUrl, machine };
@@ -62,7 +64,7 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
62
64
  logger.info("Starting restore");
63
65
  const { repository, machine } = await resolveRepoAndMachine(repoUrl, commit);
64
66
  const spinner = ora("Fetching latest backup").start();
65
- const baseDir = machineDir(machine);
67
+ const machineStagingDir = machineDir(machine);
66
68
  try {
67
69
  if (!repoUrl) {
68
70
  await gitPull(repository, commit);
@@ -73,73 +75,92 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
73
75
  throw err;
74
76
  }
75
77
  spinner.text = "Resolving files";
76
- if (!fs.existsSync(baseDir)) {
78
+ if (!fs.existsSync(machineStagingDir)) {
77
79
  spinner.stop();
78
- const available = listMachines();
79
- if (available.length > 0) {
80
+ const availableMachines = listMachines();
81
+ if (availableMachines.length > 0) {
80
82
  console.log(`\n No backup found for machine "${machine}".`);
81
- console.log(` Available machines: ${available.join(", ")}\n`);
83
+ console.log(` Available machines: ${availableMachines.join(", ")}\n`);
82
84
  }
83
85
  else {
84
86
  console.log(`\n No backup found for machine "${machine}". The repository is empty.\n`);
85
87
  }
86
88
  return;
87
89
  }
88
- const stagedFiles = walkDir(baseDir);
89
- logger.info(`Found ${pluralize(stagedFiles.length, "file")} in backup repository`);
90
- if (stagedFiles.length === 0) {
90
+ const backupFiles = listFilesRecursively(machineStagingDir);
91
+ logger.info(`Found ${pluralize(backupFiles.length, "file")} in backup repository`);
92
+ if (backupFiles.length === 0) {
91
93
  spinner.stop();
92
94
  console.log("No files found in backup repository.");
93
95
  return;
94
96
  }
95
- const fileMappings = stagedFiles.map((src) => {
96
- const rel = path.relative(baseDir, src);
97
- return { src, dest: path.join(HOME, rel), rel };
97
+ const fileMappings = backupFiles.map((backupFilePath) => {
98
+ let relativePath = path.relative(machineStagingDir, backupFilePath);
99
+ if (relativePath.endsWith(ENC_SUFFIX)) {
100
+ relativePath = relativePath.slice(0, -ENC_SUFFIX.length);
101
+ }
102
+ return { src: backupFilePath, dest: path.join(HOME, relativePath), relativePath };
98
103
  });
99
- const existing = fileMappings.filter((f) => fs.existsSync(f.dest));
100
- const fresh = fileMappings.filter((f) => !fs.existsSync(f.dest));
101
- logger.info(`${fresh.length} new, ${existing.length} already exist`);
104
+ const filesAlreadyOnDisk = fileMappings.filter((file) => fs.existsSync(file.dest));
105
+ const newFiles = fileMappings.filter((file) => !fs.existsSync(file.dest));
106
+ logger.info(`${newFiles.length} new, ${filesAlreadyOnDisk.length} already exist`);
102
107
  spinner.stop();
103
108
  console.log();
104
- let toRestore;
109
+ let filesToRestore;
105
110
  if (yes) {
106
- toRestore = fresh;
107
- if (existing.length > 0) {
108
- console.log(` Skipped ${pluralize(existing.length, "existing file")}. Run without --yes to select them.`);
111
+ filesToRestore = newFiles;
112
+ if (filesAlreadyOnDisk.length > 0) {
113
+ console.log(` Skipped ${pluralize(filesAlreadyOnDisk.length, "existing file")}. Run without --yes to select them.`);
109
114
  console.log();
110
115
  }
111
116
  }
112
117
  else {
113
118
  const choices = [];
114
- if (fresh.length > 0) {
115
- choices.push(new Separator(`── New files (${fresh.length}) ──`));
116
- for (const f of fresh) {
117
- choices.push({ name: f.rel, value: f, checked: true });
119
+ if (newFiles.length > 0) {
120
+ choices.push(new Separator(`── New files (${newFiles.length}) ──`));
121
+ for (const file of newFiles) {
122
+ choices.push({ name: file.relativePath, value: file, checked: true });
118
123
  }
119
124
  }
120
- if (existing.length > 0) {
121
- choices.push(new Separator(`── Existing files — will overwrite (${existing.length}) ──`));
122
- for (const f of existing) {
123
- choices.push({ name: f.rel, value: f, checked: false });
125
+ if (filesAlreadyOnDisk.length > 0) {
126
+ choices.push(new Separator(`── Existing files — will overwrite (${filesAlreadyOnDisk.length}) ──`));
127
+ for (const file of filesAlreadyOnDisk) {
128
+ choices.push({ name: file.relativePath, value: file, checked: false });
124
129
  }
125
130
  }
126
- toRestore = await checkbox({
131
+ filesToRestore = await checkbox({
127
132
  message: "Select files to restore:",
128
133
  loop: false,
129
134
  choices,
130
135
  });
131
136
  console.log();
132
137
  }
133
- if (toRestore.length === 0) {
138
+ if (filesToRestore.length === 0) {
134
139
  console.log("No files selected for restore.");
135
140
  return;
136
141
  }
137
- const copySpinner = ora("Restoring files").start();
138
- for (const { src, dest } of toRestore) {
142
+ let password;
143
+ const hasEncryptedFiles = filesToRestore.some(({ src }) => src.endsWith(ENC_SUFFIX));
144
+ if (hasEncryptedFiles) {
145
+ const result = await resolvePassword();
146
+ password = result.password;
147
+ }
148
+ const derivedKey = password ? deriveKey(password) : undefined;
149
+ const restoreSpinner = ora("Restoring files").start();
150
+ for (const { src, dest } of filesToRestore) {
139
151
  fs.mkdirSync(path.dirname(dest), { recursive: true });
140
- fs.copyFileSync(src, dest);
152
+ if (derivedKey && src.endsWith(ENC_SUFFIX)) {
153
+ const content = fs.readFileSync(src);
154
+ fs.writeFileSync(dest, decrypt(content, derivedKey));
155
+ }
156
+ else {
157
+ fs.copyFileSync(src, dest);
158
+ }
141
159
  }
142
- copySpinner.succeed(`Restored ${pluralize(toRestore.length, "file")}`);
160
+ restoreSpinner.succeed(`Restored ${pluralize(filesToRestore.length, "file")}`);
143
161
  console.log();
144
- logger.info(`Restored ${pluralize(toRestore.length, "file")}`);
162
+ if (password) {
163
+ await offerToSaveKeyFile(password);
164
+ }
165
+ logger.info(`Restored ${pluralize(filesToRestore.length, "file")}`);
145
166
  }
@@ -1,4 +1,8 @@
1
+ import fs from "node:fs";
2
+ import chalk from "chalk";
1
3
  import { setupLaunchd, uninstallLaunchd } from "../launchd.js";
4
+ import { loadConfig } from "../config.js";
5
+ import { KEY_FILE_PATH } from "../crypto/password.js";
2
6
  function requireMacOS() {
3
7
  if (process.platform !== "darwin") {
4
8
  throw new Error("Scheduling is only supported on macOS (launchd). Use cron or systemd on Linux.");
@@ -7,6 +11,16 @@ function requireMacOS() {
7
11
  export function schedule() {
8
12
  requireMacOS();
9
13
  setupLaunchd();
14
+ try {
15
+ const config = loadConfig();
16
+ if (config.encrypt && !fs.existsSync(KEY_FILE_PATH)) {
17
+ console.log(chalk.yellow(` Encryption is enabled. Run ${chalk.bold("backdot backup")} once to create ${KEY_FILE_PATH},\n` +
18
+ ` or set ${chalk.bold("BACKDOT_PASSWORD")} in your environment.\n`));
19
+ }
20
+ }
21
+ catch {
22
+ // Config may not exist yet; ignore
23
+ }
10
24
  }
11
25
  export function unschedule() {
12
26
  requireMacOS();
@@ -6,17 +6,47 @@ import { resolveFiles } from "../resolveFiles.js";
6
6
  import { compareFiles } from "../staging.js";
7
7
  import { isScheduled } from "../launchd.js";
8
8
  import { pluralize } from "../utils.js";
9
- function tildePath(filePath) {
9
+ import { checkRepoVisibility } from "../repoVisibility.js";
10
+ import { deriveKey } from "../crypto/encryption.js";
11
+ import { resolvePassword } from "../crypto/password.js";
12
+ function abbreviateHomePath(filePath) {
10
13
  const home = os.homedir();
11
14
  return filePath.startsWith(home) ? "~" + filePath.slice(home.length) : filePath;
12
15
  }
16
+ function formatVisibility(visibility) {
17
+ switch (visibility) {
18
+ case "public":
19
+ return chalk.red.bold("public (backup disabled)");
20
+ case "private":
21
+ return chalk.green("private");
22
+ case "unknown":
23
+ return chalk.yellow("unknown (could not verify)");
24
+ }
25
+ }
13
26
  export async function status() {
14
27
  const scheduled = isScheduled();
15
28
  console.log();
16
- console.log(` Schedule: ${scheduled ? "active (daily at 02:00)" : `not active (run ${chalk.bold("backdot schedule")} to enable)`}`);
29
+ console.log(` Schedule: ${scheduled ? "active (daily at 02:00)" : `not active (run ${chalk.bold("backdot schedule")} to enable)`}`);
17
30
  const config = loadConfig();
18
- console.log(` Repo: ${config.repository}`);
19
- console.log(` Machine: ${config.machine}`);
31
+ console.log(` Repo: ${config.repository}`);
32
+ console.log(` Machine: ${config.machine}`);
33
+ if (config.encrypt) {
34
+ console.log(` Encryption: ${chalk.green("enabled")}`);
35
+ }
36
+ const visibilitySpinner = ora("Checking repository visibility").start();
37
+ const visibility = await checkRepoVisibility(config.repository);
38
+ visibilitySpinner.stop();
39
+ if (visibility !== "private") {
40
+ console.log(` Visibility: ${formatVisibility(visibility)}`);
41
+ }
42
+ if (visibility === "public") {
43
+ console.log();
44
+ console.log(chalk.red(" ⚠ Repository is public. Backup is disabled to prevent leaking sensitive files.\n" +
45
+ " Make the repository private, then try again."));
46
+ console.log();
47
+ return;
48
+ }
49
+ const derivedKey = config.encrypt ? deriveKey((await resolvePassword()).password) : undefined;
20
50
  console.log();
21
51
  const spinner = ora("Resolving files").start();
22
52
  try {
@@ -28,7 +58,12 @@ export async function status() {
28
58
  }
29
59
  const files = [...userFiles, CONFIG_PATH];
30
60
  spinner.text = "Comparing with remote backup";
31
- const { backedUp, modified, notBackedUp, error } = await compareFiles(files, config.machine, config.repository);
61
+ const { backedUp, modified, notBackedUp, error } = await compareFiles({
62
+ files,
63
+ machine: config.machine,
64
+ repository: config.repository,
65
+ derivedKey,
66
+ });
32
67
  spinner.stop();
33
68
  if (error) {
34
69
  console.log(chalk.yellow(` Could not fetch status: ${error}`));
@@ -40,22 +75,22 @@ export async function status() {
40
75
  else {
41
76
  if (modified.length > 0) {
42
77
  console.log(chalk.yellow(` Modified since last backup (${modified.length}):`));
43
- for (const f of modified) {
44
- console.log(` ${tildePath(f)}`);
78
+ for (const filePath of modified) {
79
+ console.log(` ${abbreviateHomePath(filePath)}`);
45
80
  }
46
81
  console.log();
47
82
  }
48
83
  if (notBackedUp.length > 0) {
49
84
  console.log(chalk.red(` Not yet backed up (${notBackedUp.length}):`));
50
- for (const f of notBackedUp) {
51
- console.log(` ${tildePath(f)}`);
85
+ for (const filePath of notBackedUp) {
86
+ console.log(` ${abbreviateHomePath(filePath)}`);
52
87
  }
53
88
  console.log();
54
89
  }
55
90
  if (backedUp.length > 0) {
56
91
  console.log(chalk.green(` Backed up (${backedUp.length}):`));
57
- for (const f of backedUp) {
58
- console.log(` ${tildePath(f)}`);
92
+ for (const filePath of backedUp) {
93
+ console.log(` ${abbreviateHomePath(filePath)}`);
59
94
  }
60
95
  console.log();
61
96
  }
package/dist/commitUrl.js CHANGED
@@ -1,19 +1,17 @@
1
+ import { extractRepoPath } from "./utils.js";
1
2
  const PROVIDERS = {
2
- "github.com": (p, sha) => `https://github.com/${p}/commit/${sha}`,
3
- "gitlab.com": (p, sha) => `https://gitlab.com/${p}/-/commit/${sha}`,
4
- "bitbucket.org": (p, sha) => `https://bitbucket.org/${p}/commits/${sha}`,
3
+ "github.com": (repoPath, sha) => `https://github.com/${repoPath}/commit/${sha}`,
4
+ "gitlab.com": (repoPath, sha) => `https://gitlab.com/${repoPath}/-/commit/${sha}`,
5
+ "bitbucket.org": (repoPath, sha) => `https://bitbucket.org/${repoPath}/commits/${sha}`,
5
6
  };
6
7
  export function getCommitUrl(remoteUrl, sha) {
7
- for (const [host, buildUrl] of Object.entries(PROVIDERS)) {
8
- const idx = remoteUrl.indexOf(host);
9
- if (idx === -1) {
10
- continue;
11
- }
12
- let repoPath = remoteUrl.slice(idx + host.length + 1).trim();
13
- if (repoPath.endsWith(".git")) {
14
- repoPath = repoPath.slice(0, -4);
15
- }
16
- return buildUrl(repoPath, sha);
8
+ const parsed = extractRepoPath(remoteUrl);
9
+ if (!parsed) {
10
+ return null;
17
11
  }
18
- return null;
12
+ const buildUrl = PROVIDERS[parsed.host];
13
+ if (!buildUrl) {
14
+ return null;
15
+ }
16
+ return buildUrl(parsed.repoPath, sha);
19
17
  }
package/dist/config.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { z } from "zod";
2
2
  export declare const CONFIG_PATH: string;
3
- export declare function expandTilde(p: string): string;
3
+ export declare function expandTilde(pattern: string): string;
4
4
  declare const ConfigSchema: z.ZodObject<{
5
5
  repository: z.ZodString;
6
6
  machine: z.ZodString;
7
7
  paths: z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
8
+ encrypt: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
8
9
  }, z.core.$strip>;
9
10
  export type Config = z.infer<typeof ConfigSchema>;
10
11
  export declare function loadConfig(): Config;
package/dist/config.js CHANGED
@@ -3,14 +3,15 @@ import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { z } from "zod";
5
5
  export const CONFIG_PATH = path.join(os.homedir(), ".backdot.json");
6
- export function expandTilde(p) {
7
- if (p.startsWith("!")) {
8
- return "!" + expandTilde(p.slice(1));
6
+ export function expandTilde(pattern) {
7
+ // fast-glob uses "!" for negation patterns — preserve the prefix, expand the rest
8
+ if (pattern.startsWith("!")) {
9
+ return "!" + expandTilde(pattern.slice(1));
9
10
  }
10
- if (p.startsWith("~/") || p === "~") {
11
- return path.join(os.homedir(), p.slice(1));
11
+ if (pattern.startsWith("~/") || pattern === "~") {
12
+ return path.join(os.homedir(), pattern.slice(1));
12
13
  }
13
- return p;
14
+ return pattern;
14
15
  }
15
16
  const ConfigSchema = z.object({
16
17
  repository: z.string().min(1),
@@ -18,30 +19,31 @@ const ConfigSchema = z.object({
18
19
  paths: z
19
20
  .array(z.string().min(1).transform(expandTilde))
20
21
  .min(1, '"paths" must be a non-empty array'),
22
+ encrypt: z.boolean().optional().default(false),
21
23
  });
22
24
  export function loadConfig() {
23
25
  if (!fs.existsSync(CONFIG_PATH)) {
24
26
  throw new Error(`Config file not found: ${CONFIG_PATH}\n Run "backdot init" to create it.`);
25
27
  }
26
- let raw;
28
+ let rawJson;
27
29
  try {
28
- raw = fs.readFileSync(CONFIG_PATH, "utf-8");
30
+ rawJson = fs.readFileSync(CONFIG_PATH, "utf-8");
29
31
  }
30
32
  catch {
31
33
  throw new Error(`Failed to read config file: ${CONFIG_PATH}`);
32
34
  }
33
- let parsed;
35
+ let parsedJson;
34
36
  try {
35
- parsed = JSON.parse(raw);
37
+ parsedJson = JSON.parse(rawJson);
36
38
  }
37
39
  catch {
38
40
  throw new Error(`Invalid JSON in config file: ${CONFIG_PATH}`);
39
41
  }
40
- const result = ConfigSchema.safeParse(parsed);
42
+ const result = ConfigSchema.safeParse(parsedJson);
41
43
  if (!result.success) {
42
- const messages = result.error.issues.map((i) => {
43
- const path = i.path.length > 0 ? `"${i.path.join(".")}"` : "config";
44
- return ` - ${path}: ${i.message}`;
44
+ const messages = result.error.issues.map((issue) => {
45
+ const field = issue.path.length > 0 ? `"${issue.path.join(".")}"` : "config";
46
+ return ` - ${field}: ${issue.message}`;
45
47
  });
46
48
  throw new Error(`Invalid config in ${CONFIG_PATH}:\n${messages.join("\n")}`);
47
49
  }
@@ -0,0 +1,9 @@
1
+ /** An encryption key derived from the user's password via scrypt. */
2
+ export interface DerivedKey {
3
+ passwordHash: string;
4
+ salt: Buffer;
5
+ key: Buffer;
6
+ }
7
+ export declare function deriveKey(passwordHash: string, salt?: Buffer): DerivedKey;
8
+ export declare function encrypt(plaintext: Buffer, derivedKey: DerivedKey): Buffer;
9
+ export declare function decrypt(encryptedPayload: Buffer, derivedKey: DerivedKey): Buffer;