backdot 1.0.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 ADDED
@@ -0,0 +1,98 @@
1
+ # backdot
2
+
3
+ Lightweight CLI to back up dotfiles and gitignored files to a private Git repo, with optional daily scheduling via macOS launchd.
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
13
+
14
+ ```bash
15
+ npm install -g backdot
16
+ ```
17
+
18
+ Requires Node.js and `git` available on your PATH.
19
+
20
+ ## Configuration
21
+
22
+ Create `~/.backdot.json`:
23
+
24
+ ```json
25
+ {
26
+ "repository": "git@github.com:USERNAME/dotfiles-backup.git",
27
+ "files.gitignored": ["~/my-project"],
28
+ "files.match": ["~/.zshrc", "~/.config/ghostty/**"]
29
+ }
30
+ ```
31
+
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
44
+
45
+ ```bash
46
+ backdot --backup
47
+ ```
48
+
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
+ ```
76
+
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.
78
+
79
+ ## File locations
80
+
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`) |
87
+
88
+ ## Development
89
+
90
+ ```bash
91
+ npm install
92
+ npm run build
93
+ npm start
94
+ ```
95
+
96
+ ## License
97
+
98
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ import ora from "ora";
3
+ import { loadConfig } from "./config.js";
4
+ import { resolveFiles } from "./resolve.js";
5
+ import { copyToStaging } from "./staging.js";
6
+ import { gitSync } from "./git.js";
7
+ import { restore } from "./restore.js";
8
+ import { setupLaunchd, uninstallLaunchd, isScheduled } from "./plist.js";
9
+ import { logger } from "./log.js";
10
+ async function backup() {
11
+ logger.info("Starting backup");
12
+ const config = loadConfig();
13
+ logger.info(`Repository: ${config.repository}`);
14
+ 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;
20
+ }
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
+ logger.info("Backup complete");
28
+ }
29
+ function status() {
30
+ const scheduled = isScheduled();
31
+ console.log();
32
+ console.log(` Schedule: ${scheduled ? "active (daily at 02:00)" : "not active"}`);
33
+ try {
34
+ const config = loadConfig();
35
+ console.log(` Repo: ${config.repository}`);
36
+ console.log();
37
+ const spinner = ora("Resolving files").start();
38
+ const files = resolveFiles(config.files);
39
+ if (files.length === 0) {
40
+ spinner.warn("No files resolved. Check your ~/.backdot.json entries.");
41
+ }
42
+ else {
43
+ spinner.succeed(`${files.length} file(s) resolved`);
44
+ console.log();
45
+ for (const file of files) {
46
+ console.log(` ${file}`);
47
+ }
48
+ }
49
+ }
50
+ catch (err) {
51
+ const msg = err instanceof Error ? err.message : String(err);
52
+ console.error(`\n Config error: ${msg}`);
53
+ process.exit(1);
54
+ }
55
+ console.log();
56
+ }
57
+ async function main() {
58
+ const args = process.argv.slice(2);
59
+ const command = args[0];
60
+ try {
61
+ if (command === "--backup") {
62
+ await backup();
63
+ }
64
+ else if (command === "--schedule") {
65
+ setupLaunchd();
66
+ }
67
+ else if (command === "--unschedule") {
68
+ uninstallLaunchd();
69
+ }
70
+ else if (command === "--status") {
71
+ status();
72
+ }
73
+ else if (command === "--restore") {
74
+ await restore();
75
+ }
76
+ else {
77
+ console.log();
78
+ console.log(" Usage: backdot <command>");
79
+ console.log();
80
+ console.log(" Commands:");
81
+ console.log();
82
+ console.log(" --backup Run a backup now");
83
+ console.log(" --restore Restore files from the backup repo");
84
+ console.log(" --schedule Install daily backup schedule (macOS launchd)");
85
+ console.log(" --unschedule Remove the daily backup schedule");
86
+ console.log(" --status Show schedule and resolved files");
87
+ console.log();
88
+ if (command && command !== "--help") {
89
+ console.error(` Unknown command: ${command}`);
90
+ console.log();
91
+ process.exit(1);
92
+ }
93
+ }
94
+ }
95
+ catch (err) {
96
+ const msg = err instanceof Error ? err.message : String(err);
97
+ logger.error(msg);
98
+ console.error(`\n Error: ${msg}\n`);
99
+ process.exit(1);
100
+ }
101
+ }
102
+ main();
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ export declare function expandTilde(p: string): string;
3
+ declare const ConfigSchema: z.ZodPipe<z.ZodObject<{
4
+ repository: z.ZodString;
5
+ "files.gitignored": z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>>;
6
+ "files.match": z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>>;
7
+ }, z.core.$strip>, z.ZodTransform<{
8
+ repository: string;
9
+ files: {
10
+ gitignored: string[];
11
+ match: string[];
12
+ };
13
+ }, {
14
+ repository: string;
15
+ "files.gitignored": string[];
16
+ "files.match": string[];
17
+ }>>;
18
+ export type Config = z.infer<typeof ConfigSchema>;
19
+ export declare function loadConfig(): Config;
20
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,48 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { z } from "zod";
5
+ const CONFIG_PATH = path.join(os.homedir(), ".backdot.json");
6
+ export function expandTilde(p) {
7
+ if (p.startsWith("~/") || p === "~") {
8
+ return path.join(os.homedir(), p.slice(1));
9
+ }
10
+ return p;
11
+ }
12
+ const pathList = z.array(z.string().min(1).transform(expandTilde)).optional().default([]);
13
+ const ConfigSchema = z
14
+ .object({
15
+ repository: z.string().min(1),
16
+ "files.gitignored": pathList,
17
+ "files.match": pathList,
18
+ })
19
+ .refine((c) => c["files.gitignored"].length > 0 || c["files.match"].length > 0, {
20
+ message: 'At least one of "files.gitignored" or "files.match" must be a non-empty array',
21
+ })
22
+ .transform((c) => ({
23
+ repository: c.repository,
24
+ files: {
25
+ gitignored: c["files.gitignored"],
26
+ match: c["files.match"],
27
+ },
28
+ }));
29
+ export function loadConfig() {
30
+ if (!fs.existsSync(CONFIG_PATH)) {
31
+ throw new Error(`Config file not found: ${CONFIG_PATH}`);
32
+ }
33
+ let raw;
34
+ try {
35
+ raw = fs.readFileSync(CONFIG_PATH, "utf-8");
36
+ }
37
+ catch {
38
+ throw new Error(`Failed to read config file: ${CONFIG_PATH}`);
39
+ }
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(raw);
43
+ }
44
+ catch {
45
+ throw new Error(`Invalid JSON in config file: ${CONFIG_PATH}`);
46
+ }
47
+ return ConfigSchema.parse(parsed);
48
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare function gitPull(repository: string): Promise<void>;
2
+ export declare function gitSync(repository: string): Promise<void>;
package/dist/git.js ADDED
@@ -0,0 +1,48 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { simpleGit, CleanOptions } from "simple-git";
4
+ import { logger } from "./log.js";
5
+ import { STAGING_DIR } from "./staging.js";
6
+ export async function gitPull(repository) {
7
+ if (fs.existsSync(path.join(STAGING_DIR, ".git"))) {
8
+ const git = simpleGit(STAGING_DIR);
9
+ await git.fetch("origin");
10
+ const branch = await git.revparse(["--abbrev-ref", "HEAD"]);
11
+ await git.reset(["--hard", `origin/${branch}`]);
12
+ await git.clean(CleanOptions.FORCE, ["-d"]);
13
+ }
14
+ else {
15
+ await simpleGit().clone(repository, STAGING_DIR);
16
+ }
17
+ logger.info("Synced staging directory from remote");
18
+ }
19
+ export async function gitSync(repository) {
20
+ if (!fs.existsSync(STAGING_DIR)) {
21
+ fs.mkdirSync(STAGING_DIR, { recursive: true });
22
+ }
23
+ 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
+ await git.add(".");
30
+ const status = await git.status();
31
+ if (status.isClean()) {
32
+ logger.info("No changes to commit");
33
+ return;
34
+ }
35
+ const date = new Date().toISOString().split("T")[0];
36
+ const message = `Automated backup: ${date}`;
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
+ }
48
+ }
package/dist/log.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import winston from "winston";
2
+ export declare const logger: winston.Logger;
package/dist/log.js ADDED
@@ -0,0 +1,9 @@
1
+ import path from "node:path";
2
+ import os from "node:os";
3
+ import winston from "winston";
4
+ const LOG_FILE = path.join(os.homedir(), ".backdot", "backup.log");
5
+ export const logger = winston.createLogger({
6
+ level: "info",
7
+ format: winston.format.combine(winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), winston.format.printf(({ timestamp, level, message }) => `${timestamp} [${level}] ${message}`)),
8
+ transports: [new winston.transports.File({ filename: LOG_FILE })],
9
+ });
@@ -0,0 +1,3 @@
1
+ export declare function isScheduled(): boolean;
2
+ export declare function setupLaunchd(): void;
3
+ export declare function uninstallLaunchd(): void;
package/dist/plist.js ADDED
@@ -0,0 +1,104 @@
1
+ import { execSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import ora from "ora";
6
+ import { logger } from "./log.js";
7
+ const LABEL = "com.backdot.daemon";
8
+ const PLIST_PATH = path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
9
+ function getScriptPath() {
10
+ const currentDir = path.dirname(new URL(import.meta.url).pathname);
11
+ return path.resolve(currentDir, "cli.js");
12
+ }
13
+ function buildPlist() {
14
+ const nodePath = process.execPath;
15
+ const scriptPath = getScriptPath();
16
+ const workingDir = path.dirname(scriptPath);
17
+ const logPath = path.join(os.homedir(), ".backdot", "launchd.log");
18
+ return `<?xml version="1.0" encoding="UTF-8"?>
19
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
20
+ <plist version="1.0">
21
+ <dict>
22
+ <key>Label</key>
23
+ <string>${LABEL}</string>
24
+ <key>ProgramArguments</key>
25
+ <array>
26
+ <string>${nodePath}</string>
27
+ <string>${scriptPath}</string>
28
+ <string>--backup</string>
29
+ </array>
30
+ <key>WorkingDirectory</key>
31
+ <string>${workingDir}</string>
32
+ <key>StartCalendarInterval</key>
33
+ <dict>
34
+ <key>Hour</key>
35
+ <integer>2</integer>
36
+ <key>Minute</key>
37
+ <integer>0</integer>
38
+ </dict>
39
+ <key>StandardOutPath</key>
40
+ <string>${logPath}</string>
41
+ <key>StandardErrorPath</key>
42
+ <string>${logPath}</string>
43
+ <key>RunAtLoad</key>
44
+ <false/>
45
+ </dict>
46
+ </plist>`;
47
+ }
48
+ export function isScheduled() {
49
+ if (!fs.existsSync(PLIST_PATH)) {
50
+ return false;
51
+ }
52
+ try {
53
+ const output = execSync(`launchctl list ${LABEL}`, { encoding: "utf-8", stdio: "pipe" });
54
+ return output.includes(LABEL);
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ export function setupLaunchd() {
61
+ const spinner = ora("Installing schedule").start();
62
+ const plistContent = buildPlist();
63
+ const dir = path.dirname(PLIST_PATH);
64
+ if (!fs.existsSync(dir)) {
65
+ fs.mkdirSync(dir, { recursive: true });
66
+ }
67
+ try {
68
+ execSync(`launchctl unload ${PLIST_PATH}`, { stdio: "pipe" });
69
+ }
70
+ catch {
71
+ // Not loaded, that's fine
72
+ }
73
+ fs.writeFileSync(PLIST_PATH, plistContent);
74
+ logger.info(`Plist written to ${PLIST_PATH}`);
75
+ try {
76
+ execSync(`launchctl load ${PLIST_PATH}`);
77
+ spinner.succeed("Daily backup scheduled (02:00)");
78
+ console.log();
79
+ logger.info("Launchd job loaded");
80
+ }
81
+ catch (err) {
82
+ const msg = err instanceof Error ? err.message : String(err);
83
+ spinner.fail(`Failed to load launchd job: ${msg}`);
84
+ console.log();
85
+ logger.error(`Failed to load launchd job: ${msg}`);
86
+ process.exit(1);
87
+ }
88
+ }
89
+ export function uninstallLaunchd() {
90
+ const spinner = ora("Removing schedule").start();
91
+ try {
92
+ execSync(`launchctl unload ${PLIST_PATH}`, { stdio: "pipe" });
93
+ logger.info("Launchd job unloaded");
94
+ }
95
+ catch {
96
+ logger.info("Launchd job was not loaded");
97
+ }
98
+ if (fs.existsSync(PLIST_PATH)) {
99
+ fs.unlinkSync(PLIST_PATH);
100
+ logger.info(`Plist removed: ${PLIST_PATH}`);
101
+ }
102
+ spinner.succeed("Schedule removed");
103
+ console.log();
104
+ }
@@ -0,0 +1,6 @@
1
+ import { Config } from "./config.js";
2
+ /**
3
+ * Resolve all file entries to absolute paths.
4
+ * Skips entries that fail resolution and logs warnings.
5
+ */
6
+ export declare function resolveFiles(files: Config["files"]): string[];
@@ -0,0 +1,62 @@
1
+ import { execSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import fg from "fast-glob";
5
+ import { logger } from "./log.js";
6
+ function resolveGitignored(dirPath) {
7
+ if (!fs.existsSync(dirPath)) {
8
+ logger.warn(`Directory does not exist, skipping: ${dirPath}`);
9
+ return [];
10
+ }
11
+ try {
12
+ const output = execSync("git ls-files --others --ignored --exclude-standard", {
13
+ cwd: dirPath,
14
+ encoding: "utf-8",
15
+ });
16
+ return output
17
+ .split("\n")
18
+ .filter((line) => line.length > 0)
19
+ .map((rel) => path.resolve(dirPath, rel));
20
+ }
21
+ catch {
22
+ logger.warn(`Failed to list gitignored files in: ${dirPath}`);
23
+ return [];
24
+ }
25
+ }
26
+ function resolveGlob(pattern) {
27
+ try {
28
+ return fg.sync(pattern, { absolute: true, dot: true });
29
+ }
30
+ catch {
31
+ logger.warn(`Glob pattern failed: ${pattern}`);
32
+ return [];
33
+ }
34
+ }
35
+ /**
36
+ * Resolve all file entries to absolute paths.
37
+ * Skips entries that fail resolution and logs warnings.
38
+ */
39
+ export function resolveFiles(files) {
40
+ const allFiles = [];
41
+ for (const dirPath of files.gitignored) {
42
+ allFiles.push(...resolveGitignored(dirPath));
43
+ }
44
+ for (const pattern of files.match) {
45
+ allFiles.push(...resolveGlob(pattern));
46
+ }
47
+ return allFiles.filter((filePath) => {
48
+ try {
49
+ fs.accessSync(filePath, fs.constants.R_OK);
50
+ const stat = fs.statSync(filePath);
51
+ if (!stat.isFile()) {
52
+ logger.warn(`Not a regular file, skipping: ${filePath}`);
53
+ return false;
54
+ }
55
+ return true;
56
+ }
57
+ catch {
58
+ logger.warn(`File not readable, skipping: ${filePath}`);
59
+ return false;
60
+ }
61
+ });
62
+ }
@@ -0,0 +1 @@
1
+ export declare function restore(): Promise<void>;
@@ -0,0 +1,81 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import ora from "ora";
5
+ import { checkbox } from "@inquirer/prompts";
6
+ import { loadConfig } from "./config.js";
7
+ import { gitPull } from "./git.js";
8
+ import { STAGING_DIR } from "./staging.js";
9
+ import { logger } from "./log.js";
10
+ const HOME = os.homedir();
11
+ function walkDir(dir) {
12
+ const results = [];
13
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
14
+ if (entry.name === ".git")
15
+ continue;
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;
25
+ }
26
+ export async function restore() {
27
+ logger.info("Starting restore");
28
+ const config = loadConfig();
29
+ const spinner = ora("Fetching latest backup").start();
30
+ await gitPull(config.repository);
31
+ spinner.text = "Resolving files";
32
+ const stagedFiles = walkDir(STAGING_DIR);
33
+ logger.info(`Found ${stagedFiles.length} file(s) in backup repository`);
34
+ if (stagedFiles.length === 0) {
35
+ spinner.stop();
36
+ console.log("No files found in backup repository.");
37
+ return;
38
+ }
39
+ const fileMappings = stagedFiles.map((src) => {
40
+ const rel = path.relative(STAGING_DIR, src);
41
+ return { src, dest: path.join(HOME, rel), rel };
42
+ });
43
+ const existing = fileMappings.filter((f) => fs.existsSync(f.dest));
44
+ const fresh = fileMappings.filter((f) => !fs.existsSync(f.dest));
45
+ logger.info(`${fresh.length} new, ${existing.length} already exist`);
46
+ spinner.stop();
47
+ console.log();
48
+ if (fresh.length > 0) {
49
+ console.log(`${fresh.length} new file(s) to restore:`);
50
+ console.log();
51
+ for (const f of fresh) {
52
+ console.log(` ${f.rel}`);
53
+ }
54
+ console.log();
55
+ }
56
+ let toRestore = fresh;
57
+ if (existing.length > 0) {
58
+ const selected = await checkbox({
59
+ message: `${existing.length} file(s) already exist. Select which to overwrite:`,
60
+ choices: existing.map((f) => ({
61
+ name: f.rel,
62
+ value: f,
63
+ checked: true,
64
+ })),
65
+ });
66
+ toRestore = [...fresh, ...selected];
67
+ console.log();
68
+ }
69
+ if (toRestore.length === 0) {
70
+ console.log("No files selected for restore.");
71
+ return;
72
+ }
73
+ const copySpinner = ora("Restoring files").start();
74
+ for (const { src, dest } of toRestore) {
75
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
76
+ fs.copyFileSync(src, dest);
77
+ }
78
+ copySpinner.succeed(`Restored ${toRestore.length} file(s)`);
79
+ console.log();
80
+ logger.info(`Restored ${toRestore.length} file(s)`);
81
+ }
@@ -0,0 +1,2 @@
1
+ export declare const STAGING_DIR: string;
2
+ export declare function copyToStaging(files: string[]): void;
@@ -0,0 +1,26 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { logger } from "./log.js";
5
+ const HOME = os.homedir();
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
+ }
11
+ let copied = 0;
12
+ 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);
16
+ try {
17
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
18
+ fs.copyFileSync(filePath, dest);
19
+ copied++;
20
+ }
21
+ catch {
22
+ logger.warn(`Failed to copy: ${filePath} -> ${dest}`);
23
+ }
24
+ }
25
+ logger.info(`Copied ${copied} file(s) to staging`);
26
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "backdot",
3
+ "version": "1.0.0",
4
+ "description": "Lightweight CLI to backup dotfiles and gitignored files to a Git repo on a daily schedule",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "dist/cli.js",
8
+ "bin": {
9
+ "backdot": "dist/cli.js"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "keywords": [
15
+ "dotfiles",
16
+ "backup",
17
+ "git",
18
+ "cli",
19
+ "launchd",
20
+ "gitignore"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "start": "node dist/cli.js",
25
+ "lint": "eslint src/",
26
+ "lint:fix": "eslint src/ --fix",
27
+ "format": "prettier --write src/",
28
+ "format:check": "prettier --check src/",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest"
31
+ },
32
+ "dependencies": {
33
+ "@inquirer/prompts": "^8.3.0",
34
+ "fast-glob": "^3.3.3",
35
+ "ora": "^9.3.0",
36
+ "simple-git": "^3.32.2",
37
+ "winston": "^3.19.0",
38
+ "zod": "^4.3.6"
39
+ },
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "devDependencies": {
44
+ "@eslint/js": "^10.0.1",
45
+ "@types/node": "^22.13.5",
46
+ "eslint": "^10.0.2",
47
+ "eslint-config-prettier": "^10.1.8",
48
+ "prettier": "^3.8.1",
49
+ "typescript": "^5.7.3",
50
+ "typescript-eslint": "^8.56.1",
51
+ "vitest": "^4.0.18"
52
+ }
53
+ }