backdot 1.0.0 → 1.1.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,46 @@
1
1
  # backdot
2
2
 
3
- Lightweight CLI to back up dotfiles and gitignored files to a private Git repo, with optional daily scheduling via macOS launchd.
3
+ Back up dotfiles and gitignored files to a private Git repo on demand or on a daily schedule.
4
4
 
5
- ## How it works
6
-
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
5
+ ## Getting started
13
6
 
14
7
  ```bash
15
8
  npm install -g backdot
16
9
  ```
17
10
 
18
- Requires Node.js and `git` available on your PATH.
19
-
20
- ## Configuration
21
-
22
- Create `~/.backdot.json`:
11
+ Create `~/.backdot.json` pointing to a private repo and the files you want backed up:
23
12
 
24
13
  ```json
25
14
  {
26
15
  "repository": "git@github.com:USERNAME/dotfiles-backup.git",
16
+ "machine": "my-work-laptop",
27
17
  "files.gitignored": ["~/my-project"],
28
- "files.match": ["~/.zshrc", "~/.config/ghostty/**"]
18
+ "files.match": ["~/.zshrc", "~/.oh-my-zsh/custom/*.zsh", "~/.ssh/config/config", "~/.npmrc"]
29
19
  }
30
20
  ```
31
21
 
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
22
+ Run your first backup:
44
23
 
45
24
  ```bash
46
25
  backdot --backup
47
26
  ```
48
27
 
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
- ```
28
+ ## Configuration
76
29
 
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.
30
+ | Key | Description |
31
+ | ------------------ | ------------------------------------------------------ |
32
+ | `files.gitignored` | Directories to scan for gitignored files |
33
+ | `files.match` | Glob patterns matching individual files or directories |
78
34
 
79
- ## File locations
35
+ ## Commands
80
36
 
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`) |
37
+ | Command | Description |
38
+ | -------------- | ------------------------------------------------------ |
39
+ | `--backup` | Run a backup now |
40
+ | `--restore` | Restore files to their original locations |
41
+ | `--schedule` | Schedule automatic daily backup via launchd (Mac-only) |
42
+ | `--unschedule` | Remove the daily schedule |
43
+ | `--status` | Show schedule and resolved file list |
87
44
 
88
45
  ## Development
89
46
 
package/dist/cli.js CHANGED
@@ -1,29 +1,53 @@
1
1
  #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
2
4
  import ora from "ora";
3
- import { loadConfig } from "./config.js";
5
+ import { loadConfig, CONFIG_PATH } from "./config.js";
4
6
  import { resolveFiles } from "./resolve.js";
5
- import { copyToStaging } from "./staging.js";
6
- import { gitSync } from "./git.js";
7
+ import { cleanStaging, copyToStaging, writeRepoReadme } from "./staging.js";
8
+ import { gitPull, gitCommitAndPush } from "./git.js";
7
9
  import { restore } from "./restore.js";
8
10
  import { setupLaunchd, uninstallLaunchd, isScheduled } from "./plist.js";
9
11
  import { logger } from "./log.js";
12
+ function getVersion() {
13
+ const pkgPath = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../package.json");
14
+ try {
15
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
16
+ return pkg.version ?? "unknown";
17
+ }
18
+ catch {
19
+ return "unknown";
20
+ }
21
+ }
10
22
  async function backup() {
11
23
  logger.info("Starting backup");
12
24
  const config = loadConfig();
13
25
  logger.info(`Repository: ${config.repository}`);
26
+ logger.info(`Machine: ${config.machine}`);
14
27
  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;
28
+ try {
29
+ const userFiles = resolveFiles(config.files);
30
+ logger.info(`Resolved ${userFiles.length} file(s)`);
31
+ if (userFiles.length === 0) {
32
+ spinner.info("No files resolved, nothing to back up");
33
+ return;
34
+ }
35
+ const files = [...userFiles, CONFIG_PATH];
36
+ spinner.text = "Syncing with remote";
37
+ await gitPull(config.repository);
38
+ spinner.text = `Copying ${files.length} file(s) to staging`;
39
+ cleanStaging(config.machine);
40
+ copyToStaging(files, config.machine);
41
+ writeRepoReadme();
42
+ spinner.text = "Pushing to remote";
43
+ await gitCommitAndPush();
44
+ spinner.succeed("Backup complete");
45
+ console.log();
46
+ }
47
+ catch (err) {
48
+ spinner.fail("Backup failed");
49
+ throw err;
20
50
  }
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
51
  logger.info("Backup complete");
28
52
  }
