castled 0.1.1
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.
- checksums.yaml +7 -0
- data/Gemfile +11 -0
- data/Gemfile.lock +33 -0
- data/README.md +109 -0
- data/Rakefile +12 -0
- data/castled.gemspec +44 -0
- data/docs/index.html +152 -0
- data/exe/simple-backup +7 -0
- data/lib/castled/backup.rb +216 -0
- data/lib/castled/cli.rb +99 -0
- data/lib/castled/config.rb +67 -0
- data/lib/castled/version.rb +5 -0
- data/lib/castled.rb +9 -0
- metadata +67 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b32ade60008579e7327153766c0cb2863380e65676f74333ddefdfaf576d496a
|
|
4
|
+
data.tar.gz: d130e839828dd6b38a40317698f4f6eda5f974b68a99d009792f70a581201bdc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bef65346414a4945081b6f11fd961576b42a890a184be7cb35d3a1dd992c852f3ae92e4e3792933888366c889efa63fe3f6303310dbcbb8edc9a9b3b09fb2580
|
|
7
|
+
data.tar.gz: dc0d75e763b14b13bd3f7838cb5bb713fc71dd89179a7b95b6b7f7bfe51ea27c63faeb37ca8442dcc8f16572beea3ea5c3882afa3f4d37c8e14395eb3ec8795f
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: .
|
|
3
|
+
specs:
|
|
4
|
+
castled (0.1.1)
|
|
5
|
+
|
|
6
|
+
GEM
|
|
7
|
+
remote: https://rubygems.org/
|
|
8
|
+
specs:
|
|
9
|
+
drb (2.2.3)
|
|
10
|
+
minitest (6.0.6)
|
|
11
|
+
drb (~> 2.0)
|
|
12
|
+
prism (~> 1.5)
|
|
13
|
+
prism (1.9.0)
|
|
14
|
+
rake (13.4.2)
|
|
15
|
+
|
|
16
|
+
PLATFORMS
|
|
17
|
+
ruby
|
|
18
|
+
x86_64-linux
|
|
19
|
+
|
|
20
|
+
DEPENDENCIES
|
|
21
|
+
castled!
|
|
22
|
+
minitest (~> 6.0)
|
|
23
|
+
rake (>= 13.0, >= 0)
|
|
24
|
+
|
|
25
|
+
CHECKSUMS
|
|
26
|
+
castled (0.1.1)
|
|
27
|
+
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
|
|
28
|
+
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
|
|
29
|
+
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
|
30
|
+
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
|
31
|
+
|
|
32
|
+
BUNDLED WITH
|
|
33
|
+
4.0.6
|
data/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Castled
|
|
2
|
+
|
|
3
|
+
**Simple backups for Omarchy. Opinionated defaults, zero ceremony.**
|
|
4
|
+
|
|
5
|
+
Omarchy gives you a beautiful, modern, fully configured Linux system in one shot — the omakase menu, chef's choice. Castled applies the same spirit to backups: a tiny, plain-text tool that saves the dotfiles you care about without turning disaster recovery into another configuration hobby.
|
|
6
|
+
|
|
7
|
+
No bespoke backup framework. No paradox of choice. Just `init`, `backup`, and `restore`.
|
|
8
|
+
|
|
9
|
+
## Why Castled?
|
|
10
|
+
|
|
11
|
+
- **Curated from the start** — `simple-backup init` writes a sensible Omarchy-oriented `config.yml` you can edit in seconds.
|
|
12
|
+
- **Plain text, terminal-first** — one YAML file lists what to save and where; everything else stays out of your way.
|
|
13
|
+
- **Restore when it matters** — list backups, pick one, preview with `--dry-run`, diff with `--diff`.
|
|
14
|
+
- **Substitutions welcome** — change paths, add folders, point `destination` at your USB drive. The defaults are a starting point, not a contract.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
gem build castled.gemspec
|
|
20
|
+
gem install castled-*.gem
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or from the project directory:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bundle install
|
|
27
|
+
bundle exec rake install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
### Initialize
|
|
33
|
+
|
|
34
|
+
Create a `config.yml` in the current directory:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
simple-backup init
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Example `config.yml` (also what `init` generates):
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
backup_name: omarchy
|
|
44
|
+
backup_paths:
|
|
45
|
+
- ~/.bash_logout
|
|
46
|
+
- ~/.bash_profile
|
|
47
|
+
- ~/.bashrc
|
|
48
|
+
- ~/.gemrc
|
|
49
|
+
- ~/.XCompose
|
|
50
|
+
destination: /run/media/leonid/250GB/backups
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Adjust `backup_paths` and `destination` to match your machine. Plug in an external drive, point `destination` at its mount path, and you're done.
|
|
54
|
+
|
|
55
|
+
### Backup
|
|
56
|
+
|
|
57
|
+
Copy configured paths to a timestamped folder under `destination`:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
simple-backup backup
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Backups are stored as `backup_name_YYYYMMDD_HHMMSS` (e.g. `omarchy_20260519_112300`).
|
|
64
|
+
|
|
65
|
+
### Schedule with cron
|
|
66
|
+
|
|
67
|
+
Run backups automatically from the directory that holds your `config.yml`:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
crontab -e
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Example — backup every day at 9:00 AM:
|
|
74
|
+
|
|
75
|
+
```cron
|
|
76
|
+
0 9 * * * cd /path/to/backup-config && /usr/bin/env simple-backup backup
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Use full paths in cron jobs when possible; cron runs with a smaller environment than your interactive shell.
|
|
80
|
+
|
|
81
|
+
### Restore
|
|
82
|
+
|
|
83
|
+
List available backups and restore one interactively:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
simple-backup restore
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Restore copies files back to their original locations (overwriting existing files).
|
|
90
|
+
|
|
91
|
+
Preview without writing:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
simple-backup restore --dry-run
|
|
95
|
+
simple-backup restore 1 --dry-run --diff
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Dry run reports:
|
|
99
|
+
|
|
100
|
+
- files that would be copied to new paths
|
|
101
|
+
- files that would overwrite existing paths
|
|
102
|
+
- optional unified diffs for overwritten text files (`--diff`, requires `--dry-run`)
|
|
103
|
+
|
|
104
|
+
## Development
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
bundle install
|
|
108
|
+
bundle exec ruby -Ilib:test test/test_*.rb
|
|
109
|
+
```
|
data/Rakefile
ADDED
data/castled.gemspec
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/castled/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "castled"
|
|
7
|
+
spec.version = Castled::VERSION
|
|
8
|
+
spec.authors = ["Castled"]
|
|
9
|
+
spec.email = ["castled@example.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Simple backups via simple-backup CLI"
|
|
12
|
+
spec.description = "Initialize, backup, and restore files with a YAML config"
|
|
13
|
+
spec.homepage = "https://github.com/example/castled"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = ">= 3.0.0"
|
|
16
|
+
|
|
17
|
+
spec.files = Dir.chdir(__dir__) do
|
|
18
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
19
|
+
f.start_with?("test/", ".git")
|
|
20
|
+
end
|
|
21
|
+
rescue StandardError
|
|
22
|
+
%w[
|
|
23
|
+
castled.gemspec
|
|
24
|
+
Gemfile
|
|
25
|
+
README.md
|
|
26
|
+
exe/simple-backup
|
|
27
|
+
lib/castled.rb
|
|
28
|
+
lib/castled/version.rb
|
|
29
|
+
lib/castled/cli.rb
|
|
30
|
+
lib/castled/config.rb
|
|
31
|
+
lib/castled/backup.rb
|
|
32
|
+
test/test_helper.rb
|
|
33
|
+
test/test_config.rb
|
|
34
|
+
test/test_backup.rb
|
|
35
|
+
test/test_cli.rb
|
|
36
|
+
]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
spec.bindir = "exe"
|
|
40
|
+
spec.executables = ["simple-backup"]
|
|
41
|
+
spec.require_paths = ["lib"]
|
|
42
|
+
|
|
43
|
+
spec.add_development_dependency "rake", ">= 13.0"
|
|
44
|
+
end
|
data/docs/index.html
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Castled — Simple backups for Omarchy</title>
|
|
7
|
+
<meta name="description" content="A tiny, opinionated backup tool for Omarchy users. Initialize once, back up your dotfiles, restore when you need them.">
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #0f1114;
|
|
11
|
+
--surface: #1a1d23;
|
|
12
|
+
--border: #2a3038;
|
|
13
|
+
--text: #e8eaed;
|
|
14
|
+
--muted: #9aa0a6;
|
|
15
|
+
--accent: #7eb8da;
|
|
16
|
+
--accent-dim: #5a8fad;
|
|
17
|
+
}
|
|
18
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
19
|
+
body {
|
|
20
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
21
|
+
background: var(--bg);
|
|
22
|
+
color: var(--text);
|
|
23
|
+
line-height: 1.6;
|
|
24
|
+
min-height: 100vh;
|
|
25
|
+
}
|
|
26
|
+
.wrap {
|
|
27
|
+
max-width: 42rem;
|
|
28
|
+
margin: 0 auto;
|
|
29
|
+
padding: 3rem 1.5rem 4rem;
|
|
30
|
+
}
|
|
31
|
+
h1 {
|
|
32
|
+
font-size: 1.75rem;
|
|
33
|
+
font-weight: 600;
|
|
34
|
+
letter-spacing: -0.02em;
|
|
35
|
+
margin-bottom: 0.5rem;
|
|
36
|
+
}
|
|
37
|
+
.tagline {
|
|
38
|
+
font-size: 1.125rem;
|
|
39
|
+
color: var(--muted);
|
|
40
|
+
margin-bottom: 2rem;
|
|
41
|
+
}
|
|
42
|
+
.pitch {
|
|
43
|
+
margin-bottom: 2.5rem;
|
|
44
|
+
color: var(--muted);
|
|
45
|
+
}
|
|
46
|
+
.pitch strong { color: var(--text); font-weight: 500; }
|
|
47
|
+
h2 {
|
|
48
|
+
font-size: 0.75rem;
|
|
49
|
+
font-weight: 600;
|
|
50
|
+
text-transform: uppercase;
|
|
51
|
+
letter-spacing: 0.08em;
|
|
52
|
+
color: var(--accent-dim);
|
|
53
|
+
margin: 2rem 0 0.75rem;
|
|
54
|
+
}
|
|
55
|
+
pre, code {
|
|
56
|
+
font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace;
|
|
57
|
+
font-size: 0.8125rem;
|
|
58
|
+
}
|
|
59
|
+
pre {
|
|
60
|
+
background: var(--surface);
|
|
61
|
+
border: 1px solid var(--border);
|
|
62
|
+
border-radius: 6px;
|
|
63
|
+
padding: 1rem 1.25rem;
|
|
64
|
+
overflow-x: auto;
|
|
65
|
+
margin-bottom: 0.5rem;
|
|
66
|
+
}
|
|
67
|
+
.cmd { color: var(--accent); }
|
|
68
|
+
.comment { color: var(--muted); }
|
|
69
|
+
ul.features {
|
|
70
|
+
list-style: none;
|
|
71
|
+
margin: 1.5rem 0;
|
|
72
|
+
}
|
|
73
|
+
ul.features li {
|
|
74
|
+
padding: 0.35rem 0;
|
|
75
|
+
padding-left: 1.25rem;
|
|
76
|
+
position: relative;
|
|
77
|
+
color: var(--muted);
|
|
78
|
+
}
|
|
79
|
+
ul.features li::before {
|
|
80
|
+
content: "→";
|
|
81
|
+
position: absolute;
|
|
82
|
+
left: 0;
|
|
83
|
+
color: var(--accent-dim);
|
|
84
|
+
}
|
|
85
|
+
footer {
|
|
86
|
+
margin-top: 3rem;
|
|
87
|
+
padding-top: 1.5rem;
|
|
88
|
+
border-top: 1px solid var(--border);
|
|
89
|
+
font-size: 0.875rem;
|
|
90
|
+
color: var(--muted);
|
|
91
|
+
}
|
|
92
|
+
footer a { color: var(--accent); text-decoration: none; }
|
|
93
|
+
footer a:hover { text-decoration: underline; }
|
|
94
|
+
</style>
|
|
95
|
+
</head>
|
|
96
|
+
<body>
|
|
97
|
+
<div class="wrap">
|
|
98
|
+
<h1>Castled</h1>
|
|
99
|
+
<p class="tagline">Simple backups for Omarchy. Opinionated defaults, zero ceremony.</p>
|
|
100
|
+
|
|
101
|
+
<p class="pitch">
|
|
102
|
+
Omarchy is <strong>beautiful, modern & opinionated Linux</strong> — omakase computing,
|
|
103
|
+
chef's choice. Castled brings the same spirit to your personal dotfiles: one command to
|
|
104
|
+
initialize, one to back up, one to restore. No backup framework to learn. Substitutions
|
|
105
|
+
welcome.
|
|
106
|
+
</p>
|
|
107
|
+
|
|
108
|
+
<ul class="features">
|
|
109
|
+
<li>Curated <code>config.yml</code> from <code>simple-backup init</code></li>
|
|
110
|
+
<li>Timestamped copies to any destination (USB, NAS, local disk)</li>
|
|
111
|
+
<li>Interactive restore with dry-run and diff preview</li>
|
|
112
|
+
</ul>
|
|
113
|
+
|
|
114
|
+
<h2>Install</h2>
|
|
115
|
+
<pre><code><span class="cmd">gem build castled.gemspec</span>
|
|
116
|
+
<span class="cmd">gem install castled-*.gem</span></code></pre>
|
|
117
|
+
|
|
118
|
+
<h2>Initialize</h2>
|
|
119
|
+
<pre><code><span class="cmd">simple-backup init</span>
|
|
120
|
+
<span class="comment"># creates config.yml with Omarchy-friendly defaults</span></code></pre>
|
|
121
|
+
|
|
122
|
+
<h2>Example config</h2>
|
|
123
|
+
<pre><code>backup_name: omarchy
|
|
124
|
+
backup_paths:
|
|
125
|
+
- ~/.bash_logout
|
|
126
|
+
- ~/.bash_profile
|
|
127
|
+
- ~/.bashrc
|
|
128
|
+
- ~/.gemrc
|
|
129
|
+
- ~/.XCompose
|
|
130
|
+
destination: /run/media/leonid/250GB/backups</code></pre>
|
|
131
|
+
|
|
132
|
+
<h2>Backup & restore</h2>
|
|
133
|
+
<pre><code><span class="cmd">simple-backup backup</span>
|
|
134
|
+
<span class="comment"># → omarchy_YYYYMMDD_HHMMSS/</span>
|
|
135
|
+
|
|
136
|
+
<span class="cmd">simple-backup restore</span>
|
|
137
|
+
<span class="cmd">simple-backup restore --dry-run --diff</span></code></pre>
|
|
138
|
+
|
|
139
|
+
<h2>Schedule (optional)</h2>
|
|
140
|
+
<pre><code><span class="comment"># crontab — daily at 9:00</span>
|
|
141
|
+
0 9 * * * cd /path/to/config && simple-backup backup</code></pre>
|
|
142
|
+
|
|
143
|
+
<footer>
|
|
144
|
+
<a href="https://github.com/example/castled">GitHub</a>
|
|
145
|
+
·
|
|
146
|
+
<a href="https://omarchy.org">Omarchy</a>
|
|
147
|
+
·
|
|
148
|
+
MIT License
|
|
149
|
+
</footer>
|
|
150
|
+
</div>
|
|
151
|
+
</body>
|
|
152
|
+
</html>
|
data/exe/simple-backup
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "time"
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
module Castled
|
|
9
|
+
class Backup
|
|
10
|
+
MANIFEST_FILENAME = ".castled_manifest.yml"
|
|
11
|
+
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
def initialize(config)
|
|
15
|
+
@config = config
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run!
|
|
19
|
+
timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
|
|
20
|
+
backup_dir = @config.destination_path.join("#{@config.backup_name}_#{timestamp}")
|
|
21
|
+
FileUtils.mkdir_p(backup_dir)
|
|
22
|
+
|
|
23
|
+
entries = []
|
|
24
|
+
@config.backup_paths.each do |source_path|
|
|
25
|
+
source = Pathname.new(source_path).expand_path
|
|
26
|
+
raise Error, "Path not found: #{source}" unless source.exist?
|
|
27
|
+
|
|
28
|
+
dest_name = source.basename.to_s
|
|
29
|
+
dest = backup_dir.join(dest_name)
|
|
30
|
+
copy_entry(source, dest)
|
|
31
|
+
entries << { "source" => source.to_s, "backup_entry" => dest_name }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
write_manifest(backup_dir, entries)
|
|
35
|
+
backup_dir.to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def restore!(selection: nil, dry_run: false, diff: false, io: $stdout)
|
|
39
|
+
backups = list_backups
|
|
40
|
+
raise Error, "No backups found for '#{@config.backup_name}'" if backups.empty?
|
|
41
|
+
|
|
42
|
+
chosen = select_backup(backups, selection: selection)
|
|
43
|
+
manifest = load_manifest(chosen)
|
|
44
|
+
plan = restore_plan(chosen, manifest)
|
|
45
|
+
|
|
46
|
+
if dry_run
|
|
47
|
+
print_dry_run(chosen, plan, diff: diff, io: io)
|
|
48
|
+
return chosen.to_s
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
plan.each do |operation|
|
|
52
|
+
restore_operation(operation)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
chosen.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def list_backups
|
|
59
|
+
dest = @config.destination_path
|
|
60
|
+
return [] unless dest.directory?
|
|
61
|
+
|
|
62
|
+
prefix = "#{@config.backup_name}_"
|
|
63
|
+
dest.children
|
|
64
|
+
.select(&:directory?)
|
|
65
|
+
.select { |d| d.basename.to_s.start_with?(prefix) }
|
|
66
|
+
.sort_by { |d| d.basename.to_s }
|
|
67
|
+
.reverse
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def restore_plan(backup_dir, manifest)
|
|
73
|
+
manifest.fetch("entries").flat_map do |entry|
|
|
74
|
+
source = Pathname.new(entry.fetch("source"))
|
|
75
|
+
backup_entry = backup_dir.join(entry.fetch("backup_entry"))
|
|
76
|
+
raise Error, "Backup entry missing: #{backup_entry}" unless backup_entry.exist?
|
|
77
|
+
|
|
78
|
+
if backup_entry.directory?
|
|
79
|
+
directory_restore_plan(backup_entry, source)
|
|
80
|
+
else
|
|
81
|
+
[file_restore_operation(backup_entry, source)]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def directory_restore_plan(backup_entry, source)
|
|
87
|
+
operations = []
|
|
88
|
+
operations << { action: :mkdir, backup: backup_entry, target: source } unless source.directory?
|
|
89
|
+
|
|
90
|
+
backup_entry.find.each do |path|
|
|
91
|
+
next if path == backup_entry
|
|
92
|
+
|
|
93
|
+
relative_path = path.relative_path_from(backup_entry)
|
|
94
|
+
target = source.join(relative_path)
|
|
95
|
+
|
|
96
|
+
if path.directory?
|
|
97
|
+
operations << { action: :mkdir, backup: path, target: target } unless target.directory?
|
|
98
|
+
else
|
|
99
|
+
operations << file_restore_operation(path, target)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
operations
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def file_restore_operation(backup_entry, target)
|
|
107
|
+
action = target.exist? ? :overwrite : :copy
|
|
108
|
+
{ action: action, backup: backup_entry, target: target }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def restore_operation(operation)
|
|
112
|
+
case operation[:action]
|
|
113
|
+
when :mkdir
|
|
114
|
+
FileUtils.mkdir_p(operation[:target])
|
|
115
|
+
when :copy, :overwrite
|
|
116
|
+
FileUtils.mkdir_p(operation[:target].dirname)
|
|
117
|
+
FileUtils.cp(operation[:backup].to_s, operation[:target].to_s)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def print_dry_run(backup_dir, plan, diff:, io:)
|
|
122
|
+
io.puts "Dry run: restoring #{backup_dir.basename}"
|
|
123
|
+
|
|
124
|
+
if plan.empty?
|
|
125
|
+
io.puts "No files would be copied."
|
|
126
|
+
return
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
plan.each do |operation|
|
|
130
|
+
case operation[:action]
|
|
131
|
+
when :mkdir
|
|
132
|
+
io.puts "Would create directory: #{operation[:target]}"
|
|
133
|
+
when :copy
|
|
134
|
+
io.puts "Would copy: #{operation[:backup]} -> #{operation[:target]}"
|
|
135
|
+
when :overwrite
|
|
136
|
+
io.puts "Would overwrite: #{operation[:target]} with #{operation[:backup]}"
|
|
137
|
+
print_diff(operation, io: io) if diff
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def print_diff(operation, io:)
|
|
143
|
+
current = operation[:target].read
|
|
144
|
+
restored = operation[:backup].read
|
|
145
|
+
return if current == restored
|
|
146
|
+
|
|
147
|
+
io.puts "Diff for #{operation[:target]}:"
|
|
148
|
+
io.puts "--- current"
|
|
149
|
+
io.puts "+++ restored"
|
|
150
|
+
io.puts unified_line_diff(current, restored)
|
|
151
|
+
rescue ArgumentError
|
|
152
|
+
io.puts "Diff skipped for #{operation[:target]}: file is not valid text"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def unified_line_diff(current, restored)
|
|
156
|
+
current_lines = current.lines
|
|
157
|
+
restored_lines = restored.lines
|
|
158
|
+
max = [current_lines.length, restored_lines.length].max
|
|
159
|
+
output = []
|
|
160
|
+
|
|
161
|
+
max.times do |index|
|
|
162
|
+
old_line = current_lines[index]
|
|
163
|
+
new_line = restored_lines[index]
|
|
164
|
+
next if old_line == new_line
|
|
165
|
+
|
|
166
|
+
output << "-#{old_line}" if old_line
|
|
167
|
+
output << "+#{new_line}" if new_line
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
output.join
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def copy_entry(source, dest)
|
|
174
|
+
if source.directory?
|
|
175
|
+
FileUtils.mkdir_p(dest)
|
|
176
|
+
source.children.each do |child|
|
|
177
|
+
FileUtils.cp_r(child.to_s, dest.to_s)
|
|
178
|
+
end
|
|
179
|
+
else
|
|
180
|
+
FileUtils.mkdir_p(dest.dirname)
|
|
181
|
+
FileUtils.cp(source.to_s, dest.to_s)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def write_manifest(backup_dir, entries)
|
|
186
|
+
manifest = {
|
|
187
|
+
"backup_name" => @config.backup_name,
|
|
188
|
+
"created_at" => Time.now.iso8601,
|
|
189
|
+
"entries" => entries
|
|
190
|
+
}
|
|
191
|
+
backup_dir.join(MANIFEST_FILENAME).write(YAML.dump(manifest))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def load_manifest(backup_dir)
|
|
195
|
+
path = backup_dir.join(MANIFEST_FILENAME)
|
|
196
|
+
raise Error, "Manifest not found in #{backup_dir}" unless path.exist?
|
|
197
|
+
|
|
198
|
+
YAML.safe_load(path.read) || {}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def select_backup(backups, selection: nil)
|
|
202
|
+
return backups[selection.to_i - 1] if selection
|
|
203
|
+
|
|
204
|
+
puts "Available backups:"
|
|
205
|
+
backups.each_with_index do |backup, index|
|
|
206
|
+
puts " #{index + 1}. #{backup.basename}"
|
|
207
|
+
end
|
|
208
|
+
print "Select backup (1-#{backups.size}): "
|
|
209
|
+
choice = $stdin.gets&.strip
|
|
210
|
+
index = choice.to_i
|
|
211
|
+
raise Error, "Invalid selection" if index < 1 || index > backups.size
|
|
212
|
+
|
|
213
|
+
backups[index - 1]
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
data/lib/castled/cli.rb
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Castled
|
|
4
|
+
class CLI
|
|
5
|
+
COMMANDS = %w[init backup restore].freeze
|
|
6
|
+
|
|
7
|
+
def self.run(argv)
|
|
8
|
+
new.run(argv)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def run(argv)
|
|
12
|
+
command = argv.first
|
|
13
|
+
if command.nil? || command == "--help" || command == "-h"
|
|
14
|
+
print_usage
|
|
15
|
+
exit command.nil? ? 1 : 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
unless COMMANDS.include?(command)
|
|
19
|
+
$stderr.puts "Unknown command: #{command}"
|
|
20
|
+
print_usage
|
|
21
|
+
exit 1
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
send(command, argv[1..])
|
|
25
|
+
rescue Config::Error, Backup::Error => e
|
|
26
|
+
$stderr.puts "Error: #{e.message}"
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def init(_args)
|
|
33
|
+
path = Config.init!
|
|
34
|
+
puts "Created #{path}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def backup(_args)
|
|
38
|
+
config = Config.load!
|
|
39
|
+
backup_dir = Backup.new(config).run!
|
|
40
|
+
puts "Backup created at #{backup_dir}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def restore(args)
|
|
44
|
+
options = parse_restore_options(args)
|
|
45
|
+
config = Config.load!
|
|
46
|
+
restored = Backup.new(config).restore!(
|
|
47
|
+
selection: options[:selection],
|
|
48
|
+
dry_run: options[:dry_run],
|
|
49
|
+
diff: options[:diff]
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if options[:dry_run]
|
|
53
|
+
puts "Dry run complete for #{restored}"
|
|
54
|
+
else
|
|
55
|
+
puts "Restored from #{restored}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse_restore_options(args)
|
|
60
|
+
dry_run = false
|
|
61
|
+
diff = false
|
|
62
|
+
selection = nil
|
|
63
|
+
unknown = []
|
|
64
|
+
|
|
65
|
+
args.each do |arg|
|
|
66
|
+
case arg
|
|
67
|
+
when "--dry-run", "-n"
|
|
68
|
+
dry_run = true
|
|
69
|
+
when "--diff"
|
|
70
|
+
diff = true
|
|
71
|
+
when /\A\d+\z/
|
|
72
|
+
selection ||= arg
|
|
73
|
+
else
|
|
74
|
+
unknown << arg
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
raise Backup::Error, "Unknown restore options: #{unknown.join(', ')}" unless unknown.empty?
|
|
79
|
+
raise Backup::Error, "--diff requires --dry-run" if diff && !dry_run
|
|
80
|
+
|
|
81
|
+
{ dry_run: dry_run, diff: diff, selection: selection }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def print_usage
|
|
85
|
+
puts <<~USAGE
|
|
86
|
+
Usage: simple-backup <command>
|
|
87
|
+
|
|
88
|
+
Commands:
|
|
89
|
+
init Create config.yml in the current directory
|
|
90
|
+
backup Copy configured paths to a timestamped backup folder
|
|
91
|
+
restore List backups and restore one interactively
|
|
92
|
+
|
|
93
|
+
Restore options:
|
|
94
|
+
--dry-run, -n Preview files that would be copied or overwritten
|
|
95
|
+
--diff Show unified diffs for files that would be overwritten
|
|
96
|
+
USAGE
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Castled
|
|
7
|
+
class Config
|
|
8
|
+
CONFIG_FILENAME = "config.yml"
|
|
9
|
+
|
|
10
|
+
DEFAULT_TEMPLATE = <<~YAML
|
|
11
|
+
backup_name: omarchy
|
|
12
|
+
backup_paths:
|
|
13
|
+
- ~/.bash_logout
|
|
14
|
+
- ~/.bash_profile
|
|
15
|
+
- ~/.bashrc
|
|
16
|
+
- ~/.gemrc
|
|
17
|
+
- ~/.XCompose
|
|
18
|
+
destination: /run/media/leonid/250GB/backups
|
|
19
|
+
YAML
|
|
20
|
+
|
|
21
|
+
class Error < StandardError; end
|
|
22
|
+
|
|
23
|
+
attr_reader :backup_name, :backup_paths, :destination
|
|
24
|
+
|
|
25
|
+
def self.init!(dir: Dir.pwd)
|
|
26
|
+
path = Pathname.new(dir).join(CONFIG_FILENAME)
|
|
27
|
+
raise Error, "#{CONFIG_FILENAME} already exists" if path.exist?
|
|
28
|
+
|
|
29
|
+
path.write(DEFAULT_TEMPLATE)
|
|
30
|
+
path.to_s
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.load!(dir: Dir.pwd)
|
|
34
|
+
path = Pathname.new(dir).join(CONFIG_FILENAME)
|
|
35
|
+
raise Error, "#{CONFIG_FILENAME} not found. Run `simple-backup init` first." unless path.exist?
|
|
36
|
+
|
|
37
|
+
new(path)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(path)
|
|
41
|
+
@path = Pathname.new(path)
|
|
42
|
+
data = YAML.safe_load(@path.read, permitted_classes: [Symbol], aliases: true) || {}
|
|
43
|
+
@backup_name = data["backup_name"] || data[:backup_name]
|
|
44
|
+
@backup_paths = Array(data["backup_paths"] || data[:backup_paths])
|
|
45
|
+
@destination = data["destination"] || data[:destination]
|
|
46
|
+
validate!
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def destination_path
|
|
50
|
+
Pathname.new(@destination).expand_path
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def validate!
|
|
56
|
+
raise Error, "backup_name is required" if @backup_name.nil? || @backup_name.to_s.strip.empty?
|
|
57
|
+
|
|
58
|
+
if @backup_paths.empty?
|
|
59
|
+
raise Error, "backup_paths must list at least one file or folder"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if @destination.nil? || @destination.to_s.strip.empty?
|
|
63
|
+
raise Error, "destination is required"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/castled.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: castled
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Castled
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rake
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '13.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '13.0'
|
|
26
|
+
description: Initialize, backup, and restore files with a YAML config
|
|
27
|
+
email:
|
|
28
|
+
- castled@example.com
|
|
29
|
+
executables:
|
|
30
|
+
- simple-backup
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- Gemfile
|
|
35
|
+
- Gemfile.lock
|
|
36
|
+
- README.md
|
|
37
|
+
- Rakefile
|
|
38
|
+
- castled.gemspec
|
|
39
|
+
- docs/index.html
|
|
40
|
+
- exe/simple-backup
|
|
41
|
+
- lib/castled.rb
|
|
42
|
+
- lib/castled/backup.rb
|
|
43
|
+
- lib/castled/cli.rb
|
|
44
|
+
- lib/castled/config.rb
|
|
45
|
+
- lib/castled/version.rb
|
|
46
|
+
homepage: https://github.com/example/castled
|
|
47
|
+
licenses:
|
|
48
|
+
- MIT
|
|
49
|
+
metadata: {}
|
|
50
|
+
rdoc_options: []
|
|
51
|
+
require_paths:
|
|
52
|
+
- lib
|
|
53
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: 3.0.0
|
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '0'
|
|
63
|
+
requirements: []
|
|
64
|
+
rubygems_version: 4.0.6
|
|
65
|
+
specification_version: 4
|
|
66
|
+
summary: Simple backups via simple-backup CLI
|
|
67
|
+
test_files: []
|