backdot 1.1.0 → 1.3.0

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
@@ -1,21 +1,26 @@
1
- # backdot
1
+ <p align="center">
2
+ <img src="logo-small.png" alt="backdot" width="400" />
3
+ </p>
2
4
 
3
- Back up dotfiles and gitignored files to a private Git repo — on demand or on a daily schedule.
5
+ <h1 align="center">backdot</h1>
6
+
7
+ <p align="center">Automated backup of important files (configs, dotfiles, gitignored files) to your own private Git repo.</p>
4
8
 
5
9
  ## Getting started
6
10
 
7
11
  ```bash
8
12
  npm install -g backdot
13
+ backdot --init
9
14
  ```
10
15
 
11
- Create `~/.backdot.json` pointing to a private repo and the files you want backed up:
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:
12
17
 
13
18
  ```json
14
19
  {
15
- "repository": "git@github.com:USERNAME/dotfiles-backup.git",
20
+ "repository": "git@github.com:USERNAME/backdot-backup.git",
16
21
  "machine": "my-work-laptop",
17
- "files.gitignored": ["~/my-project"],
18
- "files.match": ["~/.zshrc", "~/.oh-my-zsh/custom/*.zsh", "~/.ssh/config/config", "~/.npmrc"]
22
+ "gitignored": ["~/my-project"],
23
+ "paths": ["~/.zshrc", "~/.oh-my-zsh/custom/*.zsh", "~/.ssh/config/config", "~/.npmrc"]
19
24
  }
20
25
  ```
21
26
 
@@ -27,15 +32,16 @@ backdot --backup
27
32
 
28
33
  ## Configuration
29
34
 
30
- | Key | Description |
31
- | ------------------ | ------------------------------------------------------ |
32
- | `files.gitignored` | Directories to scan for gitignored files |
33
- | `files.match` | Glob patterns matching individual files or directories |
35
+ | Key | Description |
36
+ | ------------ | ------------------------------------------------------ |
37
+ | `gitignored` | Directories to scan for gitignored files |
38
+ | `paths` | Glob patterns matching individual files or directories |
34
39
 
35
40
  ## Commands
36
41
 
37
42
  | Command | Description |
38
43
  | -------------- | ------------------------------------------------------ |
44
+ | `--init` | Set up backdot for the first time |
39
45
  | `--backup` | Run a backup now |
40
46
  | `--restore` | Restore files to their original locations |
41
47
  | `--schedule` | Schedule automatic daily backup via launchd (Mac-only) |
package/dist/cli.js CHANGED
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
5
+ import chalk from "chalk";
4
6
  import ora from "ora";
5
7
  import { loadConfig, CONFIG_PATH } from "./config.js";
6
8
  import { resolveFiles } from "./resolve.js";
7
- import { cleanStaging, copyToStaging, writeRepoReadme } from "./staging.js";
9
+ import { cleanStaging, copyToStaging, writeRepoReadme, compareFiles } from "./staging.js";
8
10
  import { gitPull, gitCommitAndPush } from "./git.js";
9
11
  import { restore } from "./restore.js";
12
+ import { init } from "./init.js";
10
13
  import { setupLaunchd, uninstallLaunchd, isScheduled } from "./plist.js";
11
14
  import { logger } from "./log.js";