29
53
  function status() {
@@ -33,14 +57,17 @@ function status() {
33
57
  try {
34
58
  const config = loadConfig();
35
59
  console.log(` Repo: ${config.repository}`);
60
+ console.log(` Machine: ${config.machine}`);
36
61
  console.log();
37
62
  const spinner = ora("Resolving files").start();
38
- const files = resolveFiles(config.files);
39
- if (files.length === 0) {
63
+ const userFiles = resolveFiles(config.files);
64
+ if (userFiles.length === 0) {
40
65
  spinner.warn("No files resolved. Check your ~/.backdot.json entries.");
41
66
  }
42
67
  else {
43
- spinner.succeed(`${files.length} file(s) resolved`);
68
+ const files = [...userFiles, CONFIG_PATH];
69
+ spinner.stop();
70
+ console.log(`${files.length} file(s) resolved:`);
44
71
  console.log();
45
72
  for (const file of files) {
46
73
  console.log(` ${file}`);
@@ -54,6 +81,11 @@ function status() {
54
81
  }
55
82
  console.log();
56
83
  }
84
+ function requireMacOS() {
85
+ if (process.platform !== "darwin") {
86
+ throw new Error("Scheduling is only supported on macOS (launchd). Use cron or systemd on Linux.");
87
+ }
88
+ }
57
89
  async function main() {
58
90
  const args = process.argv.slice(2);
59
91
  const command = args[0];
@@ -62,9 +94,11 @@ async function main() {
62
94
  await backup();
63
95
  }
64
96
  else if (command === "--schedule") {
97
+ requireMacOS();
65
98
  setupLaunchd();
66
99
  }
67
100
  else if (command === "--unschedule") {
101
+ requireMacOS();
68
102
  uninstallLaunchd();
69
103
  }
70
104
  else if (command === "--status") {
@@ -73,6 +107,9 @@ async function main() {
73
107
  else if (command === "--restore") {
74
108
  await restore();
75
109
  }
110
+ else if (command === "--version") {
111
+ console.log(getVersion());
112
+ }
76
113
  else {
77
114
  console.log();
78
115
  console.log(" Usage: backdot <command>");
@@ -84,6 +121,7 @@ async function main() {
84
121
  console.log(" --schedule Install daily backup schedule (macOS launchd)");
85
122
  console.log(" --unschedule Remove the daily backup schedule");
86
123
  console.log(" --status Show schedule and resolved files");
124
+ console.log(" --version Show version");
87
125
  console.log();
88
126
  if (command && command !== "--help") {
89
127
  console.error(` Unknown command: ${command}`);
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,2 @@
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<void>;
package/dist/git.js CHANGED
@@ -12,20 +12,20 @@ export async function gitPull(repository) {
12
12
  await git.clean(CleanOptions.FORCE, ["-d"]);
13
13
  }
14
14
  else {
15
- await simpleGit().clone(repository, STAGING_DIR);
15
+ try {
16
+ await simpleGit().clone(repository, STAGING_DIR);
17
+ }
18
+ catch {
19
+ fs.mkdirSync(STAGING_DIR, { recursive: true });
20
+ const git = simpleGit(STAGING_DIR);
21
+ await git.init();
22
+ await git.addRemote("origin", repository);
23
+ }
16
24
  }
17
25
  logger.info("Synced staging directory from remote");
18
26
  }
19
- export async function gitSync(repository) {
20
- if (!fs.existsSync(STAGING_DIR)) {
21
- fs.mkdirSync(STAGING_DIR, { recursive: true });
22
- }
27
+ export async function gitCommitAndPush() {
23
28
  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
29
  await git.add(".");
30
30
  const status = await git.status();
31
31
  if (status.isClean()) {
@@ -35,14 +35,6 @@ export async function gitSync(repository) {
35
35
  const date = new Date().toISOString().split("T")[0];
36
36
  const message = `Automated backup: ${date}`;
37
37
  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
- }
38
+ await git.push(["-u", "origin", "HEAD"]);
39
+ logger.info(`Committed and pushed: ${message}`);
48
40
  }
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.js CHANGED
@@ -5,7 +5,7 @@ import ora from "ora";
5
5
  import { checkbox } 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,34 @@ function walkDir(dir) {
23
23
  }
24
24
  return results;
25
25
  }
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
+ }
26
34
  export async function restore() {
27
35
  logger.info("Starting restore");
28
36
  const config = loadConfig();
37
+ const baseDir = machineDir(config.machine);
29
38
  const spinner = ora("Fetching latest backup").start();
30
39
  await gitPull(config.repository);
31
40
  spinner.text = "Resolving files";
32
- const stagedFiles = walkDir(STAGING_DIR);
41
+ if (!fs.existsSync(baseDir)) {
42
+ spinner.stop();
43
+ const available = listMachines();
44
+ if (available.length > 0) {
45
+ console.log(`\n No backup found for machine "${config.machine}".`);
46
+ console.log(` Available machines: ${available.join(", ")}\n`);
47
+ }
48
+ else {
49
+ console.log(`\n No backup found for machine "${config.machine}". The repository is empty.\n`);
50
+ }
51
+ return;
52
+ }
53
+ const stagedFiles = walkDir(baseDir);
33
54
  logger.info(`Found ${stagedFiles.length} file(s) in backup repository`);
34
55
  if (stagedFiles.length === 0) {
35
56
  spinner.stop();
@@ -37,7 +58,7 @@ export async function restore() {
37
58
  return;
38
59
  }
39
60
  const fileMappings = stagedFiles.map((src) => {
40
- const rel = path.relative(STAGING_DIR, src);
61
+ const rel = path.relative(baseDir, src);
41
62
  return { src, dest: path.join(HOME, rel), rel };
42
63
  });
43
64
  const existing = fileMappings.filter((f) => fs.existsSync(f.dest));
package/dist/staging.d.ts CHANGED
@@ -1,2 +1,5 @@
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 cleanStaging(machine: string): void;
4
+ export declare function copyToStaging(files: string[], machine: string): void;
5
+ export declare function writeRepoReadme(): void;
package/dist/staging.js CHANGED
@@ -4,15 +4,24 @@ import os from "node:os";
4
4
  import { logger } from "./log.js";
5
5
  const HOME = os.homedir();
6
6
  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
- }
7
+ export function machineDir(machine) {
8
+ return path.join(STAGING_DIR, machine);
9
+ }
10
+ export function cleanStaging(machine) {
11
+ const dir = machineDir(machine);
12
+ if (!fs.existsSync(dir))
13
+ return;
14
+ fs.rmSync(dir, { recursive: true, force: true });
15
+ logger.info(`Cleaned staging directory for machine "${machine}"`);
16
+ }
17
+ export function copyToStaging(files, machine) {
18
+ const dir = machineDir(machine);
19
+ fs.mkdirSync(dir, { recursive: true });
11
20
  let copied = 0;
12
21
  for (const filePath of files) {
13
22
  const rel = path.relative(HOME, filePath);
14
23
  const destRel = rel.startsWith("..") ? filePath.slice(1) : rel;
15
- const dest = path.join(STAGING_DIR, destRel);
24
+ const dest = path.join(dir, destRel);
16
25
  try {
17
26
  fs.mkdirSync(path.dirname(dest), { recursive: true });
18
27
  fs.copyFileSync(filePath, dest);
@@ -24,3 +33,27 @@ export function copyToStaging(files) {
24
33
  }
25
34
  logger.info(`Copied ${copied} file(s) to staging`);
26
35
  }
36
+ const REPO_README = `# Dotfiles Backup
37
+
38
+ This repository contains dotfiles backed up automatically using [backdot](https://github.com/sorenlouv/backdot).
39
+
40
+ ## Quick start
41
+
42
+ Install backdot:
43
+
44
+ \`\`\`bash
45
+ npm install -g backdot
46
+ \`\`\`
47
+
48
+ Restore files from this backup:
49
+
50
+ \`\`\`bash
51
+ backdot --restore
52
+ \`\`\`
53
+
54
+ For full documentation, configuration options, and scheduling, see the [official README](https://github.com/sorenlouv/backdot).
55
+ `;
56
+ export function writeRepoReadme() {
57
+ fs.writeFileSync(path.join(STAGING_DIR, "README.md"), REPO_README);
58
+ logger.info("Wrote README.md to staging directory");
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backdot",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",