backdot 1.1.0 → 1.2.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,6 +1,10 @@
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
 
package/dist/cli.js CHANGED
@@ -1,10 +1,12 @@
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";
10
12
  import { setupLaunchd, uninstallLaunchd, isScheduled } from "./plist.js";
@@ -38,10 +40,13 @@ async function backup() {
38
40
  spinner.text = `Copying ${files.length} file(s) to staging`;
39
41
  cleanStaging(config.machine);
40
42
  copyToStaging(files, config.machine);
41
- writeRepoReadme();
43
+ writeRepoReadme(config.repository);
42
44
  spinner.text = "Pushing to remote";
43
- await gitCommitAndPush();
44
- spinner.succeed("Backup complete");
45
+ const result = await gitCommitAndPush();
46
+ const successMsg = result?.commitUrl
47
+ ? `Backup complete → ${result.commitUrl}`
48
+ : "Backup complete";
49
+ spinner.succeed(successMsg);
45
50
  console.log();
46
51
  }
47
52
  catch (err) {
@@ -50,7 +55,11 @@ async function backup() {
50
55
  }
51
56
  logger.info("Backup complete");
52
57
  }
53
- function status() {
58
+ function tildePath(filePath) {
59
+ const home = os.homedir();
60
+ return filePath.startsWith(home) ? "~" + filePath.slice(home.length) : filePath;
61
+ }
62
+ async function status() {
54
63
  const scheduled = isScheduled();
55
64
  console.log();
56
65
  console.log(` Schedule: ${scheduled ? "active (daily at 02:00)" : "not active"}`);
@@ -63,15 +72,39 @@ function status() {
63
72
  const userFiles = resolveFiles(config.files);
64
73
  if (userFiles.length === 0) {
65
74
  spinner.warn("No files resolved. Check your ~/.backdot.json entries.");
75
+ console.log();
76
+ return;
77
+ }
78
+ const files = [...userFiles, CONFIG_PATH];
79
+ spinner.text = "Comparing with remote backup";
80
+ const { backedUp, modified, notBackedUp } = await compareFiles(files, config.machine);
81
+ spinner.stop();
82
+ if (modified.length === 0 && notBackedUp.length === 0) {
83
+ console.log(chalk.green(` All ${files.length} file(s) are backed up ✓`));
66
84
  }
67
85
  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}`);
86
+ if (modified.length > 0) {
87
+ console.log(chalk.yellow(` Modified since last backup (${modified.length}):`));
88
+ for (const f of modified) {
89
+ console.log(` ${tildePath(f)}`);
90
+ }
91
+ console.log();
92
+ }
93
+ if (notBackedUp.length > 0) {
94
+ console.log(chalk.red(` Not yet backed up (${notBackedUp.length}):`));
95
+ for (const f of notBackedUp) {
96
+ console.log(` ${tildePath(f)}`);
97
+ }
98
+ console.log();
99
+ }
100
+ if (backedUp.length > 0) {
101
+ console.log(chalk.green(` Backed up (${backedUp.length}):`));
102
+ for (const f of backedUp) {
103
+ console.log(` ${tildePath(f)}`);
104
+ }
105
+ console.log();
74
106
  }
107
+ console.log(` Run ${chalk.bold("backdot --backup")} to back up all changes.`);
75
108
  }
76
109
  }
77
110
  catch (err) {
@@ -102,10 +135,10 @@ async function main() {
102
135
  uninstallLaunchd();
103
136
  }
104
137
  else if (command === "--status") {
105
- status();
138
+ await status();
106
139
  }
107
140
  else if (command === "--restore") {
108
- await restore();
141
+ await restore(args[1]);
109
142
  }
110
143
  else if (command === "--version") {
111
144
  console.log(getVersion());
@@ -117,7 +150,7 @@ async function main() {
117
150
  console.log(" Commands:");
118
151
  console.log();
119
152
  console.log(" --backup Run a backup now");
120
- console.log(" --restore Restore files from the backup repo");
153
+ console.log(" --restore [url] Restore files from the backup repo");
121
154
  console.log(" --schedule Install daily backup schedule (macOS launchd)");
122
155
  console.log(" --unschedule Remove the daily backup schedule");
123
156
  console.log(" --status Show schedule and resolved files");
@@ -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/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
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { simpleGit, CleanOptions } from "simple-git";
4
4
  import { logger } from "./log.js";
5
5
  import { STAGING_DIR } from "./staging.js";
6
+ import { getCommitUrl } from "./commitUrl.js";
6
7
  export async function gitPull(repository) {
7
8
  if (fs.existsSync(path.join(STAGING_DIR, ".git"))) {
8
9
  const git = simpleGit(STAGING_DIR);
@@ -30,11 +31,15 @@ export async function gitCommitAndPush() {
30
31
  const status = await git.status();
31
32
  if (status.isClean()) {
32
33
  logger.info("No changes to commit");
33
- return;
34
+ return null;
34
35
  }
35
36
  const date = new Date().toISOString().split("T")[0];
36
37
  const message = `Automated backup: ${date}`;
37
38
  await git.commit(message);
38
39
  await git.push(["-u", "origin", "HEAD"]);
39
40
  logger.info(`Committed and pushed: ${message}`);
41
+ const sha = await git.revparse(["HEAD"]);
42
+ const remoteUrl = (await git.remote(["get-url", "origin"])) ?? "";
43
+ const commitUrl = getCommitUrl(remoteUrl, sha);
44
+ return { commitUrl };
40
45
  }
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,7 +2,7 @@ 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";
@@ -31,22 +31,48 @@ function listMachines() {
31
31
  .filter((e) => e.isDirectory() && e.name !== ".git")
32
32
  .map((e) => e.name);
33
33
  }
34
- export async function restore() {
34
+ async function resolveRepoAndMachine(repoUrl) {
35
+ if (!repoUrl) {
36
+ const config = loadConfig();
37
+ return { repository: config.repository, machine: config.machine };
38
+ }
39
+ const spinner = ora("Cloning backup repository").start();
40
+ await gitPull(repoUrl);
41
+ spinner.stop();
42
+ const machines = listMachines();
43
+ if (machines.length === 0) {
44
+ throw new Error("The backup repository is empty (no machine directories found).");
45
+ }
46
+ let machine;
47
+ if (machines.length === 1) {
48
+ machine = machines[0];
49
+ }
50
+ else {
51
+ machine = await select({
52
+ message: "Multiple machines found. Which one do you want to restore?",
53
+ choices: machines.map((m) => ({ name: m, value: m })),
54
+ });
55
+ }
56
+ return { repository: repoUrl, machine };
57
+ }
58
+ export async function restore(repoUrl) {
35
59
  logger.info("Starting restore");
36
- const config = loadConfig();
37
- const baseDir = machineDir(config.machine);
60
+ const { repository, machine } = await resolveRepoAndMachine(repoUrl);
38
61
  const spinner = ora("Fetching latest backup").start();
39
- await gitPull(config.repository);
62
+ const baseDir = machineDir(machine);
63
+ if (!repoUrl) {
64
+ await gitPull(repository);
65
+ }
40
66
  spinner.text = "Resolving files";
41
67
  if (!fs.existsSync(baseDir)) {
42
68
  spinner.stop();
43
69
  const available = listMachines();
44
70
  if (available.length > 0) {
45
- console.log(`\n No backup found for machine "${config.machine}".`);
71
+ console.log(`\n No backup found for machine "${machine}".`);
46
72
  console.log(` Available machines: ${available.join(", ")}\n`);
47
73
  }
48
74
  else {
49
- console.log(`\n No backup found for machine "${config.machine}". The repository is empty.\n`);
75
+ console.log(`\n No backup found for machine "${machine}". The repository is empty.\n`);
50
76
  }
51
77
  return;
52
78
  }
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,86 @@ 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
+ export async function compareFiles(files, machine) {
42
+ const result = { backedUp: [], modified: [], notBackedUp: [] };
43
+ if (files.length === 0)
44
+ return result;
45
+ const gitDir = path.join(STAGING_DIR, ".git");
46
+ if (!fs.existsSync(gitDir)) {
47
+ result.notBackedUp.push(...files);
48
+ return result;
49
+ }
50
+ const git = simpleGit(STAGING_DIR);
51
+ await git.fetch("origin");
52
+ let branch;
53
+ try {
54
+ branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
55
+ }
56
+ catch {
57
+ result.notBackedUp.push(...files);
58
+ return result;
59
+ }
60
+ const committedHashes = new Map();
61
+ try {
62
+ const treeOutput = execFileSync("git", ["ls-tree", "-r", `origin/${branch}`, `${machine}/`], {
63
+ encoding: "utf-8",
64
+ cwd: STAGING_DIR,
65
+ });
66
+ for (const line of treeOutput.split("\n")) {
67
+ if (!line)
68
+ continue;
69
+ const match = line.match(/^\d+ blob ([0-9a-f]+)\t(.+)$/);
70
+ if (match) {
71
+ committedHashes.set(match[2], match[1]);
72
+ }
73
+ }
74
+ }
75
+ catch {
76
+ result.notBackedUp.push(...files);
77
+ return result;
78
+ }
79
+ let sourceHashes;
80
+ try {
81
+ const hashOutput = execFileSync("git", ["hash-object", "--", ...files], {
82
+ encoding: "utf-8",
83
+ });
84
+ sourceHashes = hashOutput.trim().split("\n");
85
+ }
86
+ catch {
87
+ result.notBackedUp.push(...files);
88
+ return result;
89
+ }
90
+ for (let i = 0; i < files.length; i++) {
91
+ const repoRelPath = path.relative(STAGING_DIR, getStagedPath(files[i], machine));
92
+ const committedHash = committedHashes.get(repoRelPath);
93
+ const sourceHash = sourceHashes[i];
94
+ if (!committedHash) {
95
+ result.notBackedUp.push(files[i]);
96
+ }
97
+ else if (committedHash === sourceHash) {
98
+ result.backedUp.push(files[i]);
99
+ }
100
+ else {
101
+ result.modified.push(files[i]);
102
+ }
103
+ }
104
+ return result;
105
+ }
106
+ function repoReadme(repository) {
107
+ return `# Backdot Backup
37
108
 
38
109
  This repository contains dotfiles backed up automatically using [backdot](https://github.com/sorenlouv/backdot).
39
110
 
40
- ## Quick start
41
-
42
- Install backdot:
111
+ ## Restore
43
112
 
44
113
  \`\`\`bash
45
- npm install -g backdot
46
- \`\`\`
47
-
48
- Restore files from this backup:
49
-
50
- \`\`\`bash
51
- backdot --restore
114
+ npx backdot --restore ${repository}
52
115
  \`\`\`
53
116
 
54
117
  For full documentation, configuration options, and scheduling, see the [official README](https://github.com/sorenlouv/backdot).
55
118
  `;
56
- export function writeRepoReadme() {
57
- fs.writeFileSync(path.join(STAGING_DIR, "README.md"), REPO_README);
119
+ }
120
+ export function writeRepoReadme(repository) {
121
+ fs.writeFileSync(path.join(STAGING_DIR, "README.md"), repoReadme(repository));
58
122
  logger.info("Wrote README.md to staging directory");
59
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backdot",
3
- "version": "1.1.0",
3
+ "version": "1.2.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,6 +21,8 @@
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",
@@ -31,6 +33,7 @@
31
33
  },
32
34
  "dependencies": {
33
35
  "@inquirer/prompts": "^8.3.0",
36
+ "chalk": "^5.6.2",
34
37
  "fast-glob": "^3.3.3",
35
38
  "ora": "^9.3.0",
36
39
  "simple-git": "^3.32.2",