backdot 1.2.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
@@ -10,16 +10,17 @@
10
10
 
11
11
  ```bash
12
12
  npm install -g backdot
13
+ backdot --init
13
14
  ```
14
15
 
15
- 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:
16
17
 
17
18
  ```json
18
19
  {
19
- "repository": "git@github.com:USERNAME/dotfiles-backup.git",
20
+ "repository": "git@github.com:USERNAME/backdot-backup.git",
20
21
  "machine": "my-work-laptop",
21
- "files.gitignored": ["~/my-project"],
22
- "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"]
23
24
  }
24
25
  ```
25
26
 
@@ -31,15 +32,16 @@ backdot --backup
31
32
 
32
33
  ## Configuration
33
34
 
34
- | Key | Description |
35
- | ------------------ | ------------------------------------------------------ |
36
- | `files.gitignored` | Directories to scan for gitignored files |
37
- | `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 |
38
39
 
39
40
  ## Commands
40
41
 
41
42
  | Command | Description |
42
43
  | -------------- | ------------------------------------------------------ |
44
+ | `--init` | Set up backdot for the first time |
43
45
  | `--backup` | Run a backup now |
44
46
  | `--restore` | Restore files to their original locations |
45
47
  | `--schedule` | Schedule automatic daily backup via launchd (Mac-only) |
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { resolveFiles } from "./resolve.js";
9
9
  import { cleanStaging, copyToStaging, writeRepoReadme, compareFiles } from "./staging.js";
10
10
  import { gitPull, gitCommitAndPush } from "./git.js";
11
11
  import { restore } from "./restore.js";
12
+ import { init } from "./init.js";
12
13
  import { setupLaunchd, uninstallLaunchd, isScheduled } from "./plist.js";
13
14
  import { logger } from "./log.js";
14
15
  function getVersion() {
@@ -28,7 +29,7 @@ async function backup() {
28
29
  logger.info(`Machine: ${config.machine}`);
29
30
  const spinner = ora("Resolving files").start();
30
31
  try {
31
- const userFiles = resolveFiles(config.files);
32
+ const userFiles = resolveFiles(config);
32
33
  logger.info(`Resolved ${userFiles.length} file(s)`);
33
34
  if (userFiles.length === 0) {
34
35
  spinner.info("No files resolved, nothing to back up");
@@ -69,7 +70,7 @@ async function status() {
69
70
  console.log(` Machine: ${config.machine}`);
70
71
  console.log();
71
72
  const spinner = ora("Resolving files").start();
72
- const userFiles = resolveFiles(config.files);
73
+ const userFiles = resolveFiles(config);
73
74
  if (userFiles.length === 0) {
74
75
  spinner.warn("No files resolved. Check your ~/.backdot.json entries.");
75
76
  console.log();
@@ -123,7 +124,10 @@ async function main() {
123
124
  const args = process.argv.slice(2);
124
125
  const command = args[0];
125
126
  try {
126
- if (command === "--backup") {
127
+ if (command === "--init") {
128
+ init();
129
+ }
130
+ else if (command === "--backup") {
127
131
  await backup();
128
132
  }
129
133
  else if (command === "--schedule") {
@@ -149,6 +153,7 @@ async function main() {
149
153
  console.log();
150
154
  console.log(" Commands:");
151
155
  console.log();
156
+ console.log(" --init Set up backdot for the first time");
152
157
  console.log(" --backup Run a backup now");
153
158
  console.log(" --restore [url] Restore files from the backup repo");
154
159
  console.log(" --schedule Install daily backup schedule (macOS launchd)");
@@ -161,6 +166,10 @@ async function main() {
161
166
  console.log();
162
167
  process.exit(1);
163
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
+ }
164
173
  }
165
174
  }
166
175
  catch (err) {
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.js CHANGED
@@ -1,5 +1,6 @@
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";
@@ -36,7 +37,15 @@ export async function gitCommitAndPush() {
36
37
  const date = new Date().toISOString().split("T")[0];
37
38
  const message = `Automated backup: ${date}`;
38
39
  await git.commit(message);
39
- 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
+ });
40
49
  logger.info(`Committed and pushed: ${message}`);
41
50
  const sha = await git.revparse(["HEAD"]);
42
51
  const remoteUrl = (await git.remote(["get-url", "origin"])) ?? "";
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.js CHANGED
@@ -9,19 +9,13 @@ 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))
package/dist/staging.js CHANGED
@@ -38,15 +38,17 @@ export function copyToStaging(files, machine) {
38
38
  }
39
39
  logger.info(`Copied ${copied} file(s) to staging`);
40
40
  }
41
+ const allNotBackedUp = (files) => ({
42
+ backedUp: [],
43
+ modified: [],
44
+ notBackedUp: files,
45
+ });
41
46
  export async function compareFiles(files, machine) {
42
- const result = { backedUp: [], modified: [], notBackedUp: [] };
43
47
  if (files.length === 0)
44
- return result;
48
+ return { backedUp: [], modified: [], notBackedUp: [] };
45
49
  const gitDir = path.join(STAGING_DIR, ".git");
46
- if (!fs.existsSync(gitDir)) {
47
- result.notBackedUp.push(...files);
48
- return result;
49
- }
50
+ if (!fs.existsSync(gitDir))
51
+ return allNotBackedUp(files);
50
52
  const git = simpleGit(STAGING_DIR);
51
53
  await git.fetch("origin");
52
54
  let branch;
@@ -54,27 +56,22 @@ export async function compareFiles(files, machine) {
54
56
  branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
55
57
  }
56
58
  catch {
57
- result.notBackedUp.push(...files);
58
- return result;
59
+ return allNotBackedUp(files);
59
60
  }
60
- const committedHashes = new Map();
61
+ let committedHashes;
61
62
  try {
62
63
  const treeOutput = execFileSync("git", ["ls-tree", "-r", `origin/${branch}`, `${machine}/`], {
63
64
  encoding: "utf-8",
64
65
  cwd: STAGING_DIR,
65
66
  });
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
- }
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]]));
74
72
  }
75
73
  catch {
76
- result.notBackedUp.push(...files);
77
- return result;
74
+ return allNotBackedUp(files);
78
75
  }
79
76
  let sourceHashes;
80
77
  try {
@@ -84,24 +81,22 @@ export async function compareFiles(files, machine) {
84
81
  sourceHashes = hashOutput.trim().split("\n");
85
82
  }
86
83
  catch {
87
- result.notBackedUp.push(...files);
88
- return result;
84
+ return allNotBackedUp(files);
89
85
  }
90
- for (let i = 0; i < files.length; i++) {
91
- const repoRelPath = path.relative(STAGING_DIR, getStagedPath(files[i], machine));
86
+ return files.reduce((acc, file, i) => {
87
+ const repoRelPath = path.relative(STAGING_DIR, getStagedPath(file, machine));
92
88
  const committedHash = committedHashes.get(repoRelPath);
93
- const sourceHash = sourceHashes[i];
94
89
  if (!committedHash) {
95
- result.notBackedUp.push(files[i]);
90
+ acc.notBackedUp.push(file);
96
91
  }
97
- else if (committedHash === sourceHash) {
98
- result.backedUp.push(files[i]);
92
+ else if (committedHash === sourceHashes[i]) {
93
+ acc.backedUp.push(file);
99
94
  }
100
95
  else {
101
- result.modified.push(files[i]);
96
+ acc.modified.push(file);
102
97
  }
103
- }
104
- return result;
98
+ return acc;
99
+ }, { backedUp: [], modified: [], notBackedUp: [] });
105
100
  }
106
101
  function repoReadme(repository) {
107
102
  return `# Backdot Backup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backdot",
3
- "version": "1.2.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",
@@ -28,7 +28,8 @@
28
28
  "lint:fix": "eslint src/ --fix",
29
29
  "format": "prettier --write src/",
30
30
  "format:check": "prettier --check src/",
31
- "test": "vitest run",
31
+ "test": "vitest run --exclude src/e2e.test.ts",
32
+ "test:e2e": "npm run build && vitest run src/e2e.test.ts",
32
33
  "test:watch": "vitest"
33
34
  },
34
35
  "dependencies": {
@@ -36,6 +37,7 @@
36
37
  "chalk": "^5.6.2",
37
38
  "fast-glob": "^3.3.3",
38
39
  "ora": "^9.3.0",
40
+ "p-retry": "^7.1.1",
39
41
  "simple-git": "^3.32.2",
40
42
  "winston": "^3.19.0",
41
43
  "zod": "^4.3.6"