backdot 1.8.0 → 1.8.2

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/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";
@@ -30,10 +30,17 @@ cli.command("backup", "Run a backup now").action(async () => {
30
30
  });
31
31
  cli
32
32
  .command("restore [url]", "Restore files")
33
+ .option("--machine <name>", "Restore a specific machine")
33
34
  .option("--commit <sha>", "Restore from a specific backup commit")
34
- .option("-y, --yes", "Accept defaults without prompting")
35
+ .option("--no-overwrite", "Restore only new files; never overwrite existing ones (non-interactive)")
35
36
  .action(async (url, options) => {
36
- await restore({ repoUrl: url, commit: options.commit, yes: !!options.yes });
37
+ await restore({
38
+ repoUrl: url,
39
+ commit: options.commit,
40
+ // cac defaults `overwrite` to true and `--no-overwrite` flips it to false.
41
+ skipExisting: options.overwrite === false,
42
+ machine: options.machine,
43
+ });
37
44
  });
38
45
  cli
39
46
  .command("history [url]", "List and restore a previous backup")
@@ -51,8 +58,14 @@ cli.command("", "").action(() => {
51
58
  console.log(` No config found. Run ${chalk.bold("backdot init")} to get started.\n`);
52
59
  }
53
60
  });
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")));
61
+ cli.help((sections) => sections.filter((section) => {
62
+ if (!section.body) {
63
+ return true;
64
+ }
65
+ const contentLines = section.body.split("\n").filter((line) => line.trim() !== "");
66
+ const listsOnlyHelpAndVersion = contentLines.every((line) => /--(help|version)/.test(line));
67
+ return !listsOnlyHelpAndVersion;
68
+ }));
56
69
  cli.version(getVersion());