12
15
  function getVersion() {
@@ -26,7 +29,7 @@ async function backup() {
26
29
  logger.info(`Machine: ${config.machine}`);
27
30
  const spinner = ora("Resolving files").start();
28
31
  try {
29
- const userFiles = resolveFiles(config.files);
32
+ const userFiles = resolveFiles(config);
30
33
  logger.info(`Resolved ${userFiles.length} file(s)`);
31
34
  if (userFiles.length === 0) {
32
35
  spinner.info("No files resolved, nothing to back up");
@@ -38,10 +41,13 @@ async function backup() {
38
41
  spinner.text = `Copying ${files.length} file(s) to staging`;
39
42
  cleanStaging(config.machine);
40
43
  copyToStaging(files, config.machine);
41
- writeRepoReadme();
44
+ writeRepoReadme(config.repository);
42
45
  spinner.text = "Pushing to remote";
43
- await gitCommitAndPush();
44
- spinner.succeed("Backup complete");
46
+ const result = await gitCommitAndPush();
47
+ const successMsg = result?.commitUrl
48
+ ? `Backup complete → ${result.commitUrl}`
49
+ : "Backup complete";
50
+ spinner.succeed(successMsg);
45
51
  console.log();
46
52
  }
47
53
  catch (err) {
@@ -50,7 +56,11 @@ async function backup() {
50
56
  }
51
57
  logger.info("Backup complete");
52
58
  }
53
- function status() {
59
+ function tildePath(filePath) {
60
+ const home = os.homedir();
61
+ return filePath.startsWith(home) ? "~" + filePath.slice(home.length) : filePath;
62
+ }
63
+ async function status() {
54
64
  const scheduled = isScheduled();
55
65
  console.log();
56
66
  console.log(` Schedule: ${scheduled ? "active (daily at 02:00)" : "not active"}`);
@@ -60,18 +70,42 @@ function status() {
60
70
  console.log(` Machine: ${config.machine}`);
61
71
  console.log();
62
72
  const spinner = ora("Resolving files").start();
63
- const userFiles = resolveFiles(config.files);
73
+ const userFiles = resolveFiles(config);
64
74
  if (userFiles.length === 0) {
65
75
  spinner.warn("No files resolved. Check your ~/.backdot.json entries.");
76
+ console.log();
77
+ return;
78
+ }
79
+ const files = [...userFiles, CONFIG_PATH];
80
+ spinner.text = "Comparing with remote backup";
81
+ const { backedUp, modified, notBackedUp } = await compareFiles(files, config.machine);
82
+ spinner.stop();
83
+ if (modified.length === 0 && notBackedUp.length === 0) {
84
+ console.log(chalk.green(` All ${files.length} file(s) are backed up ✓`));
66
85
  }
67
86
  else {
68
- const files = [...userFiles, CONFIG_PATH];
69
- spinner.stop();
70
- console.log(`${files.length} file(s) resolved:`);
71
- console.log();
72
- for (const file of files) {
73
- console.log(` ${file}`);
87
+ if (modified.length > 0) {
88
+ console.log(chalk.yellow(` Modified since last backup (${modified.length}):`));
89
+ for (const f of modified) {
90
+ console.log(` ${tildePath(f)}`);
91
+ }
92
+ console.log();
74
93
  }
94
+ if (notBackedUp.length > 0) {
95
+ console.log(chalk.red(` Not yet backed up (${notBackedUp.length}):`));
96
+ for (const f of notBackedUp) {
97
+ console.log(` ${tildePath(f)}`);
98
+ }
99
+ console.log();
100
+ }
101
+ if (backedUp.length > 0) {
102
+ console.log(chalk.green(` Backed up (${backedUp.length}):`));
103
+ for (const f of backedUp) {
104
+ console.log(` ${tildePath(f)}`);
105
+ }
106
+ console.log();
107
+ }
108
+ console.log(` Run ${chalk.bold("backdot --backup")} to back up all changes.`);
75
109
  }
76
110
  }
77
111
  catch (err) {
@@ -90,7 +124,10 @@ async function main() {
90
124
  const args = process.argv.slice(2);
91
125
  const command = args[0];
92
126
  try {
93
- if (command === "--backup") {
127
+ if (command === "--init") {
128
+ init();
129
+ }
130
+ else if (command === "--backup") {
94
131
  await backup();
95
132
  }
96
133
  else if (command === "--schedule") {
@@ -102,10 +139,10 @@ async function main() {
102
139
  uninstallLaunchd();
103
140
  }
104
141
  else if (command === "--status") {
105
- status();
142
+ await status();
106
143
  }
107
144
  else if (command === "--restore") {
108
- await restore();
145
+ await restore(args[1]);
109
146
  }
110
147
  else if (command === "--version") {
111
148
  console.log(getVersion());
@@ -116,8 +153,9 @@ async function main() {
116
153
  console.log();
117
154
  console.log(" Commands:");
118
155
  console.log();
156
+ console.log(" --init Set up backdot for the first time");
119
157
  console.log(" --backup Run a backup now");
120
- console.log(" --restore Restore files from the backup repo");
158
+ console.log(" --restore [url] Restore files from the backup repo");
121
159
  console.log(" --schedule Install daily backup schedule (macOS launchd)");
122
160
  console.log(" --unschedule Remove the daily backup schedule");
123
161
  console.log(" --status Show schedule and resolved files");
@@ -128,6 +166,10 @@ async function main() {
128
166
  console.log();
129
167
  process.exit(1);
130
168
  }
169
+ else if (!fs.existsSync(CONFIG_PATH)) {
170
+ console.log(` No config found. Run ${chalk.bold("backdot --init")} to get started.`);
171
+ console.log();
172
+ }
131
173
  }
132
174
  }
133
175
  catch (err) {
@@ -0,0 +1 @@
1
+ export declare function getCommitUrl(remoteUrl: string, sha: string): string | null;
@@ -0,0 +1,18 @@
1
+ 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}`,
5
+ };
6
+ 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
+ let repoPath = remoteUrl.slice(idx + host.length + 1).trim();
12
+ if (repoPath.endsWith(".git")) {
13
+ repoPath = repoPath.slice(0, -4);
14
+ }
15
+ return buildUrl(repoPath, sha);
16
+ }
17
+ return null;
18
+ }
package/dist/config.d.ts CHANGED
@@ -1,24 +1,12 @@
1
1
  import { z } from "zod";
2
2
  export declare const CONFIG_PATH: string;
3
3
  export declare function expandTilde(p: string): string;
4
- declare const ConfigSchema: z.ZodPipe<z.ZodObject<{
4
+ declare const ConfigSchema: z.ZodObject<{
5
5
  repository: z.ZodString;
6
6
  machine: z.ZodString;
7
- "files.gitignored": z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>>;
8
- "files.match": z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>>;
9
- }, z.core.$strip>, z.ZodTransform<{
10
- repository: string;
11
- machine: string;
12
- files: {
13
- gitignored: string[];
14
- match: string[];
15
- };
16
- }, {
17
- repository: string;
18
- machine: string;
19
- "files.gitignored": string[];
20
- "files.match": string[];
21
- }>>;
7
+ gitignored: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>>;
8
+ paths: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>>;
9
+ }, z.core.$strip>;
22
10
  export type Config = z.infer<typeof ConfigSchema>;
23
11
  export declare function loadConfig(): Config;
24
12
  export {};
package/dist/config.js CHANGED
@@ -14,23 +14,15 @@ const ConfigSchema = z
14
14
  .object({
15
15
  repository: z.string().min(1),
16
16
  machine: z.string().min(1),
17
- "files.gitignored": pathList,
18
- "files.match": pathList,
17
+ gitignored: pathList,
18
+ paths: pathList,
19
19
  })
20
- .refine((c) => c["files.gitignored"].length > 0 || c["files.match"].length > 0, {
21
- message: 'At least one of "files.gitignored" or "files.match" must be a non-empty array',
22
- })
23
- .transform((c) => ({
24
- repository: c.repository,
25
- machine: c.machine,
26
- files: {
27
- gitignored: c["files.gitignored"],
28
- match: c["files.match"],
29
- },
30
- }));
20
+ .refine((c) => c.gitignored.length > 0 || c.paths.length > 0, {
21
+ message: 'At least one of "gitignored" or "paths" must be a non-empty array',
22
+ });
31
23
  export function loadConfig() {
32
24
  if (!fs.existsSync(CONFIG_PATH)) {
33
- throw new Error(`Config file not found: ${CONFIG_PATH}`);
25
+ throw new Error(`Config file not found: ${CONFIG_PATH}\n Run "backdot --init" to create it.`);
34
26
  }
35
27
  let raw;
36
28
  try {
package/dist/git.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export declare function gitPull(repository: string): Promise<void>;
2
- export declare function gitCommitAndPush(): Promise<void>;
2
+ export declare function gitCommitAndPush(): Promise<{
3
+ commitUrl: string | null;
4
+ } | null>;
package/dist/git.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import pRetry from "p-retry";
3
4
  import { simpleGit, CleanOptions } from "simple-git";
4
5
  import { logger } from "./log.js";
5
6
  import { STAGING_DIR } from "./staging.js";
7
+ import { getCommitUrl } from "./commitUrl.js";
6
8
  export async function gitPull(repository) {
7
9
  if (fs.existsSync(path.join(STAGING_DIR, ".git"))) {
8
10
  const git = simpleGit(STAGING_DIR);
@@ -30,11 +32,23 @@ export async function gitCommitAndPush() {
30
32
  const status = await git.status();
31
33
  if (status.isClean()) {
32
34
  logger.info("No changes to commit");
33
- return;
35
+ return null;
34
36
  }
35
37
  const date = new Date().toISOString().split("T")[0];
36
38
  const message = `Automated backup: ${date}`;
37
39
  await git.commit(message);
38
- await git.push(["-u", "origin", "HEAD"]);
40
+ await pRetry(async () => git.push(["-u", "origin", "HEAD"]), {
41
+ retries: 5,
42
+ onFailedAttempt: async ({ attemptNumber, retriesLeft }) => {
43
+ logger.info(`Push failed (attempt ${attemptNumber}, ${retriesLeft} retries left), rebasing`);
44
+ await git.fetch("origin");
45
+ const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
46
+ await git.rebase([`origin/${branch}`]);
47
+ },
48
+ });
39
49
  logger.info(`Committed and pushed: ${message}`);
50
+ const sha = await git.revparse(["HEAD"]);
51
+ const remoteUrl = (await git.remote(["get-url", "origin"])) ?? "";
52
+ const commitUrl = getCommitUrl(remoteUrl, sha);
53
+ return { commitUrl };
40
54
  }
package/dist/init.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function init(): void;
package/dist/init.js ADDED
@@ -0,0 +1,43 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import chalk from "chalk";
4
+ import { CONFIG_PATH } from "./config.js";
5
+ const TEMPLATE = {
6
+ repository: "git@github.com:USERNAME/backdot-backup.git",
7
+ machine: os.hostname(),
8
+ gitignored: [],
9
+ paths: ["~/.zshrc", "~/.gitconfig"],
10
+ };
11
+ export function init() {
12
+ console.log();
13
+ console.log(chalk.bold(" Welcome to backdot!"));
14
+ console.log();
15
+ // Step 1
16
+ console.log(chalk.bold(" Step 1 — Create a private Git repository"));
17
+ console.log();
18
+ console.log(" If you don't have a backup repo yet, create one:");
19
+ console.log();
20
+ console.log(` GitHub: ${chalk.cyan("https://github.com/new?name=backdot-backup&visibility=private")}`);
21
+ console.log(` GitLab: ${chalk.cyan("https://gitlab.com/projects/new#blank_project")}`);
22
+ console.log(` Bitbucket: ${chalk.cyan("https://bitbucket.org/repo/create")}`);
23
+ console.log();
24
+ // Step 2
25
+ console.log(chalk.bold(` Step 2 — Edit ${CONFIG_PATH}`));
26
+ console.log();
27
+ if (fs.existsSync(CONFIG_PATH)) {
28
+ console.log(` ${chalk.yellow(`${CONFIG_PATH} already exists — skipping creation.`)}`);
29
+ }
30
+ else {
31
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(TEMPLATE, null, 2) + "\n");
32
+ console.log(` Created ${CONFIG_PATH} with defaults.`);
33
+ }
34
+ console.log(" Open it and set your repository URL and files to back up.");
35
+ console.log();
36
+ // Step 3
37
+ console.log(chalk.bold(" Step 3 — Run your first backup"));
38
+ console.log();
39
+ console.log(` ${chalk.bold("backdot --backup")} Run a one-time backup`);
40
+ console.log(` ${chalk.bold("backdot --schedule")} Schedule daily backups (macOS)`);
41
+ console.log(` ${chalk.bold("backdot --status")} Check which files will be backed up`);
42
+ console.log();
43
+ }
package/dist/resolve.d.ts CHANGED
@@ -3,4 +3,4 @@ import { Config } from "./config.js";
3
3
  * Resolve all file entries to absolute paths.
4
4
  * Skips entries that fail resolution and logs warnings.
5
5
  */
6
- export declare function resolveFiles(files: Config["files"]): string[];
6
+ export declare function resolveFiles(config: Config): string[];
package/dist/resolve.js CHANGED
@@ -37,15 +37,13 @@ const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
37
37
  * Resolve all file entries to absolute paths.
38
38
  * Skips entries that fail resolution and logs warnings.
39
39
  */
40
- export function resolveFiles(files) {
41
- const allFiles = [];
42
- for (const dirPath of files.gitignored) {
43
- allFiles.push(...resolveGitignored(dirPath));
44
- }
45
- for (const pattern of files.match) {
46
- allFiles.push(...resolveGlob(pattern));
47
- }
48
- const unique = [...new Set(allFiles)];
40
+ export function resolveFiles(config) {
41
+ const unique = [
42
+ ...new Set([
43
+ ...config.gitignored.flatMap(resolveGitignored),
44
+ ...config.paths.flatMap(resolveGlob),
45
+ ]),
46
+ ];
49
47
  return unique.filter((filePath) => {
50
48
  try {
51
49
  fs.accessSync(filePath, fs.constants.R_OK);
package/dist/restore.d.ts CHANGED
@@ -1 +1 @@
1
- export declare function restore(): Promise<void>;
1
+ export declare function restore(repoUrl?: string): Promise<void>;
package/dist/restore.js CHANGED
@@ -2,26 +2,20 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
4
  import ora from "ora";
5
- import { checkbox } from "@inquirer/prompts";
5
+ import { checkbox, select } from "@inquirer/prompts";
6
6
  import { loadConfig } from "./config.js";
7
7
  import { gitPull } from "./git.js";
8
8
  import { STAGING_DIR, machineDir } from "./staging.js";
9
9
  import { logger } from "./log.js";
10
10
  const HOME = os.homedir();
11
11
  function walkDir(dir) {
12
- const results = [];
13
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
14
- if (entry.name === ".git")
15
- continue;
12
+ return fs
13
+ .readdirSync(dir, { withFileTypes: true })
14
+ .filter((entry) => entry.name !== ".git")
15
+ .flatMap((entry) => {
16
16
  const full = path.join(dir, entry.name);
17
- if (entry.isDirectory()) {
18
- results.push(...walkDir(full));
19
- }
20
- else {
21
- results.push(full);
22
- }
23
- }
24
- return results;
17
+ return entry.isDirectory() ? walkDir(full) : [full];
18
+ });
25
19
  }
26
20
  function listMachines() {
27
21
  if (!fs.existsSync(STAGING_DIR))
@@ -31,22 +25,48 @@ function listMachines() {
31
25
  .filter((e) => e.isDirectory() && e.name !== ".git")
32
26
  .map((e) => e.name);
33
27
  }
34
- export async function restore() {
28
+ async function resolveRepoAndMachine(repoUrl) {
29
+ if (!repoUrl) {
30
+ const config = loadConfig();
31
+ return { repository: config.repository, machine: config.machine };
32
+ }
33
+ const spinner = ora("Cloning backup repository").start();
34
+ await gitPull(repoUrl);
35
+ spinner.stop();
36
+ const machines = listMachines();
37
+ if (machines.length === 0) {
38
+ throw new Error("The backup repository is empty (no machine directories found).");
39
+ }
40
+ let machine;
41
+ if (machines.length === 1) {
42
+ machine = machines[0];
43
+ }
44
+ else {
45
+ machine = await select({
46
+ message: "Multiple machines found. Which one do you want to restore?",
47
+ choices: machines.map((m) => ({ name: m, value: m })),
48
+ });
49
+ }
50
+ return { repository: repoUrl, machine };
51
+ }
52
+ export async function restore(repoUrl) {
35
53
  logger.info("Starting restore");
36
- const config = loadConfig();
37
- const baseDir = machineDir(config.machine);
54
+ const { repository, machine } = await resolveRepoAndMachine(repoUrl);
38
55
  const spinner = ora("Fetching latest backup").start();
39
- await gitPull(config.repository);
56
+ const baseDir = machineDir(machine);
57
+ if (!repoUrl) {
58
+ await gitPull(repository);
59
+ }
40
60
  spinner.text = "Resolving files";
41
61
  if (!fs.existsSync(baseDir)) {
42
62
  spinner.stop();
43
63
  const available = listMachines();
44
64
  if (available.length > 0) {
45
- console.log(`\n No backup found for machine "${config.machine}".`);
65
+ console.log(`\n No backup found for machine "${machine}".`);
46
66
  console.log(` Available machines: ${available.join(", ")}\n`);
47
67
  }
48
68
  else {
49
- console.log(`\n No backup found for machine "${config.machine}". The repository is empty.\n`);
69
+ console.log(`\n No backup found for machine "${machine}". The repository is empty.\n`);
50
70
  }
51
71
  return;
52
72
  }
package/dist/staging.d.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  export declare const STAGING_DIR: string;
2
2
  export declare function machineDir(machine: string): string;
3
+ export declare function getStagedPath(filePath: string, machine: string): string;
3
4
  export declare function cleanStaging(machine: string): void;
4
5
  export declare function copyToStaging(files: string[], machine: string): void;
5
- export declare function writeRepoReadme(): void;
6
+ export interface ComparisonResult {
7
+ backedUp: string[];
8
+ modified: string[];
9
+ notBackedUp: string[];
10
+ }
11
+ export declare function compareFiles(files: string[], machine: string): Promise<ComparisonResult>;
12
+ export declare function writeRepoReadme(repository: string): void;
package/dist/staging.js CHANGED
@@ -1,12 +1,19 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
+ import { execFileSync } from "node:child_process";
5
+ import { simpleGit } from "simple-git";
4
6
  import { logger } from "./log.js";
5
7
  const HOME = os.homedir();
6
8
  export const STAGING_DIR = path.join(HOME, ".backdot", "repo");
7
9
  export function machineDir(machine) {
8
10
  return path.join(STAGING_DIR, machine);
9
11
  }
12
+ export function getStagedPath(filePath, machine) {
13
+ const rel = path.relative(HOME, filePath);
14
+ const destRel = rel.startsWith("..") ? filePath.slice(1) : rel;
15
+ return path.join(machineDir(machine), destRel);
16
+ }
10
17
  export function cleanStaging(machine) {
11
18
  const dir = machineDir(machine);
12
19
  if (!fs.existsSync(dir))
@@ -19,9 +26,7 @@ export function copyToStaging(files, machine) {
19
26
  fs.mkdirSync(dir, { recursive: true });
20
27
  let copied = 0;
21
28
  for (const filePath of files) {
22
- const rel = path.relative(HOME, filePath);
23
- const destRel = rel.startsWith("..") ? filePath.slice(1) : rel;
24
- const dest = path.join(dir, destRel);
29
+ const dest = getStagedPath(filePath, machine);
25
30
  try {
26
31
  fs.mkdirSync(path.dirname(dest), { recursive: true });
27
32
  fs.copyFileSync(filePath, dest);
@@ -33,27 +38,81 @@ export function copyToStaging(files, machine) {
33
38
  }
34
39
  logger.info(`Copied ${copied} file(s) to staging`);
35
40
  }
36
- const REPO_README = `# Dotfiles Backup
41
+ const allNotBackedUp = (files) => ({
42
+ backedUp: [],
43
+ modified: [],
44
+ notBackedUp: files,
45
+ });
46
+ export async function compareFiles(files, machine) {
47
+ if (files.length === 0)
48
+ return { backedUp: [], modified: [], notBackedUp: [] };
49
+ const gitDir = path.join(STAGING_DIR, ".git");
50
+ if (!fs.existsSync(gitDir))
51
+ return allNotBackedUp(files);
52
+ const git = simpleGit(STAGING_DIR);
53
+ await git.fetch("origin");
54
+ let branch;
55
+ try {
56
+ branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
57
+ }
58
+ catch {
59
+ return allNotBackedUp(files);
60
+ }
61
+ let committedHashes;
62
+ try {
63
+ const treeOutput = execFileSync("git", ["ls-tree", "-r", `origin/${branch}`, `${machine}/`], {
64
+ encoding: "utf-8",
65
+ cwd: STAGING_DIR,
66
+ });
67
+ committedHashes = new Map(treeOutput
68
+ .split("\n")
69
+ .map((line) => line.match(/^\d+ blob ([0-9a-f]+)\t(.+)$/))
70
+ .filter((m) => m !== null)
71
+ .map((m) => [m[2], m[1]]));
72
+ }
73
+ catch {
74
+ return allNotBackedUp(files);
75
+ }
76
+ let sourceHashes;
77
+ try {
78
+ const hashOutput = execFileSync("git", ["hash-object", "--", ...files], {
79
+ encoding: "utf-8",
80
+ });
81
+ sourceHashes = hashOutput.trim().split("\n");
82
+ }
83
+ catch {
84
+ return allNotBackedUp(files);
85
+ }
86
+ return files.reduce((acc, file, i) => {
87
+ const repoRelPath = path.relative(STAGING_DIR, getStagedPath(file, machine));
88
+ const committedHash = committedHashes.get(repoRelPath);
89
+ if (!committedHash) {
90
+ acc.notBackedUp.push(file);
91
+ }
92
+ else if (committedHash === sourceHashes[i]) {
93
+ acc.backedUp.push(file);
94
+ }
95
+ else {
96
+ acc.modified.push(file);
97
+ }
98
+ return acc;
99
+ }, { backedUp: [], modified: [], notBackedUp: [] });
100
+ }
101
+ function repoReadme(repository) {
102
+ return `# Backdot Backup
37
103
 
38
104
  This repository contains dotfiles backed up automatically using [backdot](https://github.com/sorenlouv/backdot).
39
105
 
40
- ## Quick start
41
-
42
- Install backdot:
43
-
44
- \`\`\`bash
45
- npm install -g backdot
46
- \`\`\`
47
-
48
- Restore files from this backup:
106
+ ## Restore
49
107
 
50
108
  \`\`\`bash
51
- backdot --restore
109
+ npx backdot --restore ${repository}
52
110
  \`\`\`
53
111
 
54
112
  For full documentation, configuration options, and scheduling, see the [official README](https://github.com/sorenlouv/backdot).
55
113
  `;
56
- export function writeRepoReadme() {
57
- fs.writeFileSync(path.join(STAGING_DIR, "README.md"), REPO_README);
114
+ }
115
+ export function writeRepoReadme(repository) {
116
+ fs.writeFileSync(path.join(STAGING_DIR, "README.md"), repoReadme(repository));
58
117
  logger.info("Wrote README.md to staging directory");
59
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backdot",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
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",
@@ -21,18 +21,23 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "build": "tsc",
24
+ "build:watch": "tsc --watch",
25
+ "release": "./scripts/release.sh",
24
26
  "start": "node dist/cli.js",
25
27
  "lint": "eslint src/",
26
28
  "lint:fix": "eslint src/ --fix",
27
29
  "format": "prettier --write src/",
28
30
  "format:check": "prettier --check src/",
29
- "test": "vitest run",
31
+ "test": "vitest run --exclude src/e2e.test.ts",
32
+ "test:e2e": "npm run build && vitest run src/e2e.test.ts",
30
33
  "test:watch": "vitest"
31
34
  },
32
35
  "dependencies": {
33
36
  "@inquirer/prompts": "^8.3.0",
37
+ "chalk": "^5.6.2",
34
38
  "fast-glob": "^3.3.3",
35
39
  "ora": "^9.3.0",
40
+ "p-retry": "^7.1.1",
36
41
  "simple-git": "^3.32.2",
37
42
  "winston": "^3.19.0",
38
43
  "zod": "^4.3.6"