backdot 1.0.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,89 +1,50 @@
1
- # backdot
1
+ <p align="center">
2
+ <img src="logo-small.png" alt="backdot" width="400" />
3
+ </p>
2
4
 
3
- Lightweight CLI to back up dotfiles and gitignored files to a private Git repo, with optional daily scheduling via macOS launchd.
5
+ <h1 align="center">backdot</h1>
4
6
 
5
- ## How it works
7
+ <p align="center">Automated backup of important files (configs, dotfiles, gitignored files) to your own private Git repo.</p>
6
8
 
7
- 1. Reads `~/.backdot.json` for the target repository and file entries
8
- 2. Resolves file entries — either gitignored files in a directory or glob patterns
9
- 3. Copies resolved files to `~/.backdot/repo/`, preserving directory structure relative to `~`
10
- 4. Commits and pushes changes to the configured remote
11
-
12
- ## Installation
9
+ ## Getting started
13
10
 
14
11
  ```bash
15
12
  npm install -g backdot
16
13
  ```
17
14
 
18
- Requires Node.js and `git` available on your PATH.
19
-
20
- ## Configuration
21
-
22
- Create `~/.backdot.json`:
15
+ Create `~/.backdot.json` pointing to a private repo and the files you want backed up:
23
16
 
24
17
  ```json
25
18
  {
26
19
  "repository": "git@github.com:USERNAME/dotfiles-backup.git",
20
+ "machine": "my-work-laptop",
27
21
  "files.gitignored": ["~/my-project"],
28
- "files.match": ["~/.zshrc", "~/.config/ghostty/**"]
22
+ "files.match": ["~/.zshrc", "~/.oh-my-zsh/custom/*.zsh", "~/.ssh/config/config", "~/.npmrc"]
29
23
  }
30
24
  ```
31
25
 