57
70
  async function main() {
58
71
  try {
@@ -2,12 +2,14 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import ora from "ora";
4
4
  import { loadConfig, CONFIG_PATH } from "../config.js";
5
+ import { POST_RESTORE_HOOK_PATH } from "../paths.js";
5
6
  import { resolveFiles } from "../resolveFiles.js";
6
7
  import { cleanStaging, copyToStaging, writeRepoReadme, machineDir } from "../staging.js";
7
8
  import { gitPull, gitCommitAndPush } from "../git.js";
8
9
  import { logger } from "../log.js";
9
10
  import { pluralize } from "../utils.js";
10
11
  import { checkRepoVisibility } from "../repoVisibility.js";
12
+ import { confirm } from "@inquirer/prompts";
11
13
  import { decrypt, deriveKey } from "../crypto/encryption.js";
12
14
  import { resolvePassword, offerToSaveKeyFile, confirmPassword, ENC_SUFFIX, } from "../crypto/password.js";
13
15
  function findEncryptedFile(dir) {
@@ -35,11 +37,12 @@ export async function backup() {
35
37
  logger.info(`Machine: ${config.machine}`);
36
38
  let password;
37
39
  let derivedKey;
38
- let passwordWasInteractive = false;
39
40
  if (config.encrypt) {
40
41
  const result = await resolvePassword();
41
42
  password = result.password;
42
- passwordWasInteractive = result.interactive;
43
+ if (result.source === "prompt") {
44
+ await confirmPassword(password);
45
+ }
43
46
  derivedKey = deriveKey(password);
44
47
  }
45
48
  const spinner = ora("Checking repository visibility").start();
@@ -59,6 +62,10 @@ export async function backup() {
59
62
  return;
60
63
  }
61
64
  const files = [...userFiles, CONFIG_PATH];
65
+ // Automatically backup the "post-restore" hook if it exists
66
+ if (fs.existsSync(POST_RESTORE_HOOK_PATH)) {
67
+ files.push(POST_RESTORE_HOOK_PATH);
68
+ }
62
69
  spinner.text = "Syncing with remote";
63
70
  await gitPull(config.repository);
64
71
  if (derivedKey) {
@@ -70,15 +77,22 @@ export async function backup() {
70
77
  decrypt(encryptedContent, derivedKey);
71
78
  }
72
79
  catch {
73
- spinner.fail("Backup failed");
74
- throw new Error("Password does not match the existing backup.");
80
+ if (!process.stdin.isTTY) {
81
+ spinner.fail("Backup failed");
82
+ throw new Error("Password does not match the existing backup.\n" +
83
+ "Run interactively to re-encrypt with a new password.");
84
+ }
85
+ spinner.stop();
86
+ const shouldReEncrypt = await confirm({
87
+ message: "Password does not match the existing backup. Re-encrypt all files with the new password?",
88
+ default: false,
89
+ });
90
+ if (!shouldReEncrypt) {
91
+ throw new Error("Backup aborted.");
92
+ }
93
+ spinner.start();
75
94
  }
76
95
  }
77
- else if (passwordWasInteractive) {
78
- spinner.stop();
79
- await confirmPassword(password);
80
- spinner.start();
81
- }
82
96
  }
83
97
  spinner.text = `Copying ${pluralize(files.length, "file")} to staging`;
84
98
  cleanStaging(config.machine);
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
+ import path from "node:path";
3
4
  import os from "node:os";
4
5
  import chalk from "chalk";
5
6
  import { CONFIG_PATH } from "../config.js";
@@ -42,6 +43,7 @@ export function init() {
42
43
  console.log(` ${chalk.yellow(`${CONFIG_PATH} already exists — skipping creation.`)}`);
43
44
  }
44
45
  else {
46
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
45
47
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
46
48
  console.log(` Created ${CONFIG_PATH} with defaults.`);
47
49
  }
@@ -1,5 +1,6 @@
1
- export declare function restore({ repoUrl, commit, yes, }?: {
1
+ export declare function restore({ repoUrl, commit, skipExisting, machine: machineOverride, }?: {
2
2
  repoUrl?: string;
3
3
  commit?: string;
4
- yes?: boolean;
4
+ skipExisting?: boolean;
5
+ machine?: string;
5
6
  }): Promise<void>;
@@ -1,16 +1,16 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import os from "node:os";
4
3
  import ora from "ora";
5
4
  import { checkbox, select, Separator } from "@inquirer/prompts";
6
5
  import { loadConfig } from "../config.js";
7
6
  import { gitPull } from "../git.js";
8
- import { STAGING_DIR, machineDir } from "../staging.js";
7
+ import { STAGING_DIR, machineDir, getRestoreTarget, HOME_NAMESPACE, ROOT_NAMESPACE, } from "../staging.js";
8
+ import { POST_RESTORE_HOOK_PATH } from "../paths.js";
9
+ import { runPostRestoreHook } from "../postRestoreHook.js";
9
10
  import { logger } from "../log.js";
10
11
  import { pluralize } from "../utils.js";
11
12
  import { decrypt, deriveKey } from "../crypto/encryption.js";
12
13
  import { resolvePassword, offerToSaveKeyFile, ENC_SUFFIX } from "../crypto/password.js";
13
- const HOME = os.homedir();
14
14
  function listFilesRecursively(dir) {
15
15
  return fs
16
16
  .readdirSync(dir, { withFileTypes: true })
@@ -29,10 +29,15 @@ function listMachines() {
29
29
  .filter((entry) => entry.isDirectory() && entry.name !== ".git")
30
30
  .map((entry) => entry.name);
31
31
  }
32
- async function resolveRepoAndMachine(repoUrl, commit) {
32
+ function formatMachineList(machines) {
33
+ return machines.map((machine) => ` - ${machine}`).join("\n");
34
+ }
35
+ async function resolveRepoAndMachine(repoUrl, commit, machineOverride) {
33
36
  if (!repoUrl) {
34
37
  const config = loadConfig();
35
- return { repository: config.repository, machine: config.machine };
38
+ // An explicit --machine wins over the configured machine; the repository
39
+ // still comes from config.
40
+ return { repository: config.repository, machine: machineOverride ?? config.machine };
36
41
  }
37
42
  const spinner = ora("Cloning backup repository").start();
38
43
  try {
@@ -47,24 +52,30 @@ async function resolveRepoAndMachine(repoUrl, commit) {
47
52
  if (machines.length === 0) {
48
53
  throw new Error("The backup repository is empty (no machine directories found).");
49
54
  }
50
- let machine;
55
+ if (machineOverride) {
56
+ if (!machines.includes(machineOverride)) {
57
+ throw new Error(`No backup found for machine "${machineOverride}".\n Available machines:\n${formatMachineList(machines)}`);
58
+ }
59
+ return { repository: repoUrl, machine: machineOverride };
60
+ }
51
61
  if (machines.length === 1) {
52
- machine = machines[0];
62
+ return { repository: repoUrl, machine: machines[0] };
53
63
  }
54
- else {
55
- machine = await select({
56
- message: "Multiple machines found. Which one do you want to restore?",
57
- loop: false,
58
- choices: machines.map((m) => ({ name: m, value: m })),
59
- });
64
+ if (!process.stdin.isTTY) {
65
+ throw new Error(`Multiple machines found in the backup repository. Re-run with --machine <name>:\n${formatMachineList(machines)}`);
60
66
  }
67
+ const machine = await select({
68
+ message: "Multiple machines found. Which one do you want to restore?",
69
+ loop: false,
70
+ choices: machines.map((machine) => ({ name: machine, value: machine })),
71
+ });
61
72
  return { repository: repoUrl, machine };
62
73
  }
63
- export async function restore({ repoUrl, commit, yes, } = {}) {
74
+ export async function restore({ repoUrl, commit, skipExisting, machine: machineOverride, } = {}) {
64
75
  logger.info("Starting restore");
65
- const { repository, machine } = await resolveRepoAndMachine(repoUrl, commit);
76
+ const { repository, machine } = await resolveRepoAndMachine(repoUrl, commit, machineOverride);
66
77
  const spinner = ora("Fetching latest backup").start();
67
- const baseDir = machineDir(machine);
78
+ const machineStagingDir = machineDir(machine);
68
79
  try {
69
80
  if (!repoUrl) {
70
81
  await gitPull(repository, commit);
@@ -75,31 +86,37 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
75
86
  throw err;
76
87
  }
77
88
  spinner.text = "Resolving files";
78
- if (!fs.existsSync(baseDir)) {
89
+ if (!fs.existsSync(machineStagingDir)) {
79
90
  spinner.stop();
80
- const available = listMachines();
81
- if (available.length > 0) {
91
+ const availableMachines = listMachines();
92
+ if (availableMachines.length > 0) {
82
93
  console.log(`\n No backup found for machine "${machine}".`);
83
- console.log(` Available machines: ${available.join(", ")}\n`);
94
+ console.log(` Available machines: ${availableMachines.join(", ")}\n`);
84
95
  }
85
96
  else {
86
97
  console.log(`\n No backup found for machine "${machine}". The repository is empty.\n`);
87
98
  }
88
99
  return;
89
100
  }
90
- const stagedFiles = listFilesRecursively(baseDir);
91
- logger.info(`Found ${pluralize(stagedFiles.length, "file")} in backup repository`);
92
- if (stagedFiles.length === 0) {
101
+ // Restore only the two managed namespaces; anything else in the machine dir
102
+ // (e.g. a user-authored README.md) is documentation, not a file to restore.
103
+ const backupFiles = [HOME_NAMESPACE, ROOT_NAMESPACE]
104
+ .map((namespace) => path.join(machineStagingDir, namespace))
105
+ .filter((namespaceDir) => fs.existsSync(namespaceDir))
106
+ .flatMap(listFilesRecursively);
107
+ logger.info(`Found ${pluralize(backupFiles.length, "file")} in backup repository`);
108
+ if (backupFiles.length === 0) {
93
109
  spinner.stop();
94
110
  console.log("No files found in backup repository.");
95
111
  return;
96
112
  }
97
- const fileMappings = stagedFiles.map((stagedFilePath) => {
98
- let relativePath = path.relative(baseDir, stagedFilePath);
99
- if (relativePath.endsWith(ENC_SUFFIX)) {
100
- relativePath = relativePath.slice(0, -ENC_SUFFIX.length);
113
+ const fileMappings = backupFiles.map((backupFilePath) => {
114
+ let machineRelativePath = path.relative(machineStagingDir, backupFilePath);
115
+ if (machineRelativePath.endsWith(ENC_SUFFIX)) {
116
+ machineRelativePath = machineRelativePath.slice(0, -ENC_SUFFIX.length);
101
117
  }
102
- return { src: stagedFilePath, dest: path.join(HOME, relativePath), rel: relativePath };
118
+ const { destination, displayPath } = getRestoreTarget(machineRelativePath);
119
+ return { src: backupFilePath, dest: destination, displayPath };
103
120
  });
104
121
  const filesAlreadyOnDisk = fileMappings.filter((file) => fs.existsSync(file.dest));
105
122
  const newFiles = fileMappings.filter((file) => !fs.existsSync(file.dest));
@@ -107,10 +124,10 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
107
124
  spinner.stop();
108
125
  console.log();
109
126
  let filesToRestore;
110
- if (yes) {
127
+ if (skipExisting) {
111
128
  filesToRestore = newFiles;
112
129
  if (filesAlreadyOnDisk.length > 0) {
113
- console.log(` Skipped ${pluralize(filesAlreadyOnDisk.length, "existing file")}. Run without --yes to select them.`);
130
+ console.log(` Skipped ${pluralize(filesAlreadyOnDisk.length, "existing file")}. Run without --no-overwrite to select them.`);
114
131
  console.log();
115
132
  }
116
133
  }
@@ -119,13 +136,13 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
119
136
  if (newFiles.length > 0) {
120
137
  choices.push(new Separator(`── New files (${newFiles.length}) ──`));
121
138
  for (const file of newFiles) {
122
- choices.push({ name: file.rel, value: file, checked: true });
139
+ choices.push({ name: file.displayPath, value: file, checked: true });
123
140
  }
124
141
  }
125
142
  if (filesAlreadyOnDisk.length > 0) {
126
143
  choices.push(new Separator(`── Existing files — will overwrite (${filesAlreadyOnDisk.length}) ──`));
127
144
  for (const file of filesAlreadyOnDisk) {
128
- choices.push({ name: file.rel, value: file, checked: false });
145
+ choices.push({ name: file.displayPath, value: file, checked: false });
129
146
  }
130
147
  }
131
148
  filesToRestore = await checkbox({
@@ -163,4 +180,9 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
163
180
  await offerToSaveKeyFile(password);
164
181
  }
165
182
  logger.info(`Restored ${pluralize(filesToRestore.length, "file")}`);
183
+ // Run the hook only if it was among the files just restored, so we
184
+ // never execute a stale on-disk script the user chose not to restore.
185
+ if (filesToRestore.some(({ dest }) => dest === POST_RESTORE_HOOK_PATH)) {
186
+ runPostRestoreHook();
187
+ }
166
188
  }
@@ -14,7 +14,7 @@ export function schedule() {
14
14
  try {
15
15
  const config = loadConfig();
16
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 ~/.backdot.key,\n` +
17
+ console.log(chalk.yellow(` Encryption is enabled. Run ${chalk.bold("backdot backup")} once to create ${KEY_FILE_PATH},\n` +
18
18
  ` or set ${chalk.bold("BACKDOT_PASSWORD")} in your environment.\n`));
19
19
  }
20
20
  }
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import os from "node:os";
2
3
  import chalk from "chalk";
3
4
  import ora from "ora";
@@ -24,6 +25,11 @@ function formatVisibility(visibility) {
24
25
  }
25
26
  }
26
27
  export async function status() {
28
+ if (!fs.existsSync(CONFIG_PATH)) {
29
+ console.log();
30
+ console.log(` No config found. Run ${chalk.bold("backdot init")} to get started.\n`);
31
+ return;
32
+ }
27
33
  const scheduled = isScheduled();
28
34
  console.log();
29
35
  console.log(` Schedule: ${scheduled ? "active (daily at 02:00)" : `not active (run ${chalk.bold("backdot schedule")} to enable)`}`);
@@ -46,29 +52,35 @@ export async function status() {
46
52
  console.log();
47
53
  return;
48
54
  }
49
- const derivedKey = config.encrypt ? deriveKey((await resolvePassword()).password) : undefined;
55
+ const resolveKey = config.encrypt
56
+ ? async () => deriveKey((await resolvePassword()).password)
57
+ : undefined;
50
58
  console.log();
51
59
  const spinner = ora("Resolving files").start();
52
60
  try {
53
61
  const userFiles = resolveFiles(config);
54
62
  if (userFiles.length === 0) {
55
- spinner.warn("No files resolved. Check your ~/.backdot.json entries.");
63
+ spinner.warn("No files resolved. Check your ~/.backdot/config.json entries.");
56
64
  console.log();
57
65
  return;
58
66
  }
59
67
  const files = [...userFiles, CONFIG_PATH];
60
68
  spinner.text = "Comparing with remote backup";
61
- const { backedUp, modified, notBackedUp, error } = await compareFiles({
69
+ const { backedUp, modified, notBackedUp, remoteIsEmpty, error } = await compareFiles({
62
70
  files,
63
71
  machine: config.machine,
64
72
  repository: config.repository,
65
- derivedKey,
73
+ resolveKey,
66
74
  });
67
75
  spinner.stop();
68
76
  if (error) {
69
77
  console.log(chalk.yellow(` Could not fetch status: ${error}`));
70
78
  return;
71
79
  }
80
+ if (remoteIsEmpty) {
81
+ console.log(` No backup yet — showing what ${chalk.bold("backdot backup")} would back up.`);
82
+ console.log();
83
+ }
72
84
  if (modified.length === 0 && notBackedUp.length === 0) {
73
85
  console.log(chalk.green(` All ${pluralize(files.length, "file")} are backed up ✓`));
74
86
  }
package/dist/commitUrl.js CHANGED
@@ -1,20 +1,17 @@
1
+ import { extractRepoPath } from "./utils.js";
1
2
  const PROVIDERS = {
2
3
  "github.com": (repoPath, sha) => `https://github.com/${repoPath}/commit/${sha}`,
3
4
  "gitlab.com": (repoPath, sha) => `https://gitlab.com/${repoPath}/-/commit/${sha}`,
4
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
- const separatorLength = 1; // skip the ":" (SSH) or "/" (HTTPS) after the hostname
13
- let repoPath = remoteUrl.slice(idx + host.length + separatorLength).trim();
14
- if (repoPath.endsWith(".git")) {
15
- repoPath = repoPath.slice(0, -4);
16
- }
17
- return buildUrl(repoPath, sha);
8
+ const parsed = extractRepoPath(remoteUrl);
9
+ if (!parsed) {
10
+ return null;
18
11
  }
19
- return null;
12
+ const buildUrl = PROVIDERS[parsed.host];
13
+ if (!buildUrl) {
14
+ return null;
15
+ }
16
+ return buildUrl(parsed.repoPath, sha);
20
17
  }
package/dist/config.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- export declare const CONFIG_PATH: string;
2
+ import { CONFIG_PATH } from "./paths.js";
3
+ export { CONFIG_PATH };
3
4
  export declare function expandTilde(pattern: string): string;
4
5
  declare const ConfigSchema: z.ZodObject<{
5
6
  repository: z.ZodString;
@@ -9,4 +10,3 @@ declare const ConfigSchema: z.ZodObject<{
9
10
  }, z.core.$strip>;
10
11
  export type Config = z.infer<typeof ConfigSchema>;
11
12
  export declare function loadConfig(): Config;
12
- export {};
package/dist/config.js CHANGED
@@ -2,7 +2,8 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { z } from "zod";
5
- export const CONFIG_PATH = path.join(os.homedir(), ".backdot.json");
5
+ import { CONFIG_PATH } from "./paths.js";
6
+ export { CONFIG_PATH };
6
7
  export function expandTilde(pattern) {
7
8
  // fast-glob uses "!" for negation patterns — preserve the prefix, expand the rest
8
9
  if (pattern.startsWith("!")) {
@@ -32,14 +33,14 @@ export function loadConfig() {
32
33
  catch {
33
34
  throw new Error(`Failed to read config file: ${CONFIG_PATH}`);
34
35
  }
35
- let parsed;
36
+ let parsedJson;
36
37
  try {
37
- parsed = JSON.parse(rawJson);
38
+ parsedJson = JSON.parse(rawJson);
38
39
  }
39
40
  catch {
40
41
  throw new Error(`Invalid JSON in config file: ${CONFIG_PATH}`);
41
42
  }
42
- const result = ConfigSchema.safeParse(parsed);
43
+ const result = ConfigSchema.safeParse(parsedJson);
43
44
  if (!result.success) {
44
45
  const messages = result.error.issues.map((issue) => {
45
46
  const field = issue.path.length > 0 ? `"${issue.path.join(".")}"` : "config";
@@ -1,8 +1,9 @@
1
+ /** An encryption key derived from the user's password via scrypt. */
1
2
  export interface DerivedKey {
2
- password: string;
3
+ passwordHash: string;
3
4
  salt: Buffer;
4
5
  key: Buffer;
5
6
  }
6
- export declare function deriveKey(password: string, salt?: Buffer): DerivedKey;
7
+ export declare function deriveKey(passwordHash: string, salt?: Buffer): DerivedKey;
7
8
  export declare function encrypt(plaintext: Buffer, derivedKey: DerivedKey): Buffer;
8
- export declare function decrypt(encrypted: Buffer, derivedKey: DerivedKey): Buffer;
9
+ export declare function decrypt(encryptedPayload: Buffer, derivedKey: DerivedKey): Buffer;
@@ -15,16 +15,16 @@ const SCRYPT_PARAMS = {
15
15
  maxmem: 128 * SCRYPT_N * SCRYPT_R * 2, // must exceed 128*N*r
16
16
  };
17
17
  const OVERHEAD = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;
18
- export function deriveKey(password, salt) {
18
+ export function deriveKey(passwordHash, salt) {
19
19
  const resolvedSalt = salt ?? crypto.randomBytes(SALT_LENGTH);
20
- const key = crypto.scryptSync(password, resolvedSalt, KEY_LENGTH, SCRYPT_PARAMS);
21
- return { password, salt: resolvedSalt, key };
20
+ const key = crypto.scryptSync(passwordHash, resolvedSalt, KEY_LENGTH, SCRYPT_PARAMS);
21
+ return { passwordHash, salt: resolvedSalt, key };
22
22
  }
23
23
  function deriveKeyForSalt(derivedKey, salt) {
24
24
  if (derivedKey.salt.equals(salt)) {
25
25
  return derivedKey.key;
26
26
  }
27
- return crypto.scryptSync(derivedKey.password, salt, KEY_LENGTH, SCRYPT_PARAMS);
27
+ return crypto.scryptSync(derivedKey.passwordHash, salt, KEY_LENGTH, SCRYPT_PARAMS);
28
28
  }
29
29
  export function encrypt(plaintext, derivedKey) {
30
30
  const iv = crypto.randomBytes(IV_LENGTH);
@@ -33,15 +33,18 @@ export function encrypt(plaintext, derivedKey) {
33
33
  const authTag = cipher.getAuthTag();
34
34
  return Buffer.concat([derivedKey.salt, iv, authTag, ciphertext]);
35
35
  }
36
- export function decrypt(encrypted, derivedKey) {
37
- if (encrypted.length < OVERHEAD) {
36
+ export function decrypt(encryptedPayload, derivedKey) {
37
+ if (encryptedPayload.length < OVERHEAD) {
38
38
  throw new Error("Data is too short to be encrypted content.");
39
39
  }
40
40
  let offset = 0;
41
- const salt = encrypted.subarray(offset, (offset += SALT_LENGTH));
42
- const iv = encrypted.subarray(offset, (offset += IV_LENGTH));
43
- const authTag = encrypted.subarray(offset, (offset += AUTH_TAG_LENGTH));
44
- const ciphertext = encrypted.subarray(offset);
41
+ const salt = encryptedPayload.subarray(offset, offset + SALT_LENGTH);
42
+ offset += SALT_LENGTH;
43
+ const iv = encryptedPayload.subarray(offset, offset + IV_LENGTH);
44
+ offset += IV_LENGTH;
45
+ const authTag = encryptedPayload.subarray(offset, offset + AUTH_TAG_LENGTH);
46
+ offset += AUTH_TAG_LENGTH;
47
+ const ciphertext = encryptedPayload.subarray(offset);
45
48
  const key = deriveKeyForSalt(derivedKey, salt);
46
49
  const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
47
50
  decipher.setAuthTag(authTag);
@@ -1,11 +1,14 @@
1
- export declare const KEY_FILE_PATH: string;
1
+ import { KEY_FILE_PATH } from "../paths.js";
2
+ export { KEY_FILE_PATH };
2
3
  export declare const ENC_SUFFIX = ".encrypted";
4
+ export declare function hashPassword(password: string): string;
3
5
  export declare function checkKeyFilePermissions(): void;
4
6
  export declare function saveKeyFile(password: string): void;
7
+ export type PasswordSource = "env" | "file" | "prompt";
5
8
  export interface PasswordResult {
6
9
  password: string;
7
- interactive: boolean;
10
+ source: PasswordSource;
8
11
  }
9
12
  export declare function resolvePassword(): Promise<PasswordResult>;
10
- export declare function confirmPassword(password: string): Promise<void>;
13
+ export declare function confirmPassword(hashedPassword: string): Promise<void>;
11
14
  export declare function offerToSaveKeyFile(password: string): Promise<void>;
@@ -1,9 +1,12 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
- import path from "node:path";
3
- import os from "node:os";
4
3
  import { password as passwordPrompt, confirm } from "@inquirer/prompts";
5
- export const KEY_FILE_PATH = path.join(os.homedir(), ".backdot.key");
4
+ import { KEY_FILE_PATH } from "../paths.js";
5
+ export { KEY_FILE_PATH };
6
6
  export const ENC_SUFFIX = ".encrypted";
7
+ export function hashPassword(password) {
8
+ return crypto.createHash("sha256").update(password).digest("hex");
9
+ }
7
10
  export function checkKeyFilePermissions() {
8
11
  if (process.platform === "win32") {
9
12
  return;
@@ -32,24 +35,24 @@ function readKeyFile() {
32
35
  export async function resolvePassword() {
33
36
  const envPassword = process.env.BACKDOT_PASSWORD;
34
37
  if (envPassword) {
35
- return { password: envPassword, interactive: false };
38
+ return { password: hashPassword(envPassword), source: "env" };
36
39
  }
37
40
  const filePassword = readKeyFile();
38
41
  if (filePassword) {
39
- return { password: filePassword, interactive: false };
42
+ return { password: filePassword, source: "file" };
40
43
  }
41
44
  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.');
45
+ throw new Error(`Encryption is enabled but no password found.\n Run "backdot backup" interactively to create ${KEY_FILE_PATH}, or set BACKDOT_PASSWORD.`);
43
46
  }
44
47
  const enteredPassword = await passwordPrompt({ message: "Enter encryption password:" });
45
48
  if (!enteredPassword) {
46
49
  throw new Error("No password provided.");
47
50
  }
48
- return { password: enteredPassword, interactive: true };
51
+ return { password: hashPassword(enteredPassword), source: "prompt" };
49
52
  }
50
- export async function confirmPassword(password) {
53
+ export async function confirmPassword(hashedPassword) {
51
54
  const confirmedPassword = await passwordPrompt({ message: "Confirm password:" });
52
- if (confirmedPassword !== password) {
55
+ if (hashPassword(confirmedPassword) !== hashedPassword) {
53
56
  throw new Error("Passwords do not match.");
54
57
  }
55
58
  }
@@ -64,7 +67,7 @@ export async function offerToSaveKeyFile(password) {
64
67
  return;
65
68
  }
66
69
  const save = await confirm({
67
- message: "Save password to ~/.backdot.key for automated backups?",
70
+ message: `Save password to ${KEY_FILE_PATH} for automated backups?`,
68
71
  default: true,
69
72
  });
70
73
  if (save) {
package/dist/git.d.ts CHANGED
@@ -11,7 +11,7 @@ interface FileChangeSummary {
11
11
  export declare function buildCommitMessage(changes: FileChangeSummary, maxLength?: number): string;
12
12
  export declare function ensureRemoteUrl(repository: string): Promise<void>;
13
13
  export declare function getCurrentBranch(git: SimpleGit): Promise<string>;
14
- export declare function friendlyGitError(raw: string, repository: string): string;
14
+ export declare function friendlyGitError(rawErrorMessage: string, repository: string): string;
15
15
  export declare function gitError(err: unknown, repository: string): Error;
16
16
  export declare function gitPull(repository: string, commit?: string): Promise<void>;
17
17
  export declare function gitLog(limit?: number): Promise<Array<{
package/dist/git.js CHANGED
@@ -57,8 +57,8 @@ export async function ensureRemoteUrl(repository) {
57
57
  export async function getCurrentBranch(git) {
58
58
  return (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
59
59
  }
60
- export function friendlyGitError(raw, repository) {
61
- const normalizedMessage = raw.toLowerCase();
60
+ export function friendlyGitError(rawErrorMessage, repository) {
61
+ const normalizedMessage = rawErrorMessage.toLowerCase();
62
62
  if (normalizedMessage.includes("not found") ||
63
63
  normalizedMessage.includes("does not exist") ||
64
64
  normalizedMessage.includes("does not appear to be a git repository")) {
@@ -73,7 +73,7 @@ export function friendlyGitError(raw, repository) {
73
73
  normalizedMessage.includes("connection timed out")) {
74
74
  return "Could not connect to remote host. Check your internet connection.";
75
75
  }
76
- return raw;
76
+ return rawErrorMessage;
77
77
  }
78
78
  export function gitError(err, repository) {
79
79
  const raw = errorMessage(err);
@@ -116,8 +116,8 @@ export async function gitPull(repository, commit) {
116
116
  }
117
117
  export async function gitLog(limit = 20) {
118
118
  const git = simpleGit(STAGING_DIR);
119
- const log = await git.log({ maxCount: limit });
120
- return log.all.map((entry) => ({
119
+ const logResult = await git.log({ maxCount: limit });
120
+ return logResult.all.map((entry) => ({
121
121
  hash: entry.hash,
122
122
  date: entry.date,
123
123
  message: entry.message,
@@ -133,6 +133,7 @@ export async function gitCommitAndPush() {
133
133
  }
134
134
  const message = buildCommitMessage(status);
135
135
  await git.commit(message);
136
+ const remoteUrl = ((await git.remote(["get-url", "origin"])) ?? "").trim();
136
137
  try {
137
138
  await pRetry(async () => git.push(["-u", "origin", "HEAD"]), {
138
139
  retries: 5,
@@ -151,12 +152,10 @@ export async function gitCommitAndPush() {
151
152
  });
152
153
  }
153
154
  catch (err) {
154
- const remoteUrl = ((await git.remote(["get-url", "origin"])) ?? "").trim();
155
155
  throw gitError(err, remoteUrl);
156
156
  }
157
157
  logger.info(`Committed and pushed: ${message}`);
158
158
  const commitHash = (await git.revparse(["HEAD"])).trim();
159
- const remoteUrl = (await git.remote(["get-url", "origin"])) ?? "";
160
159
  const commitUrl = getCommitUrl(remoteUrl, commitHash);
161
160
  return { commitUrl };
162
161
  }