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 +98 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +102 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +48 -0
- package/dist/git.d.ts +2 -0
- package/dist/git.js +48 -0
- package/dist/log.d.ts +2 -0
- package/dist/log.js +9 -0
- package/dist/plist.d.ts +3 -0
- package/dist/plist.js +104 -0
- package/dist/resolve.d.ts +6 -0
- package/dist/resolve.js +62 -0
- package/dist/restore.d.ts +1 -0
- package/dist/restore.js +81 -0
- package/dist/staging.d.ts +2 -0
- package/dist/staging.js +26 -0
- package/package.json +53 -0
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
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();
|
package/dist/config.d.ts
ADDED
|
@@ -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
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
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
|
+
});
|
package/dist/plist.d.ts
ADDED
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
|
+
}
|
package/dist/resolve.js
ADDED
|
@@ -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>;
|
package/dist/restore.js
ADDED
|
@@ -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
|
+
}
|
package/dist/staging.js
ADDED
|
@@ -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
|
+
}
|