32
- ### File entry types
33
-
34
- | Key | Description |
35
- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
36
- | `files.gitignored` | Array of directories. Backs up all gitignored files in each directory (runs `git ls-files --others --ignored --exclude-standard`) |
37
- | `files.match` | Array of glob patterns. Backs up all matching files (powered by [fast-glob](https://github.com/mrmlnc/fast-glob)) |
38
-
39
- Both keys are optional, but at least one must be present and non-empty. Paths support `~` expansion.
40
-
41
- ## Usage
42
-
43
- ### Run a backup
26
+ Run your first backup:
44
27
 
45
28
  ```bash
46
29
  backdot --backup
47
30
  ```
48
31
 
49
- ### Restore from backup
50
-
51
- ```bash
52
- backdot --restore
53
- ```
54
-
55
- Pulls the latest state from the remote backup repo and copies files back to their original locations. If any files already exist, you'll be prompted to select which ones to overwrite.
56
-
57
- ### Schedule daily backups (macOS)
58
-
59
- ```bash
60
- backdot --schedule
61
- ```
62
-
63
- Installs a launchd job (`com.backdot.daemon`) that runs the backup daily at 02:00.
64
-
65
- ### Remove the schedule
66
-
67
- ```bash
68
- backdot --unschedule
69
- ```
70
-
71
- ### Check status
72
-
73
- ```bash
74
- backdot --status
75
- ```
32
+ ## Configuration
76
33
 
77
- Shows whether the daily schedule is active and lists all files that would be backed up. Useful for verifying your `~/.backdot.json` is correct.
34
+ | Key | Description |
35
+ | ------------------ | ------------------------------------------------------ |
36
+ | `files.gitignored` | Directories to scan for gitignored files |
37
+ | `files.match` | Glob patterns matching individual files or directories |
78
38
 
79
- ## File locations
39
+ ## Commands
80
40
 
81
- | Path | Purpose |
82
- | ------------------------------------------------- | -------------------------------------- |
83
- | `~/.backdot.json` | Configuration file |
84
- | `~/.backdot/repo/` | Local staging directory (the git repo) |
85
- | `~/.backdot/backup.log` | Backup log |
86
- | `~/Library/LaunchAgents/com.backdot.daemon.plist` | launchd job (when using `--schedule`) |
41
+ | Command | Description |
42
+ | -------------- | ------------------------------------------------------ |
43
+ | `--backup` | Run a backup now |
44
+ | `--restore` | Restore files to their original locations |
45
+ | `--schedule` | Schedule automatic daily backup via launchd (Mac-only) |
46
+ | `--unschedule` | Remove the daily schedule |
47
+ | `--status` | Show schedule and resolved file list |
87
48
 
88
49
  ## Development
89
50
 
package/dist/cli.js CHANGED
@@ -1,50 +1,110 @@
1
1
  #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import chalk from "chalk";
2
6
  import ora from "ora";
3
- import { loadConfig } from "./config.js";
7
+ import { loadConfig, CONFIG_PATH } from "./config.js";
4
8
  import { resolveFiles } from "./resolve.js";
5
- import { copyToStaging } from "./staging.js";
6
- import { gitSync } from "./git.js";
9
+ import { cleanStaging, copyToStaging, writeRepoReadme, compareFiles } from "./staging.js";
10
+ import { gitPull, gitCommitAndPush } from "./git.js";
7
11
  import { restore } from "./restore.js";
8
12
  import { setupLaunchd, uninstallLaunchd, isScheduled } from "./plist.js";
9
13
  import { logger } from "./log.js";
14
+ function getVersion() {
15
+ const pkgPath = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../package.json");
16
+ try {
17
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
18
+ return pkg.version ?? "unknown";
19
+ }
20
+ catch {
21
+ return "unknown";
22
+ }
23
+ }
10
24
  async function backup() {
11
25
  logger.info("Starting backup");
12
26
  const config = loadConfig();
13
27
  logger.info(`Repository: ${config.repository}`);
28
+ logger.info(`Machine: ${config.machine}`);
14
29
  const spinner = ora("Resolving files").start();
15
- const files = resolveFiles(config.files);
16
- logger.info(`Resolved ${files.length} file(s)`);
17
- if (files.length === 0) {
18
- spinner.info("No files resolved, nothing to back up");
19
- return;
30
+ try {
31
+ const userFiles = resolveFiles(config.files);
32
+ logger.info(`Resolved ${userFiles.length} file(s)`);
33
+ if (userFiles.length === 0) {
34
+ spinner.info("No files resolved, nothing to back up");
35
+ return;
36
+ }
37
+ const files = [...userFiles, CONFIG_PATH];
38
+ spinner.text = "Syncing with remote";
39
+ await gitPull(config.repository);
40
+ spinner.text = `Copying ${files.length} file(s) to staging`;
41
+ cleanStaging(config.machine);
42
+ copyToStaging(files, config.machine);
43
+ writeRepoReadme(config.repository);
44
+ spinner.text = "Pushing to remote";
45
+ const result = await gitCommitAndPush();
46
+ const successMsg = result?.commitUrl
47
+ ? `Backup complete → ${result.commitUrl}`
48
+ : "Backup complete";
49
+ spinner.succeed(successMsg);
50
+ console.log();
51
+ }
52
+ catch (err) {
53
+ spinner.fail("Backup failed");
54
+ throw err;
20
55
  }
21
- spinner.text = `Copying ${files.length} file(s) to staging`;
22
- copyToStaging(files);
23
- spinner.text = "Pushing to remote";
24
- await gitSync(config.repository);
25
- spinner.succeed("Backup complete");
26
- console.log();
27
56
  logger.info("Backup complete");
28
57
  }
29
- 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() {
30
63
  const scheduled = isScheduled();
31
64
  console.log();
32
65
  console.log(` Schedule: ${scheduled ? "active (daily at 02:00)" : "not active"}`);
33
66
  try {
34
67
  const config = loadConfig();
35
68
  console.log(` Repo: ${config.repository}`);
69
+ console.log(` Machine: ${config.machine}`);
36
70
  console.log();
37
71
  const spinner = ora("Resolving files").start();
38
- const files = resolveFiles(config.files);
39
- if (files.length === 0) {
72
+ const userFiles = resolveFiles(config.files);
73
+ if (userFiles.length === 0) {
40
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 ✓`));
41
84
  }
42
85
  else {
43
- spinner.succeed(`${files.length} file(s) resolved`);
44
- console.log();
45
- for (const file of files) {
46
- 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();
47
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();
106
+ }
107
+ console.log(` Run ${chalk.bold("backdot --backup")} to back up all changes.`);
48
108
  }
49
109
  }
50
110
  catch (err) {
@@ -54,6 +114,11 @@ function status() {
54
114
  }
55
115
  console.log();
56
116
  }
117
+ function requireMacOS() {
118
+ if (process.platform !== "darwin") {
119
+ throw new Error("Scheduling is only supported on macOS (launchd). Use cron or systemd on Linux.");
120
+ }
121
+ }
57
122
  async function main() {
58
123
  const args = process.argv.slice(2);
59
124
  const command = args[0];
@@ -62,16 +127,21 @@ async function main() {
62
127
  await backup();
63
128
  }
64
129
  else if (command === "--schedule") {
130
+ requireMacOS();
65
131
  setupLaunchd();
66
132
  }
67
133
  else if (command === "--unschedule") {
134
+ requireMacOS();
68
135
  uninstallLaunchd();
69
136
  }
70
137
  else if (command === "--status") {
71
- status();
138
+ await status();
72
139
  }
73
140
  else if (command === "--restore") {
74
- await restore();
141
+ await restore(args[1]);
142
+ }
143
+ else if (command === "--version") {
144
+ console.log(getVersion());
75
145
  }
76
146
  else {
77
147
  console.log();
@@ -80,10 +150,11 @@ async function main() {
80
150
  console.log(" Commands:");
81
151
  console.log();
82
152
  console.log(" --backup Run a backup now");
83
- console.log(" --restore Restore files from the backup repo");
153
+ console.log(" --restore [url] Restore files from the backup repo");
84
154
  console.log(" --schedule Install daily backup schedule (macOS launchd)");
85
155
  console.log(" --unschedule Remove the daily backup schedule");
86
156
  console.log(" --status Show schedule and resolved files");
157
+ console.log(" --version Show version");
87
158
  console.log();
88
159
  if (command && command !== "--help") {
89
160
  console.error(` Unknown command: ${command}`);
@@ -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,17 +1,21 @@
1
1
  import { z } from "zod";
2
+ export declare const CONFIG_PATH: string;
2
3
  export declare function expandTilde(p: string): string;
3
4
  declare const ConfigSchema: z.ZodPipe<z.ZodObject<{
4
5
  repository: z.ZodString;
6
+ machine: z.ZodString;
5
7
  "files.gitignored": z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>>;
6
8
  "files.match": z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>>;
7
9
  }, z.core.$strip>, z.ZodTransform<{
8
10
  repository: string;
11
+ machine: string;
9
12
  files: {
10
13
  gitignored: string[];
11
14
  match: string[];
12
15
  };
13
16
  }, {
14
17
  repository: string;
18
+ machine: string;
15
19
  "files.gitignored": string[];
16
20
  "files.match": string[];
17
21
  }>>;
package/dist/config.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 { z } from "zod";
5
- const CONFIG_PATH = path.join(os.homedir(), ".backdot.json");
5
+ export const CONFIG_PATH = path.join(os.homedir(), ".backdot.json");
6
6
  export function expandTilde(p) {
7
7
  if (p.startsWith("~/") || p === "~") {
8
8
  return path.join(os.homedir(), p.slice(1));
@@ -13,6 +13,7 @@ const pathList = z.array(z.string().min(1).transform(expandTilde)).optional().de
13
13
  const ConfigSchema = z
14
14
  .object({
15
15
  repository: z.string().min(1),
16
+ machine: z.string().min(1),
16
17
  "files.gitignored": pathList,
17
18
  "files.match": pathList,
18
19
  })
@@ -21,6 +22,7 @@ const ConfigSchema = z
21
22
  })
22
23
  .transform((c) => ({
23
24
  repository: c.repository,
25
+ machine: c.machine,
24
26
  files: {
25
27
  gitignored: c["files.gitignored"],
26
28
  match: c["files.match"],
@@ -44,5 +46,13 @@ export function loadConfig() {
44
46
  catch {
45
47
  throw new Error(`Invalid JSON in config file: ${CONFIG_PATH}`);
46
48
  }
47
- return ConfigSchema.parse(parsed);
49
+ const result = ConfigSchema.safeParse(parsed);
50
+ if (!result.success) {
51
+ const messages = result.error.issues.map((i) => {
52
+ const path = i.path.length > 0 ? `"${i.path.join(".")}"` : "config";
53
+ return ` - ${path}: ${i.message}`;
54
+ });
55
+ throw new Error(`Invalid config in ${CONFIG_PATH}:\n${messages.join("\n")}`);
56
+ }
57
+ return result.data;
48
58
  }
package/dist/git.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export declare function gitPull(repository: string): Promise<void>;
2
- export declare function gitSync(repository: string): 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);
@@ -12,37 +13,33 @@ export async function gitPull(repository) {
12
13
  await git.clean(CleanOptions.FORCE, ["-d"]);
13
14
  }
14
15
  else {
15
- await simpleGit().clone(repository, STAGING_DIR);
16
+ try {
17
+ await simpleGit().clone(repository, STAGING_DIR);
18
+ }
19
+ catch {
20
+ fs.mkdirSync(STAGING_DIR, { recursive: true });
21
+ const git = simpleGit(STAGING_DIR);
22
+ await git.init();
23
+ await git.addRemote("origin", repository);
24
+ }
16
25
  }
17
26
  logger.info("Synced staging directory from remote");
18
27
  }
19
- export async function gitSync(repository) {
20
- if (!fs.existsSync(STAGING_DIR)) {
21
- fs.mkdirSync(STAGING_DIR, { recursive: true });
22
- }
28
+ export async function gitCommitAndPush() {
23
29
  const git = simpleGit(STAGING_DIR);
24
- if (!fs.existsSync(path.join(STAGING_DIR, ".git"))) {
25
- logger.info("Initializing new git repository in staging directory");
26
- await git.init();
27
- await git.addRemote("origin", repository);
28
- }
29
30
  await git.add(".");
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
- logger.info(`Committed: ${message}`);
39
- try {
40
- await git.push(["-u", "origin", "HEAD"]);
41
- logger.info("Pushed to remote");
42
- }
43
- catch (err) {
44
- const msg = err instanceof Error ? err.message : String(err);
45
- logger.error(`Push failed: ${msg}`);
46
- throw new Error(`Push failed: ${msg}`);
47
- }
39
+ await git.push(["-u", "origin", "HEAD"]);
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 };
48
45
  }
package/dist/resolve.js CHANGED
@@ -32,6 +32,7 @@ function resolveGlob(pattern) {
32
32
  return [];
33
33
  }
34
34
  }
35
+ const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
35
36
  /**
36
37
  * Resolve all file entries to absolute paths.
37
38
  * Skips entries that fail resolution and logs warnings.
@@ -44,7 +45,8 @@ export function resolveFiles(files) {
44
45
  for (const pattern of files.match) {
45
46
  allFiles.push(...resolveGlob(pattern));
46
47
  }
47
- return allFiles.filter((filePath) => {
48
+ const unique = [...new Set(allFiles)];
49
+ return unique.filter((filePath) => {
48
50
  try {
49
51
  fs.accessSync(filePath, fs.constants.R_OK);
50
52
  const stat = fs.statSync(filePath);
@@ -52,6 +54,11 @@ export function resolveFiles(files) {
52
54
  logger.warn(`Not a regular file, skipping: ${filePath}`);
53
55
  return false;
54
56
  }
57
+ if (stat.size > LARGE_FILE_THRESHOLD) {
58
+ const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
59
+ logger.warn(`Large file (${sizeMB} MB), skipping: ${filePath}`);
60
+ return false;
61
+ }
55
62
  return true;
56
63
  }
57
64
  catch {
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,10 +2,10 @@ 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
- import { STAGING_DIR } from "./staging.js";
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) {
@@ -23,13 +23,60 @@ function walkDir(dir) {
23
23
  }
24
24
  return results;
25
25
  }
26
- export async function restore() {
26
+ function listMachines() {
27
+ if (!fs.existsSync(STAGING_DIR))
28
+ return [];
29
+ return fs
30
+ .readdirSync(STAGING_DIR, { withFileTypes: true })
31
+ .filter((e) => e.isDirectory() && e.name !== ".git")
32
+ .map((e) => e.name);
33
+ }
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) {
27
59
  logger.info("Starting restore");
28
- const config = loadConfig();
60
+ const { repository, machine } = await resolveRepoAndMachine(repoUrl);
29
61
  const spinner = ora("Fetching latest backup").start();
30
- await gitPull(config.repository);
62
+ const baseDir = machineDir(machine);
63
+ if (!repoUrl) {
64
+ await gitPull(repository);
65
+ }
31
66
  spinner.text = "Resolving files";
32
- const stagedFiles = walkDir(STAGING_DIR);
67
+ if (!fs.existsSync(baseDir)) {
68
+ spinner.stop();
69
+ const available = listMachines();
70
+ if (available.length > 0) {
71
+ console.log(`\n No backup found for machine "${machine}".`);
72
+ console.log(` Available machines: ${available.join(", ")}\n`);
73
+ }
74
+ else {
75
+ console.log(`\n No backup found for machine "${machine}". The repository is empty.\n`);
76
+ }
77
+ return;
78
+ }
79
+ const stagedFiles = walkDir(baseDir);
33
80
  logger.info(`Found ${stagedFiles.length} file(s) in backup repository`);
34
81
  if (stagedFiles.length === 0) {
35
82
  spinner.stop();
@@ -37,7 +84,7 @@ export async function restore() {
37
84
  return;
38
85
  }
39
86
  const fileMappings = stagedFiles.map((src) => {
40
- const rel = path.relative(STAGING_DIR, src);
87
+ const rel = path.relative(baseDir, src);
41
88
  return { src, dest: path.join(HOME, rel), rel };
42
89
  });
43
90
  const existing = fileMappings.filter((f) => fs.existsSync(f.dest));
package/dist/staging.d.ts CHANGED
@@ -1,2 +1,12 @@
1
1
  export declare const STAGING_DIR: string;
2
- export declare function copyToStaging(files: string[]): void;
2
+ export declare function machineDir(machine: string): string;
3
+ export declare function getStagedPath(filePath: string, machine: string): string;
4
+ export declare function cleanStaging(machine: string): void;
5
+ export declare function copyToStaging(files: string[], machine: string): 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,18 +1,32 @@
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
- export function copyToStaging(files) {
8
- if (!fs.existsSync(STAGING_DIR)) {
9
- fs.mkdirSync(STAGING_DIR, { recursive: true });
10
- }
9
+ export function machineDir(machine) {
10
+ return path.join(STAGING_DIR, machine);
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
+ }
17
+ export function cleanStaging(machine) {
18
+ const dir = machineDir(machine);
19
+ if (!fs.existsSync(dir))
20
+ return;
21
+ fs.rmSync(dir, { recursive: true, force: true });
22
+ logger.info(`Cleaned staging directory for machine "${machine}"`);
23
+ }
24
+ export function copyToStaging(files, machine) {
25
+ const dir = machineDir(machine);
26
+ fs.mkdirSync(dir, { recursive: true });
11
27
  let copied = 0;
12
28
  for (const filePath of files) {
13
- const rel = path.relative(HOME, filePath);
14
- const destRel = rel.startsWith("..") ? filePath.slice(1) : rel;
15
- const dest = path.join(STAGING_DIR, destRel);
29
+ const dest = getStagedPath(filePath, machine);
16
30
  try {
17
31
  fs.mkdirSync(path.dirname(dest), { recursive: true });
18
32
  fs.copyFileSync(filePath, dest);
@@ -24,3 +38,86 @@ export function copyToStaging(files) {
24
38
  }
25
39
  logger.info(`Copied ${copied} file(s) to staging`);
26
40
  }
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
108
+
109
+ This repository contains dotfiles backed up automatically using [backdot](https://github.com/sorenlouv/backdot).
110
+
111
+ ## Restore
112
+
113
+ \`\`\`bash
114
+ npx backdot --restore ${repository}
115
+ \`\`\`
116
+
117
+ For full documentation, configuration options, and scheduling, see the [official README](https://github.com/sorenlouv/backdot).
118
+ `;
119
+ }
120
+ export function writeRepoReadme(repository) {
121
+ fs.writeFileSync(path.join(STAGING_DIR, "README.md"), repoReadme(repository));
122
+ logger.info("Wrote README.md to staging directory");
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backdot",
3
- "version": "1.0.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",