backdot 1.8.1 → 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
@@ -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,6 +2,7 @@ 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";
@@ -61,6 +62,10 @@ export async function backup() {
61
62
  return;
62
63
  }
63
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
+ }
64
69
  spinner.text = "Syncing with remote";
65
70
  await gitPull(config.repository);
66
71
  if (derivedKey) {
@@ -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,22 +52,28 @@ 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((machine) => ({ name: machine, value: machine })),
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
78
  const machineStagingDir = machineDir(machine);
68
79
  try {
@@ -87,7 +98,12 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
87
98
  }
88
99
  return;
89
100
  }
90
- const backupFiles = listFilesRecursively(machineStagingDir);
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);
91
107
  logger.info(`Found ${pluralize(backupFiles.length, "file")} in backup repository`);
92
108
  if (backupFiles.length === 0) {
93
109
  spinner.stop();
@@ -95,11 +111,12 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
95
111
  return;
96
112
  }
97
113
  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);
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: backupFilePath, dest: path.join(HOME, relativePath), 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.relativePath, 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.relativePath, 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
  }
@@ -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/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("!")) {
@@ -1,5 +1,7 @@
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;
5
7
  export type PasswordSource = "env" | "file" | "prompt";
@@ -1,11 +1,10 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
- import path from "node:path";
4
- import os from "node:os";
5
3
  import { password as passwordPrompt, confirm } from "@inquirer/prompts";
6
- export const KEY_FILE_PATH = path.join(os.homedir(), ".backdot.key");
4
+ import { KEY_FILE_PATH } from "../paths.js";
5
+ export { KEY_FILE_PATH };
7
6
  export const ENC_SUFFIX = ".encrypted";
8
- function hashPassword(password) {
7
+ export function hashPassword(password) {
9
8
  return crypto.createHash("sha256").update(password).digest("hex");
10
9
  }
11
10
  export function checkKeyFilePermissions() {
package/dist/launchd.js CHANGED
@@ -5,6 +5,7 @@ import os from "node:os";
5
5
  import ora from "ora";
6
6
  import { logger } from "./log.js";
7
7
  import { errorMessage } from "./utils.js";
8
+ import { LOG_DIR, LAUNCHD_LOG_PATH } from "./paths.js";
8
9
  function escapeXml(text) {
9
10
  return text
10
11
  .replace(/&/g, "&amp;")
@@ -22,7 +23,6 @@ function buildPlist() {
22
23
  const nodePath = process.execPath;
23
24
  const scriptPath = getScriptPath();
24
25
  const workingDir = path.dirname(scriptPath);
25
- const logPath = path.join(os.homedir(), ".backdot", "launchd.log");
26
26
  return `<?xml version="1.0" encoding="UTF-8"?>
27
27
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
28
28
  <plist version="1.0">
@@ -45,9 +45,9 @@ function buildPlist() {
45
45
  <integer>0</integer>
46
46
  </dict>
47
47
  <key>StandardOutPath</key>
48
- <string>${escapeXml(logPath)}</string>
48
+ <string>${escapeXml(LAUNCHD_LOG_PATH)}</string>
49
49
  <key>StandardErrorPath</key>
50
- <string>${escapeXml(logPath)}</string>
50
+ <string>${escapeXml(LAUNCHD_LOG_PATH)}</string>
51
51
  <key>RunAtLoad</key>
52
52
  <false/>
53
53
  </dict>
@@ -72,9 +72,8 @@ export function setupLaunchd() {
72
72
  const spinner = ora("Installing schedule").start();
73
73
  const plistContent = buildPlist();
74
74
  const launchAgentsDir = path.dirname(PLIST_PATH);
75
- if (!fs.existsSync(launchAgentsDir)) {
76
- fs.mkdirSync(launchAgentsDir, { recursive: true });
77
- }
75
+ fs.mkdirSync(launchAgentsDir, { recursive: true });
76
+ fs.mkdirSync(LOG_DIR, { recursive: true });
78
77
  try {
79
78
  execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
80
79
  }
package/dist/log.js CHANGED
@@ -1,9 +1,6 @@
1
1
  import fs from "node:fs";
2
- import path from "node:path";
3
- import os from "node:os";
4
2
  import winston from "winston";
5
- const LOG_DIR = path.join(os.homedir(), ".backdot");
6
- const LOG_FILE = path.join(LOG_DIR, "backup.log");
3
+ import { LOG_DIR, CLI_LOG_PATH } from "./paths.js";
7
4
  let _logger;
8
5
  function getLogger() {
9
6
  if (!_logger) {
@@ -11,7 +8,7 @@ function getLogger() {
11
8
  _logger = winston.createLogger({
12
9
  level: "info",
13
10
  format: winston.format.combine(winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), winston.format.printf(({ timestamp, level, message }) => `${timestamp} [${level}] ${message}`)),
14
- transports: [new winston.transports.File({ filename: LOG_FILE })],
11
+ transports: [new winston.transports.File({ filename: CLI_LOG_PATH })],
15
12
  });
16
13
  }
17
14
  return _logger;
package/dist/paths.d.ts CHANGED
@@ -1,3 +1,9 @@
1
+ export declare const CONFIG_PATH: string;
2
+ export declare const KEY_FILE_PATH: string;
3
+ export declare const POST_RESTORE_HOOK_PATH: string;
1
4
  export declare const STAGING_DIR: string;
2
5
  export declare const STAGING_GIT_DIR: string;
6
+ export declare const LOG_DIR: string;
7
+ export declare const CLI_LOG_PATH: string;
8
+ export declare const LAUNCHD_LOG_PATH: string;
3
9
  export declare function machineDir(machine: string): string;
package/dist/paths.js CHANGED
@@ -1,8 +1,15 @@
1
1
  import path from "node:path";
2
2
  import os from "node:os";
3
3
  const HOME = os.homedir();
4
- export const STAGING_DIR = path.join(HOME, ".backdot", "repo");
4
+ const BACKDOT_DIR = path.join(HOME, ".backdot");
5
+ export const CONFIG_PATH = path.join(BACKDOT_DIR, "config.json");
6
+ export const KEY_FILE_PATH = path.join(BACKDOT_DIR, "encryption.key");
7
+ export const POST_RESTORE_HOOK_PATH = path.join(BACKDOT_DIR, "post-restore");
8
+ export const STAGING_DIR = path.join(BACKDOT_DIR, "repo");
5
9
  export const STAGING_GIT_DIR = path.join(STAGING_DIR, ".git");
10
+ export const LOG_DIR = path.join(BACKDOT_DIR, "logs");
11
+ export const CLI_LOG_PATH = path.join(LOG_DIR, "cli.log");
12
+ export const LAUNCHD_LOG_PATH = path.join(LOG_DIR, "launchd.log");
6
13
  export function machineDir(machine) {
7
14
  return path.join(STAGING_DIR, machine);
8
15
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Runs the restored `~/.backdot/post-restore` script (a POSIX `sh` script) so a
3
+ * restored machine can provision itself. The caller decides when to invoke this
4
+ * (only when the hook was actually among the restored files). A non-zero exit is
5
+ * surfaced as an error — never swallowed — but files have already been restored.
6
+ */
7
+ export declare function runPostRestoreHook(): void;
@@ -0,0 +1,35 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import os from "node:os";
3
+ import { logger } from "./log.js";
4
+ import { errorMessage } from "./utils.js";
5
+ import { POST_RESTORE_HOOK_PATH } from "./paths.js";
6
+ /**
7
+ * Runs the restored `~/.backdot/post-restore` script (a POSIX `sh` script) so a
8
+ * restored machine can provision itself. The caller decides when to invoke this
9
+ * (only when the hook was actually among the restored files). A non-zero exit is
10
+ * surfaced as an error — never swallowed — but files have already been restored.
11
+ */
12
+ export function runPostRestoreHook() {
13
+ if (process.platform === "win32") {
14
+ console.log(" Post-restore hook found, but hooks are not supported on Windows. Skipping.\n");
15
+ logger.warn("Post-restore hook skipped (not supported on Windows)");
16
+ return;
17
+ }
18
+ console.log(` Running post-restore hook (${POST_RESTORE_HOOK_PATH})…\n`);
19
+ logger.info("Running post-restore hook");
20
+ try {
21
+ execFileSync("/bin/sh", [POST_RESTORE_HOOK_PATH], {
22
+ cwd: os.homedir(),
23
+ stdio: "inherit",
24
+ });
25
+ }
26
+ catch (err) {
27
+ logger.error(`Post-restore hook failed: ${errorMessage(err)}`);
28
+ const exitCode = err.status;
29
+ const exitNote = typeof exitCode === "number" ? ` (exit code ${exitCode})` : "";
30
+ throw new Error(`Files were restored, but the post-restore hook failed${exitNote}.\n` +
31
+ ` Fix ${POST_RESTORE_HOOK_PATH} and re-run, or run it manually.`, { cause: err });
32
+ }
33
+ console.log();
34
+ logger.info("Post-restore hook completed");
35
+ }
@@ -36,7 +36,12 @@ function execGitLsRemote(url) {
36
36
  return new Promise((resolve, reject) => {
37
37
  const child = execFile("git", ["-c", "credential.helper=", "ls-remote", "--quiet", url], {
38
38
  timeout: 10_000,
39
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
39
+ env: {
40
+ ...process.env,
41
+ GIT_TERMINAL_PROMPT: "0",
42
+ GIT_ASKPASS: undefined,
43
+ SSH_ASKPASS: undefined,
44
+ },
40
45
  }, (error) => {
41
46
  if (error) {
42
47
  reject(error);
package/dist/staging.d.ts CHANGED
@@ -1,19 +1,33 @@
1
1
  import { STAGING_DIR, STAGING_GIT_DIR, machineDir } from "./paths.js";
2
2
  import { type DerivedKey } from "./crypto/encryption.js";
3
3
  export { STAGING_DIR, STAGING_GIT_DIR, machineDir };
4
+ export declare const HOME_NAMESPACE = "home";
5
+ export declare const ROOT_NAMESPACE = "root";
4
6
  export declare function getStagedPath(filePath: string, machine: string): string;
7
+ export interface RestoreTarget {
8
+ /** Absolute path the file is restored to on this machine. */
9
+ destination: string;
10
+ /** Path shown in the restore picker (e.g. "~/.zshrc" or "/etc/hosts"). */
11
+ displayPath: string;
12
+ }
13
+ /**
14
+ * Inverse of getStagedPath: maps a path relative to the machine dir
15
+ * (e.g. "home/.zshrc" or "root/etc/hosts") back to its restore destination.
16
+ */
17
+ export declare function getRestoreTarget(machineRelativePath: string): RestoreTarget;
5
18
  export declare function cleanStaging(machine: string): void;
6
19
  export declare function copyToStaging(files: string[], machine: string, derivedKey?: DerivedKey): void;
7
20
  export interface ComparisonResult {
8
21
  backedUp: string[];
9
22
  modified: string[];
10
23
  notBackedUp: string[];
24
+ remoteIsEmpty?: boolean;
11
25
  error?: string;
12
26
  }
13
27
  export declare function compareFiles(opts: {
14
28
  files: string[];
15
29
  machine: string;
16
30
  repository: string;
17
- derivedKey?: DerivedKey;
31
+ resolveKey?: () => Promise<DerivedKey>;
18
32
  }): Promise<ComparisonResult>;
19
33
  export declare function writeRepoReadme(repository: string, encrypted?: boolean): void;
package/dist/staging.js CHANGED
@@ -11,20 +11,44 @@ import { encrypt, decrypt } from "./crypto/encryption.js";
11
11
  import { KEY_FILE_PATH, ENC_SUFFIX } from "./crypto/password.js";
12
12
  export { STAGING_DIR, STAGING_GIT_DIR, machineDir };
13
13
  const HOME = os.homedir();
14
+ // Backed-up files live under one of two namespaces inside the machine dir, which
15
+ // keeps the layout lossless: HOME files restore relative to the restoring machine's
16
+ // own home (portable across machines), while files elsewhere restore to their
17
+ // original absolute path.
18
+ export const HOME_NAMESPACE = "home";
19
+ export const ROOT_NAMESPACE = "root";
20
+ function isOutsideHome(filePath) {
21
+ const relativeToHome = path.relative(HOME, filePath);
22
+ return relativeToHome.startsWith("..") || path.isAbsolute(relativeToHome);
23
+ }
14
24
  export function getStagedPath(filePath, machine) {
15
- const relativePath = path.relative(HOME, filePath);
16
- // Files outside HOME (e.g. /etc/foo) produce a relative path starting with "..",
17
- // which would escape the machine dir. Use the absolute path minus the leading "/" instead.
18
- const pathWithinMachineDir = relativePath.startsWith("..") ? filePath.slice(1) : relativePath;
25
+ const pathWithinMachineDir = isOutsideHome(filePath)
26
+ ? path.join(ROOT_NAMESPACE, filePath.slice(1)) // strip leading "/" so it stays inside the machine dir
27
+ : path.join(HOME_NAMESPACE, path.relative(HOME, filePath));
19
28
  return path.join(machineDir(machine), pathWithinMachineDir);
20
29
  }
30
+ /**
31
+ * Inverse of getStagedPath: maps a path relative to the machine dir
32
+ * (e.g. "home/.zshrc" or "root/etc/hosts") back to its restore destination.
33
+ */
34
+ export function getRestoreTarget(machineRelativePath) {
35
+ const [namespace, ...rest] = machineRelativePath.split(path.sep);
36
+ const subPath = rest.join(path.sep);
37
+ if (namespace === ROOT_NAMESPACE) {
38
+ const destination = path.join("/", subPath);
39
+ return { destination, displayPath: destination };
40
+ }
41
+ return { destination: path.join(HOME, subPath), displayPath: path.join("~", subPath) };
42
+ }
21
43
  export function cleanStaging(machine) {
22
- const machineStagingDir = machineDir(machine);
23
- if (!fs.existsSync(machineStagingDir)) {
24
- return;
44
+ // Remove only the backdot-managed namespaces so user-authored files in the
45
+ // machine dir (e.g. a hand-written README.md with restore notes) survive
46
+ // across backups. home/ and root/ are still fully rebuilt, so a backup
47
+ // remains a complete snapshot of the configured files.
48
+ for (const namespace of [HOME_NAMESPACE, ROOT_NAMESPACE]) {
49
+ fs.rmSync(path.join(machineDir(machine), namespace), { recursive: true, force: true });
25
50
  }
26
- fs.rmSync(machineStagingDir, { recursive: true, force: true });
27
- logger.info(`Cleaned staging directory for machine "${machine}"`);
51
+ logger.info(`Cleaned staging namespaces for machine "${machine}"`);
28
52
  }
29
53
  export function copyToStaging(files, machine, derivedKey) {
30
54
  const machineStagingDir = machineDir(machine);
@@ -54,13 +78,18 @@ export function copyToStaging(files, machine, derivedKey) {
54
78
  function failedComparisonResult(err) {
55
79
  return { backedUp: [], modified: [], notBackedUp: [], error: errorMessage(err) };
56
80
  }
81
+ // This machine has no snapshot in the remote yet, so every file counts as "not
82
+ // backed up". Lets `status` preview what a first backup would push instead of erroring.
83
+ function emptyRemoteResult(files) {
84
+ return { backedUp: [], modified: [], notBackedUp: files, remoteIsEmpty: true };
85
+ }
57
86
  export async function compareFiles(opts) {
58
- const { files, machine, repository, derivedKey } = opts;
87
+ const { files, machine, repository, resolveKey } = opts;
59
88
  if (files.length === 0) {
60
89
  return { backedUp: [], modified: [], notBackedUp: [] };
61
90
  }
62
91
  if (!fs.existsSync(STAGING_GIT_DIR)) {
63
- return failedComparisonResult(new Error('Backup repository not found. Run "backdot backup" first.'));
92
+ return emptyRemoteResult(files);
64
93
  }
65
94
  const git = simpleGit(STAGING_DIR);
66
95
  try {
@@ -92,7 +121,13 @@ export async function compareFiles(opts) {
92
121
  catch (err) {
93
122
  return failedComparisonResult(err);
94
123
  }
95
- if (derivedKey) {
124
+ // Repo reachable but this machine has nothing backed up yet (empty repo, or it
125
+ // only holds other machines). Treat as a pre-backup preview.
126
+ if (remoteBlobHashes.size === 0) {
127
+ return emptyRemoteResult(files);
128
+ }
129
+ if (resolveKey) {
130
+ const derivedKey = await resolveKey();
96
131
  return compareFilesToRemote(files, machine, remoteBlobHashes, ENC_SUFFIX, (file, remoteBlobHash) => {
97
132
  try {
98
133
  const blobContent = execFileSync("git", ["cat-file", "blob", remoteBlobHash], {
@@ -152,9 +187,11 @@ ${encryptionNote}
152
187
  ## Restore
153
188
 
154
189
  \`\`\`bash
155
- npx backdot restore ${repository}
190
+ npx backdot restore ${repository} --machine <machine>
156
191
  \`\`\`
157
192
 
193
+ Each machine is a top-level directory here; replace \`<machine>\` with the one you want to restore. A machine may also contain its own \`README.md\` with extra, machine-specific restore steps — backdot preserves it across backups.
194
+
158
195
  For full documentation, configuration options, and scheduling, see the [official README](https://github.com/sorenlouv/backdot).
159
196
  `;
160
197
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backdot",
3
- "version": "1.8.1",
3
+ "version": "1.8.2",
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,8 +30,7 @@
30
30
  "fmt:check": "prettier --check src/",
31
31
  "lint": "eslint src/",
32
32
  "lint:fix": "eslint src/ --fix",
33
- "prepare": "husky",
34
- "release": "./scripts/release.sh",
33
+ "prepare": "cd .. && husky",
35
34
  "start": "node dist/cli.js",
36
35
  "test": "vitest run --exclude src/e2e.test.ts --exclude src/git.integration.test.ts",
37
36
  "test:all": "npm run build && vitest run",
@@ -40,28 +39,29 @@
40
39
  "test:watch": "vitest"
41
40
  },
42
41
  "dependencies": {
43
- "@inquirer/prompts": "^8.3.0",
42
+ "@inquirer/prompts": "^8.5.2",
44
43
  "cac": "^7.0.0",
45
44
  "chalk": "^5.6.2",
46
45
  "fast-glob": "^3.3.3",
47
- "ora": "^9.3.0",
48
- "p-retry": "^7.1.1",
49
- "simple-git": "^3.32.2",
46
+ "ora": "^9.4.0",
47
+ "p-retry": "^8.0.0",
48
+ "simple-git": "^3.36.0",
50
49
  "winston": "^3.19.0",
51
- "zod": "^4.3.6"
50
+ "zod": "^4.4.3"
52
51
  },
53
52
  "engines": {
54
- "node": ">=18"
53
+ "node": ">=24"
55
54
  },
56
55
  "devDependencies": {
57
56
  "@eslint/js": "^10.0.1",
58
- "@types/node": "^22.13.5",
59
- "eslint": "^10.0.2",
57
+ "@types/node": "^25.9.3",
58
+ "esbuild": "^0.28.1",
59
+ "eslint": "^10.5.0",
60
60
  "eslint-config-prettier": "^10.1.8",
61
61
  "husky": "^9.1.7",
62
- "prettier": "^3.8.1",
63
- "typescript": "^5.7.3",
64
- "typescript-eslint": "^8.56.1",
65
- "vitest": "^4.0.18"
62
+ "prettier": "^3.8.4",
63
+ "typescript": "^6.0.3",
64
+ "typescript-eslint": "^8.61.0",
65
+ "vitest": "^4.1.8"
66
66
  }
67
67
  }
package/README.md DELETED
@@ -1,82 +0,0 @@
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.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 individual files or directories |
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.key` so that future backups do not prompt for a password.
57
-
58
- ## Commands
59
-
60
- | Command | Description |
61
- | ------------------------------ | ---------------------------------------------- |
62
- | `init` | Set up backdot for the first time |
63
- | `backup` | Run a backup now |
64
- | `restore` | Restore latest backup from the configured repo |
65
- | `restore <url>` | Restore from a specific repo URL |
66
- | `restore [url] --commit <sha>` | Restore from a specific backup commit |
67
- | `history [url]` | Browse and restore a previous backup |
68
- | `schedule` | Schedule automatic daily backup (Mac-only) |
69
- | `unschedule` | Unschedule the daily backup |
70
- | `status` | Show schedule and resolved file list |
71
-
72
- ## Development
73
-
74
- ```bash
75
- npm install
76
- npm run build
77
- npm start
78
- ```
79
-
80
- ## License
81
-
82
- MIT
package/dist/crypto.d.ts DELETED
@@ -1,13 +0,0 @@
1
- export declare const KEY_FILE_PATH: string;
2
- export declare const ENC_SUFFIX = ".encrypted";
3
- export declare function encryptBuffer(plaintext: Buffer, password: string): Buffer;
4
- export declare function decryptBuffer(data: Buffer, password: string): Buffer;
5
- export declare function checkKeyFilePermissions(): void;
6
- export declare function saveKeyFile(password: string): void;
7
- export interface PasswordResult {
8
- password: string;
9
- interactive: boolean;
10
- }
11
- export declare function resolvePassword(): Promise<PasswordResult>;
12
- export declare function confirmPassword(password: string): Promise<void>;
13
- export declare function offerToSaveKeyFile(password: string): Promise<void>;
package/dist/crypto.js DELETED
@@ -1,129 +0,0 @@
1
- import crypto from "node:crypto";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import os from "node:os";
5
- import { password as passwordPrompt, confirm } from "@inquirer/prompts";
6
- // File-format signature identifying backdot-encrypted files
7
- const MAGIC = Buffer.from("BDOT");
8
- const VERSION = 0x01;
9
- const HEADER_SIZE = MAGIC.length + 1; // 5 bytes: magic + version
10
- const SALT_SIZE = 32;
11
- const IV_SIZE = 12;
12
- const TAG_SIZE = 16;
13
- const KEY_SIZE = 32;
14
- export const KEY_FILE_PATH = path.join(os.homedir(), ".backdot.key");
15
- export const ENC_SUFFIX = ".encrypted";
16
- function deriveKey(password, salt) {
17
- return crypto.scryptSync(password, salt, KEY_SIZE, { N: 2 ** 14, r: 8, p: 1 });
18
- }
19
- export function encryptBuffer(plaintext, password) {
20
- const salt = crypto.randomBytes(SALT_SIZE);
21
- const iv = crypto.randomBytes(IV_SIZE);
22
- const key = deriveKey(password, salt);
23
- const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
24
- const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
25
- const tag = cipher.getAuthTag();
26
- return Buffer.concat([MAGIC, Buffer.from([VERSION]), salt, iv, tag, encrypted]);
27
- }
28
- export function decryptBuffer(data, password) {
29
- if (!isEncrypted(data)) {
30
- throw new Error("File is not encrypted (missing BDOT header).");
31
- }
32
- const minSize = HEADER_SIZE + SALT_SIZE + IV_SIZE + TAG_SIZE;
33
- if (data.length < minSize) {
34
- throw new Error("Encrypted file is too short or corrupted.");
35
- }
36
- let offset = HEADER_SIZE;
37
- const salt = data.subarray(offset, offset + SALT_SIZE);
38
- offset += SALT_SIZE;
39
- const iv = data.subarray(offset, offset + IV_SIZE);
40
- offset += IV_SIZE;
41
- const tag = data.subarray(offset, offset + TAG_SIZE);
42
- offset += TAG_SIZE;
43
- const ciphertext = data.subarray(offset);
44
- const key = deriveKey(password, salt);
45
- const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
46
- decipher.setAuthTag(tag);
47
- try {
48
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
49
- }
50
- catch {
51
- throw new Error("Decryption failed — wrong password or corrupted file.");
52
- }
53
- }
54
- function isEncrypted(data) {
55
- return (data.length >= HEADER_SIZE &&
56
- data[0] === MAGIC[0] &&
57
- data[1] === MAGIC[1] &&
58
- data[2] === MAGIC[2] &&
59
- data[3] === MAGIC[3] &&
60
- data[4] === VERSION);
61
- }
62
- export function checkKeyFilePermissions() {
63
- if (process.platform === "win32") {
64
- return;
65
- }
66
- if (!fs.existsSync(KEY_FILE_PATH)) {
67
- return;
68
- }
69
- const stat = fs.statSync(KEY_FILE_PATH);
70
- const mode = stat.mode & 0o777;
71
- const isAccessibleByGroupOrOthers = (mode & 0o077) !== 0;
72
- if (isAccessibleByGroupOrOthers) {
73
- throw new Error(`Key file ${KEY_FILE_PATH} has overly permissive permissions (${mode.toString(8)}).\n` +
74
- ` Run: chmod 600 ${KEY_FILE_PATH}`);
75
- }
76
- }
77
- export function saveKeyFile(password) {
78
- fs.writeFileSync(KEY_FILE_PATH, password + "\n", { mode: 0o600 });
79
- }
80
- function readKeyFile() {
81
- if (!fs.existsSync(KEY_FILE_PATH)) {
82
- return null;
83
- }
84
- checkKeyFilePermissions();
85
- return fs.readFileSync(KEY_FILE_PATH, "utf-8").trimEnd();
86
- }
87
- export async function resolvePassword() {
88
- const envPassword = process.env.BACKDOT_PASSWORD;
89
- if (envPassword) {
90
- return { password: envPassword, interactive: false };
91
- }
92
- const filePassword = readKeyFile();
93
- if (filePassword) {
94
- return { password: filePassword, interactive: false };
95
- }
96
- if (!process.stdin.isTTY) {
97
- throw new Error('Encryption is enabled but no password found.\n Run "backdot backup" interactively to create ~/.backdot.key, or set BACKDOT_PASSWORD.');
98
- }
99
- const enteredPassword = await passwordPrompt({ message: "Enter encryption password:" });
100
- if (!enteredPassword) {
101
- throw new Error("No password provided.");
102
- }
103
- return { password: enteredPassword, interactive: true };
104
- }
105
- export async function confirmPassword(password) {
106
- const confirmedPassword = await passwordPrompt({ message: "Confirm password:" });
107
- if (confirmedPassword !== password) {
108
- throw new Error("Passwords do not match.");
109
- }
110
- }
111
- export async function offerToSaveKeyFile(password) {
112
- if (!process.stdin.isTTY) {
113
- return;
114
- }
115
- if (fs.existsSync(KEY_FILE_PATH)) {
116
- return;
117
- }
118
- if (process.env.BACKDOT_PASSWORD) {
119
- return;
120
- }
121
- const save = await confirm({
122
- message: "Save password to ~/.backdot.key for automated backups?",
123
- default: true,
124
- });
125
- if (save) {
126
- saveKeyFile(password);
127
- console.log(` Created ${KEY_FILE_PATH} (permissions: 600)`);
128
- }
129
- }
@@ -1,2 +0,0 @@
1
- export declare function encrypt(plaintext: Buffer, password: string): Buffer;
2
- export declare function decrypt(encrypted: Buffer, password: string): Buffer;
@@ -1,39 +0,0 @@
1
- import crypto from "node:crypto";
2
- const ALGORITHM = "aes-256-gcm";
3
- const SALT_LENGTH = 32;
4
- const IV_LENGTH = 12;
5
- const AUTH_TAG_LENGTH = 16;
6
- const KEY_LENGTH = 32;
7
- const SCRYPT_PARAMS = { N: 2 ** 14, r: 8, p: 1 };
8
- const OVERHEAD = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;
9
- function deriveKey(password, salt) {
10
- return crypto.scryptSync(password, salt, KEY_LENGTH, SCRYPT_PARAMS);
11
- }
12
- export function encrypt(plaintext, password) {
13
- const salt = crypto.randomBytes(SALT_LENGTH);
14
- const iv = crypto.randomBytes(IV_LENGTH);
15
- const key = deriveKey(password, salt);
16
- const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
17
- const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
18
- const authTag = cipher.getAuthTag();
19
- return Buffer.concat([salt, iv, authTag, ciphertext]);
20
- }
21
- export function decrypt(encrypted, password) {
22
- if (encrypted.length < OVERHEAD) {
23
- throw new Error("Data is too short to be encrypted content.");
24
- }
25
- let offset = 0;
26
- const salt = encrypted.subarray(offset, (offset += SALT_LENGTH));
27
- const iv = encrypted.subarray(offset, (offset += IV_LENGTH));
28
- const authTag = encrypted.subarray(offset, (offset += AUTH_TAG_LENGTH));
29
- const ciphertext = encrypted.subarray(offset);
30
- const key = deriveKey(password, salt);
31
- const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
32
- decipher.setAuthTag(authTag);
33
- try {
34
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
35
- }
36
- catch {
37
- throw new Error("Decryption failed — wrong password or corrupted data.");
38
- }
39
- }
@@ -1 +0,0 @@
1
- export declare function errorMessage(err: unknown): string;
@@ -1,3 +0,0 @@
1
- export function errorMessage(err) {
2
- return err instanceof Error ? err.message : String(err);
3
- }
@@ -1,11 +0,0 @@
1
- export declare const KEY_FILE_PATH: string;
2
- export declare const ENC_SUFFIX = ".encrypted";
3
- export declare function checkKeyFilePermissions(): void;
4
- export declare function saveKeyFile(password: string): void;
5
- export interface PasswordResult {
6
- password: string;
7
- interactive: boolean;
8
- }
9
- export declare function resolvePassword(): Promise<PasswordResult>;
10
- export declare function confirmPassword(password: string): Promise<void>;
11
- export declare function offerToSaveKeyFile(password: string): Promise<void>;
package/dist/password.js DELETED
@@ -1,74 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import os from "node:os";
4
- import { password as passwordPrompt, confirm } from "@inquirer/prompts";
5
- export const KEY_FILE_PATH = path.join(os.homedir(), ".backdot.key");
6
- export const ENC_SUFFIX = ".encrypted";
7
- export function checkKeyFilePermissions() {
8
- if (process.platform === "win32") {
9
- return;
10
- }
11
- if (!fs.existsSync(KEY_FILE_PATH)) {
12
- return;
13
- }
14
- const stat = fs.statSync(KEY_FILE_PATH);
15
- const mode = stat.mode & 0o777;
16
- const isAccessibleByGroupOrOthers = (mode & 0o077) !== 0;
17
- if (isAccessibleByGroupOrOthers) {
18
- throw new Error(`Key file ${KEY_FILE_PATH} has overly permissive permissions (${mode.toString(8)}).\n` +
19
- ` Run: chmod 600 ${KEY_FILE_PATH}`);
20
- }
21
- }
22
- export function saveKeyFile(password) {
23
- fs.writeFileSync(KEY_FILE_PATH, password + "\n", { mode: 0o600 });
24
- }
25
- function readKeyFile() {
26
- if (!fs.existsSync(KEY_FILE_PATH)) {
27
- return null;
28
- }
29
- checkKeyFilePermissions();
30
- return fs.readFileSync(KEY_FILE_PATH, "utf-8").trimEnd();
31
- }
32
- export async function resolvePassword() {
33
- const envPassword = process.env.BACKDOT_PASSWORD;
34
- if (envPassword) {
35
- return { password: envPassword, interactive: false };
36
- }
37
- const filePassword = readKeyFile();
38
- if (filePassword) {
39
- return { password: filePassword, interactive: false };
40
- }
41
- 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.');
43
- }
44
- const enteredPassword = await passwordPrompt({ message: "Enter encryption password:" });
45
- if (!enteredPassword) {
46
- throw new Error("No password provided.");
47
- }
48
- return { password: enteredPassword, interactive: true };
49
- }
50
- export async function confirmPassword(password) {
51
- const confirmedPassword = await passwordPrompt({ message: "Confirm password:" });
52
- if (confirmedPassword !== password) {
53
- throw new Error("Passwords do not match.");
54
- }
55
- }
56
- export async function offerToSaveKeyFile(password) {
57
- if (!process.stdin.isTTY) {
58
- return;
59
- }
60
- if (fs.existsSync(KEY_FILE_PATH)) {
61
- return;
62
- }
63
- if (process.env.BACKDOT_PASSWORD) {
64
- return;
65
- }
66
- const save = await confirm({
67
- message: "Save password to ~/.backdot.key for automated backups?",
68
- default: true,
69
- });
70
- if (save) {
71
- saveKeyFile(password);
72
- console.log(` Created ${KEY_FILE_PATH} (permissions: 600)`);
73
- }
74
- }
package/dist/plist.d.ts DELETED
@@ -1,3 +0,0 @@
1
- export declare function isScheduled(): boolean;
2
- export declare function setupLaunchd(): void;
3
- export declare function uninstallLaunchd(): void;
package/dist/plist.js DELETED
@@ -1,112 +0,0 @@
1
- import { execFileSync } from "node:child_process";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import os from "node:os";
5
- import ora from "ora";
6
- import { logger } from "./log.js";
7
- import { errorMessage } from "./utils.js";
8
- function escapeXml(s) {
9
- return s
10
- .replace(/&/g, "&amp;")
11
- .replace(/</g, "&lt;")
12
- .replace(/>/g, "&gt;")
13
- .replace(/"/g, "&quot;");
14
- }
15
- const LABEL = "com.backdot.daemon";
16
- const PLIST_PATH = path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
17
- function getScriptPath() {
18
- const currentDir = path.dirname(new URL(import.meta.url).pathname);
19
- return path.resolve(currentDir, "cli.js");
20
- }
21
- function buildPlist() {
22
- const nodePath = process.execPath;
23
- const scriptPath = getScriptPath();
24
- const workingDir = path.dirname(scriptPath);
25
- const logPath = path.join(os.homedir(), ".backdot", "launchd.log");
26
- return `<?xml version="1.0" encoding="UTF-8"?>
27
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
28
- <plist version="1.0">
29
- <dict>
30
- <key>Label</key>
31
- <string>${LABEL}</string>
32
- <key>ProgramArguments</key>
33
- <array>
34
- <string>${escapeXml(nodePath)}</string>
35
- <string>${escapeXml(scriptPath)}</string>
36
- <string>--backup</string>
37
- </array>
38
- <key>WorkingDirectory</key>
39
- <string>${escapeXml(workingDir)}</string>
40
- <key>StartCalendarInterval</key>
41
- <dict>
42
- <key>Hour</key>
43
- <integer>2</integer>
44
- <key>Minute</key>
45
- <integer>0</integer>
46
- </dict>
47
- <key>StandardOutPath</key>
48
- <string>${escapeXml(logPath)}</string>
49
- <key>StandardErrorPath</key>
50
- <string>${escapeXml(logPath)}</string>
51
- <key>RunAtLoad</key>
52
- <false/>
53
- </dict>
54
- </plist>`;
55
- }
56
- export function isScheduled() {
57
- if (!fs.existsSync(PLIST_PATH)) {
58
- return false;
59
- }
60
- try {
61
- const output = execFileSync("launchctl", ["list", LABEL], { encoding: "utf-8", stdio: "pipe" });
62
- return output.includes(LABEL);
63
- }
64
- catch {
65
- return false;
66
- }
67
- }
68
- export function setupLaunchd() {
69
- const spinner = ora("Installing schedule").start();
70
- const plistContent = buildPlist();
71
- const dir = path.dirname(PLIST_PATH);
72
- if (!fs.existsSync(dir)) {
73
- fs.mkdirSync(dir, { recursive: true });
74
- }
75
- try {
76
- execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
77
- }
78
- catch {
79
- // Not loaded, that's fine
80
- }
81
- fs.writeFileSync(PLIST_PATH, plistContent);
82
- logger.info(`Plist written to ${PLIST_PATH}`);
83
- try {
84
- execFileSync("launchctl", ["load", PLIST_PATH], { stdio: "pipe" });
85
- spinner.succeed("Daily backup scheduled (02:00)");
86
- console.log();
87
- logger.info("Launchd job loaded");
88
- }
89
- catch (err) {
90
- const msg = errorMessage(err);
91
- spinner.fail(`Failed to load launchd job: ${msg}`);
92
- console.log();
93
- logger.error(`Failed to load launchd job: ${msg}`);
94
- throw new Error(`Failed to load launchd job: ${msg}`, { cause: err });
95
- }
96
- }
97
- export function uninstallLaunchd() {
98
- const spinner = ora("Removing schedule").start();
99
- try {
100
- execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
101
- logger.info("Launchd job unloaded");
102
- }
103
- catch {
104
- logger.info("Launchd job was not loaded");
105
- }
106
- if (fs.existsSync(PLIST_PATH)) {
107
- fs.unlinkSync(PLIST_PATH);
108
- logger.info(`Plist removed: ${PLIST_PATH}`);
109
- }
110
- spinner.succeed("Schedule removed");
111
- console.log();
112
- }
package/dist/resolve.d.ts DELETED
@@ -1,6 +0,0 @@
1
- import { Config } from "./config.js";
2
- /**
3
- * Resolve all file entries to absolute paths.
4
- * Skips entries that fail resolution and logs warnings.
5
- */
6
- export declare function resolveFiles(config: Config): string[];
package/dist/resolve.js DELETED
@@ -1,44 +0,0 @@
1
- import fs from "node:fs";
2
- import fg from "fast-glob";
3
- import { logger } from "./log.js";
4
- import { errorMessage, uniq } from "./utils.js";
5
- function resolveGlobs(patterns) {
6
- if (patterns.length === 0) {
7
- return [];
8
- }
9
- try {
10
- return fg.sync(patterns, { absolute: true, dot: true, onlyFiles: true });
11
- }
12
- catch (err) {
13
- logger.warn(`Glob pattern resolution failed: ${errorMessage(err)}`);
14
- return [];
15
- }
16
- }
17
- const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
18
- /**
19
- * Resolve all file entries to absolute paths.
20
- * Skips entries that fail resolution and logs warnings.
21
- */
22
- export function resolveFiles(config) {
23
- const unique = uniq(resolveGlobs(config.paths));
24
- return unique.filter((filePath) => {
25
- try {
26
- fs.accessSync(filePath, fs.constants.R_OK);
27
- const stat = fs.statSync(filePath);
28
- if (!stat.isFile()) {
29
- logger.warn(`Not a regular file, skipping: ${filePath}`);
30
- return false;
31
- }
32
- if (stat.size > LARGE_FILE_THRESHOLD) {
33
- const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
34
- logger.warn(`Large file (${sizeMB} MB), skipping: ${filePath}`);
35
- return false;
36
- }
37
- return true;
38
- }
39
- catch {
40
- logger.warn(`File not readable, skipping: ${filePath}`);
41
- return false;
42
- }
43
- });
44
- }