dotlayer 0.2.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.
- checksums.yaml +7 -0
- data/LICENSE +9 -0
- data/README.md +192 -0
- data/exe/dotlayer +5 -0
- data/lib/dotlayer/cli.rb +84 -0
- data/lib/dotlayer/commands/adopt.rb +88 -0
- data/lib/dotlayer/commands/doctor.rb +109 -0
- data/lib/dotlayer/commands/install.rb +113 -0
- data/lib/dotlayer/commands/status.rb +44 -0
- data/lib/dotlayer/commands/update.rb +64 -0
- data/lib/dotlayer/config.rb +85 -0
- data/lib/dotlayer/detector.rb +53 -0
- data/lib/dotlayer/output.rb +50 -0
- data/lib/dotlayer/resolver.rb +113 -0
- data/lib/dotlayer/stow.rb +36 -0
- data/lib/dotlayer.rb +20 -0
- metadata +59 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d30157869fd9bd7c0d98d7957fd82879a06db355368ae7a66919d43ba2dac6dc
|
|
4
|
+
data.tar.gz: 560bf0f69bc6a68083d67739d91a97cbd3f208696313ba97f03619ccbae3901a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ffe27ed4bf4d65aacd1a52fc1c7697771f53b1fe69b1622a10a0789a2e01ce698d56ce6f4a485256e9b86faf5b696efe974d32dacfdfc894ddfdeb6540bb26fc
|
|
7
|
+
data.tar.gz: 919ce9855ebfb9a7a5ba0b74c06d818f405bf843ba7505433f3529a89dc6565a5f6a04e5f8bf03853fb45f625b964b19c3e2a8686821f5fced95b7e952f2294d
|
data/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Copyright © 2026, Douglas Andrade.
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# dotlayer
|
|
2
|
+
|
|
3
|
+
A convention-driven CLI wrapper around [GNU Stow](https://www.gnu.org/software/stow/) for managing dotfiles across multiple machines with layered overrides.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Managing dotfiles across machines with different OSes, hardware profiles, and Linux distros means lots of conditional logic in install scripts. Dotlayer replaces that with a naming convention: directory names declare when they should be stowed.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
config/ → always stowed
|
|
11
|
+
config-linux/ → stowed on Linux
|
|
12
|
+
config-macos/ → stowed on macOS
|
|
13
|
+
config-omarchy/ → stowed when Omarchy is detected
|
|
14
|
+
config-omarchy-desktop/ → stowed on Omarchy + desktop profile
|
|
15
|
+
config-omarchy-laptop/ → stowed on Omarchy + laptop profile
|
|
16
|
+
config-doximity/ → stowed when doximity group is detected
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
No config file needed — just name your directories and dotlayer figures out the rest.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
gem install dotlayer
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or clone and run directly:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
git clone https://github.com/douglas/dotlayer.git
|
|
31
|
+
export PATH="$HOME/src/dotlayer/exe:$PATH"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Requirements:** Ruby >= 3.2, GNU Stow
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
# See what dotlayer detects on your system
|
|
40
|
+
dotlayer status
|
|
41
|
+
|
|
42
|
+
# Stow all matching packages
|
|
43
|
+
dotlayer install
|
|
44
|
+
|
|
45
|
+
# Pull latest changes and re-stow
|
|
46
|
+
dotlayer update
|
|
47
|
+
|
|
48
|
+
# Check for broken symlinks and missing deps
|
|
49
|
+
dotlayer doctor
|
|
50
|
+
|
|
51
|
+
# Move existing config into a stow package
|
|
52
|
+
dotlayer adopt ~/.config/lazygit config
|
|
53
|
+
|
|
54
|
+
# Move config into the private repo
|
|
55
|
+
dotlayer adopt --private ~/.config/lazysql config
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## How it works
|
|
59
|
+
|
|
60
|
+
Dotlayer auto-detects four things about your system:
|
|
61
|
+
|
|
62
|
+
| Detection | Method | Example |
|
|
63
|
+
|-----------|--------|---------|
|
|
64
|
+
| **OS** | `RbConfig::CONFIG["host_os"]` | `linux`, `macos` |
|
|
65
|
+
| **Profile** | `hostnamectl chassis` or `$DOTLAYER_PROFILE` | `desktop`, `laptop` |
|
|
66
|
+
| **Distro** | Shell commands from config | `omarchy`, `fedora` |
|
|
67
|
+
| **Group** | Shell commands from config | `doximity`, `acme` |
|
|
68
|
+
|
|
69
|
+
It then scans your dotfiles repo for directories matching these tags and stows them in layer order:
|
|
70
|
+
|
|
71
|
+
1. **Base packages** — `stow`, `bin`, `git`, `zsh`, `config`
|
|
72
|
+
2. **OS layer** — `config-linux` or `config-macos`
|
|
73
|
+
3. **Distro layer** — `config-omarchy`
|
|
74
|
+
4. **Distro + profile** — `config-omarchy-desktop`
|
|
75
|
+
5. **Group layer** — `config-doximity`
|
|
76
|
+
|
|
77
|
+
Each layer can add files but never conflict with earlier layers (Stow constraint).
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
Dotlayer works with zero configuration using sensible defaults. For customization, create a `dotlayer.yml`:
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
target: ~
|
|
85
|
+
|
|
86
|
+
repos:
|
|
87
|
+
- path: ~/.public_dotfiles
|
|
88
|
+
- path: ~/.private_dotfiles
|
|
89
|
+
private: true
|
|
90
|
+
|
|
91
|
+
packages:
|
|
92
|
+
- stow
|
|
93
|
+
- bin
|
|
94
|
+
- git
|
|
95
|
+
- zsh
|
|
96
|
+
- config
|
|
97
|
+
|
|
98
|
+
profiles:
|
|
99
|
+
detect: hostnamectl chassis
|
|
100
|
+
env: DOTLAYER_PROFILE
|
|
101
|
+
|
|
102
|
+
distros:
|
|
103
|
+
omarchy:
|
|
104
|
+
detect: test -d ~/.local/share/omarchy
|
|
105
|
+
fedora:
|
|
106
|
+
detect: . /etc/os-release && test "$ID" = "fedora"
|
|
107
|
+
|
|
108
|
+
groups:
|
|
109
|
+
doximity:
|
|
110
|
+
detect: test -d ~/src/dox
|
|
111
|
+
|
|
112
|
+
system_files:
|
|
113
|
+
- source: config-linux/etc/systemd/system-sleep/xremap-restart.sh
|
|
114
|
+
dest: /etc/systemd/system-sleep/xremap-restart.sh
|
|
115
|
+
mode: "0755"
|
|
116
|
+
|
|
117
|
+
hooks:
|
|
118
|
+
after_system_files:
|
|
119
|
+
- sudo udevadm control --reload-rules
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Config file is discovered automatically from:
|
|
123
|
+
- `~/.config/dotlayer/dotlayer.yml`
|
|
124
|
+
- `~/.public_dotfiles/dotlayer.yml`
|
|
125
|
+
- `~/.dotfiles/dotlayer.yml`
|
|
126
|
+
|
|
127
|
+
## Multi-repo support
|
|
128
|
+
|
|
129
|
+
Dotlayer supports multiple repos. Each repo can have its own base packages:
|
|
130
|
+
|
|
131
|
+
```yaml
|
|
132
|
+
repos:
|
|
133
|
+
- path: ~/.public_dotfiles
|
|
134
|
+
- path: ~/.private_dotfiles
|
|
135
|
+
private: true
|
|
136
|
+
packages:
|
|
137
|
+
- config
|
|
138
|
+
- fonts
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Repos without any matching base packages automatically stow all their top-level directories (sorted alphabetically). This is useful for private repos that only contain standalone packages.
|
|
142
|
+
|
|
143
|
+
Repos are processed in order — packages from the first repo are stowed before packages from the second.
|
|
144
|
+
|
|
145
|
+
## CLI reference
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
dotlayer [options] <command>
|
|
149
|
+
|
|
150
|
+
Commands:
|
|
151
|
+
install Detect system, stow all layers, install system files
|
|
152
|
+
update Pull repos, re-stow all layers
|
|
153
|
+
status Show detected OS, profile, distros, groups, and packages
|
|
154
|
+
doctor Check for broken symlinks, missing repos, missing stow
|
|
155
|
+
adopt Move config files into a stow package and restow
|
|
156
|
+
version Print version
|
|
157
|
+
|
|
158
|
+
Options:
|
|
159
|
+
-c, --config PATH Config file path
|
|
160
|
+
-n, --dry-run Show what would be done without making changes
|
|
161
|
+
-p, --private Use private repo (for adopt command)
|
|
162
|
+
-v, --verbose Verbose output
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### adopt
|
|
166
|
+
|
|
167
|
+
Moves existing config files or directories into a stow package, then restows so the originals become symlinks managed by stow.
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
# Move a single directory
|
|
171
|
+
dotlayer adopt ~/.config/lazygit config
|
|
172
|
+
|
|
173
|
+
# Move multiple paths at once
|
|
174
|
+
dotlayer adopt ~/.config/lazygit ~/.config/lazydocker config
|
|
175
|
+
|
|
176
|
+
# Move into the private repo
|
|
177
|
+
dotlayer adopt --private ~/.config/lazysql config
|
|
178
|
+
|
|
179
|
+
# Preview without moving anything
|
|
180
|
+
dotlayer adopt --dry-run ~/.config/lazygit config
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The last argument is always the package name. Dotlayer finds the first repo containing that package, or falls back to the first repo for new packages.
|
|
184
|
+
|
|
185
|
+
## Documentation
|
|
186
|
+
|
|
187
|
+
- [Architecture](docs/architecture.md) — system design, data flow, and class responsibilities
|
|
188
|
+
- [Contributing](CONTRIBUTING.md) — setup, testing, and contribution guidelines
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
[O-SaaSy](https://osaasy.dev)
|
data/exe/dotlayer
ADDED
data/lib/dotlayer/cli.rb
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
|
|
3
|
+
module Dotlayer
|
|
4
|
+
class CLI
|
|
5
|
+
def run(argv = ARGV)
|
|
6
|
+
if argv.first == "--version"
|
|
7
|
+
puts "dotlayer #{VERSION}"
|
|
8
|
+
return
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
options = parse_global_options(argv)
|
|
12
|
+
command = argv.shift
|
|
13
|
+
config = Config.new(options[:config])
|
|
14
|
+
|
|
15
|
+
case command
|
|
16
|
+
when "status" then Commands::Status.new(config:).run
|
|
17
|
+
when "install" then Commands::Install.new(config:, dry_run: options[:dry_run], verbose: options[:verbose]).run
|
|
18
|
+
when "update" then Commands::Update.new(config:, dry_run: options[:dry_run], verbose: options[:verbose]).run
|
|
19
|
+
when "doctor" then Commands::Doctor.new(config:).run
|
|
20
|
+
when "adopt" then run_adopt(config:, argv:, options:)
|
|
21
|
+
when "version"
|
|
22
|
+
puts "dotlayer #{VERSION}"
|
|
23
|
+
else
|
|
24
|
+
print_usage
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def parse_global_options(argv)
|
|
31
|
+
options = { dry_run: false, verbose: false, config: nil, private: false }
|
|
32
|
+
|
|
33
|
+
OptionParser.new do |opts|
|
|
34
|
+
opts.on("-c", "--config PATH", "Config file path") { |v| options[:config] = v }
|
|
35
|
+
opts.on("-n", "--dry-run", "Show what would be done") { options[:dry_run] = true }
|
|
36
|
+
opts.on("-v", "--verbose", "Verbose output") { options[:verbose] = true }
|
|
37
|
+
opts.on("-p", "--private", "Use private repo") { options[:private] = true }
|
|
38
|
+
end.parse!(argv)
|
|
39
|
+
|
|
40
|
+
options
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def run_adopt(config:, argv:, options:)
|
|
44
|
+
package = argv.pop
|
|
45
|
+
paths = argv
|
|
46
|
+
|
|
47
|
+
if paths.empty? || package.nil?
|
|
48
|
+
abort "Usage: dotlayer adopt <path>... <package>"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Commands::Adopt.new(
|
|
52
|
+
config:, paths:, package:,
|
|
53
|
+
private_repo: options[:private],
|
|
54
|
+
dry_run: options[:dry_run], verbose: options[:verbose]
|
|
55
|
+
).run
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def print_usage
|
|
59
|
+
puts <<~USAGE
|
|
60
|
+
Usage: dotlayer [options] <command>
|
|
61
|
+
|
|
62
|
+
Commands:
|
|
63
|
+
install Detect system, stow all layers, install system files
|
|
64
|
+
update Pull repos, re-stow all layers
|
|
65
|
+
status Show detected OS, profile, distros, and packages
|
|
66
|
+
doctor Check for broken symlinks, conflicts, missing deps
|
|
67
|
+
adopt Move config into a stow package and restow
|
|
68
|
+
version Print version
|
|
69
|
+
|
|
70
|
+
Options:
|
|
71
|
+
-c, --config PATH Config file path (default: dotlayer.yml in repo)
|
|
72
|
+
-n, --dry-run Show what would be done without making changes
|
|
73
|
+
-p, --private Use private repo (for adopt command)
|
|
74
|
+
-v, --verbose Verbose output
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
dotlayer adopt ~/.config/lazygit config
|
|
78
|
+
dotlayer adopt ~/.config/lazygit ~/.config/lazydocker config
|
|
79
|
+
dotlayer adopt --private ~/.config/lazysql config
|
|
80
|
+
|
|
81
|
+
USAGE
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Dotlayer
|
|
4
|
+
module Commands
|
|
5
|
+
class Adopt
|
|
6
|
+
include Output
|
|
7
|
+
|
|
8
|
+
def initialize(config:, paths:, package:, private_repo: false, dry_run: false, verbose: false)
|
|
9
|
+
@config = config
|
|
10
|
+
@paths = paths
|
|
11
|
+
@package = package
|
|
12
|
+
@private_repo = private_repo
|
|
13
|
+
@dry_run = dry_run
|
|
14
|
+
@verbose = verbose
|
|
15
|
+
@target = File.expand_path(@config.target)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
repo_path = find_repo
|
|
20
|
+
unless repo_path
|
|
21
|
+
abort "Error: package '#{@package}' not found in any repo"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
stow = Stow.new(target: @target, dry_run: @dry_run, verbose: @verbose)
|
|
25
|
+
|
|
26
|
+
@paths.each do |path|
|
|
27
|
+
adopt_path(File.expand_path(path), repo_path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
restow_package(stow, repo_path, @package, verb: "Restowing")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def find_repo
|
|
36
|
+
if @private_repo
|
|
37
|
+
repo = @config.repos.find(&:private)
|
|
38
|
+
return repo.path if repo
|
|
39
|
+
abort "Error: no private repo found in config (add private: true to a repo)"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@config.repos.each do |repo|
|
|
43
|
+
next unless Dir.exist?(repo.path)
|
|
44
|
+
|
|
45
|
+
pkg_dir = File.join(repo.path, @package)
|
|
46
|
+
return repo.path if Dir.exist?(pkg_dir)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Package doesn't exist yet — use first repo
|
|
50
|
+
@config.repos.first&.path
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def adopt_path(source, repo_path)
|
|
54
|
+
unless File.exist?(source)
|
|
55
|
+
error " Skipping #{source}: does not exist"
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
relative = relative_to_target(source)
|
|
60
|
+
unless relative
|
|
61
|
+
error " Skipping #{source}: not under target #{@target}"
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
dest = File.join(repo_path, @package, relative)
|
|
66
|
+
|
|
67
|
+
if File.exist?(dest)
|
|
68
|
+
warning " Skipping #{relative}: already exists in #{@package}"
|
|
69
|
+
return
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
puts " Moving #{green(relative)} → #{@package}/"
|
|
73
|
+
|
|
74
|
+
return if @dry_run
|
|
75
|
+
|
|
76
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
77
|
+
FileUtils.mv(source, dest)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def relative_to_target(path)
|
|
81
|
+
path = File.expand_path(path)
|
|
82
|
+
return nil unless path.start_with?("#{@target}/")
|
|
83
|
+
|
|
84
|
+
path.delete_prefix("#{@target}/")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
module Dotlayer
|
|
2
|
+
module Commands
|
|
3
|
+
class Doctor
|
|
4
|
+
include Output
|
|
5
|
+
|
|
6
|
+
def initialize(config:, detector: nil)
|
|
7
|
+
@config = config
|
|
8
|
+
@detector = detector || Detector.new(config: @config)
|
|
9
|
+
@issues = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run
|
|
13
|
+
detection = @detector.detect
|
|
14
|
+
resolver = Resolver.new(config: @config, detection: detection)
|
|
15
|
+
@packages = resolver.resolve
|
|
16
|
+
|
|
17
|
+
heading "Dotlayer Doctor"
|
|
18
|
+
puts
|
|
19
|
+
|
|
20
|
+
check_stow_installed
|
|
21
|
+
check_repos_exist
|
|
22
|
+
check_packages_exist(@packages)
|
|
23
|
+
check_broken_symlinks
|
|
24
|
+
|
|
25
|
+
puts
|
|
26
|
+
if @issues.empty?
|
|
27
|
+
ok "No issues found."
|
|
28
|
+
else
|
|
29
|
+
error "#{@issues.size} issue(s) found:"
|
|
30
|
+
@issues.each { |issue| puts " - #{issue}" }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def check_stow_installed
|
|
37
|
+
print " Checking stow... "
|
|
38
|
+
if system("which", "stow", out: File::NULL, err: File::NULL)
|
|
39
|
+
ok "installed"
|
|
40
|
+
else
|
|
41
|
+
error "missing"
|
|
42
|
+
@issues << "GNU Stow is not installed"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def check_repos_exist
|
|
47
|
+
@config.repos.each do |repo|
|
|
48
|
+
print " Checking repo #{repo.path}... "
|
|
49
|
+
if Dir.exist?(repo.path)
|
|
50
|
+
ok "exists"
|
|
51
|
+
else
|
|
52
|
+
error "missing"
|
|
53
|
+
@issues << "Repo not found: #{repo.path}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def check_packages_exist(packages)
|
|
59
|
+
packages.each do |repo_path, package|
|
|
60
|
+
pkg_path = File.join(repo_path, package)
|
|
61
|
+
unless Dir.exist?(pkg_path)
|
|
62
|
+
@issues << "Package directory missing: #{pkg_path}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def check_broken_symlinks
|
|
68
|
+
print " Checking for broken symlinks in #{@config.target}... "
|
|
69
|
+
broken = find_broken_symlinks(@config.target)
|
|
70
|
+
if broken.empty?
|
|
71
|
+
ok "none"
|
|
72
|
+
else
|
|
73
|
+
warning "#{broken.size} found"
|
|
74
|
+
broken.each do |link|
|
|
75
|
+
target = File.readlink(link) rescue "(deleted)"
|
|
76
|
+
@issues << "Broken symlink: #{link} -> #{target}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def find_broken_symlinks(target)
|
|
82
|
+
broken = []
|
|
83
|
+
scan_dirs = @packages.flat_map { |repo_path, package|
|
|
84
|
+
pkg_dir = File.join(repo_path, package)
|
|
85
|
+
next [] unless Dir.exist?(pkg_dir)
|
|
86
|
+
|
|
87
|
+
Dir.children(pkg_dir).map { |child| File.join(target, child) }
|
|
88
|
+
}.uniq
|
|
89
|
+
|
|
90
|
+
scan_dirs.each do |dir|
|
|
91
|
+
next unless File.exist?(dir) || File.symlink?(dir)
|
|
92
|
+
|
|
93
|
+
if File.symlink?(dir) && !File.exist?(dir)
|
|
94
|
+
broken << dir
|
|
95
|
+
next
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
next unless File.directory?(dir)
|
|
99
|
+
|
|
100
|
+
Dir.glob(File.join(dir, "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
101
|
+
broken << path if File.symlink?(path) && !File.exist?(path)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
broken
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module Dotlayer
|
|
2
|
+
module Commands
|
|
3
|
+
class Install
|
|
4
|
+
include Output
|
|
5
|
+
|
|
6
|
+
def initialize(config:, detector: nil, dry_run: false, verbose: false)
|
|
7
|
+
@config = config
|
|
8
|
+
@detector = detector || Detector.new(config: @config)
|
|
9
|
+
@dry_run = dry_run
|
|
10
|
+
@verbose = verbose
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
detection = @detector.detect
|
|
15
|
+
resolver = Resolver.new(config: @config, detection: detection)
|
|
16
|
+
packages = resolver.resolve
|
|
17
|
+
stow = Stow.new(target: @config.target, dry_run: @dry_run, verbose: @verbose)
|
|
18
|
+
|
|
19
|
+
heading "Installing dotfiles (#{detection.os}/#{detection.profile})"
|
|
20
|
+
puts
|
|
21
|
+
|
|
22
|
+
packages.each do |repo_path, package|
|
|
23
|
+
restow_package(stow, repo_path, package)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
install_system_files if detection.os == "linux"
|
|
27
|
+
|
|
28
|
+
puts
|
|
29
|
+
puts "Done! #{packages.size} package(s) stowed."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def install_system_files
|
|
35
|
+
return if @config.system_files.empty?
|
|
36
|
+
|
|
37
|
+
first_repo = @config.repos.first
|
|
38
|
+
unless first_repo
|
|
39
|
+
warning " Skipping system files: no repos configured"
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
puts
|
|
44
|
+
heading "System files"
|
|
45
|
+
|
|
46
|
+
unless @dry_run
|
|
47
|
+
return unless confirm("The following files will be installed with sudo:",
|
|
48
|
+
@config.system_files.map { |e| e["dest"] })
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@config.system_files.each do |entry|
|
|
52
|
+
source = File.expand_path(entry["source"], first_repo.path)
|
|
53
|
+
dest = entry["dest"]
|
|
54
|
+
mode = entry["mode"]
|
|
55
|
+
|
|
56
|
+
print " #{dest}... "
|
|
57
|
+
|
|
58
|
+
if @dry_run
|
|
59
|
+
warning("dry-run")
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
unless system("sudo", "cp", source, dest)
|
|
64
|
+
error("failed")
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if mode && !system("sudo", "chmod", mode, dest)
|
|
69
|
+
error("chmod failed")
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
ok
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
run_hooks("after_system_files")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def confirm(message, items)
|
|
80
|
+
puts " #{message}"
|
|
81
|
+
items.each { |item| puts " #{item}" }
|
|
82
|
+
print " Continue? [y/N] "
|
|
83
|
+
if $stdin.gets&.strip&.downcase == "y"
|
|
84
|
+
true
|
|
85
|
+
else
|
|
86
|
+
warning(" Skipped.")
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def run_hooks(name)
|
|
92
|
+
commands = Array(@config.hooks[name])
|
|
93
|
+
return if commands.empty?
|
|
94
|
+
|
|
95
|
+
puts
|
|
96
|
+
heading "Running #{name} hooks"
|
|
97
|
+
|
|
98
|
+
unless @dry_run
|
|
99
|
+
return unless confirm("The following commands will be executed:", commands)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
commands.each do |cmd|
|
|
103
|
+
print " #{cmd}... "
|
|
104
|
+
if @dry_run
|
|
105
|
+
warning("dry-run")
|
|
106
|
+
else
|
|
107
|
+
system(cmd) ? ok : error("failed")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Dotlayer
|
|
2
|
+
module Commands
|
|
3
|
+
class Status
|
|
4
|
+
include Output
|
|
5
|
+
|
|
6
|
+
def initialize(config:, detector: nil)
|
|
7
|
+
@config = config
|
|
8
|
+
@detector = detector || Detector.new(config: @config)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def run
|
|
12
|
+
detection = @detector.detect
|
|
13
|
+
resolver = Resolver.new(config: @config, detection: detection)
|
|
14
|
+
packages = resolver.resolve
|
|
15
|
+
|
|
16
|
+
print_detection(detection)
|
|
17
|
+
print_packages(packages)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def print_detection(detection)
|
|
23
|
+
heading "System Detection"
|
|
24
|
+
puts " OS: #{detection.os}"
|
|
25
|
+
puts " Profile: #{detection.profile}"
|
|
26
|
+
puts " Distros: #{detection.distros.empty? ? "(none)" : detection.distros.join(", ")}"
|
|
27
|
+
puts " Groups: #{detection.groups.empty? ? "(none)" : detection.groups.join(", ")}"
|
|
28
|
+
puts
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def print_packages(packages)
|
|
32
|
+
heading "Packages to stow"
|
|
33
|
+
|
|
34
|
+
packages.each do |repo_path, package|
|
|
35
|
+
repo_name = File.basename(repo_path)
|
|
36
|
+
puts " #{repo_name}/#{green(package)}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
puts
|
|
40
|
+
puts " #{packages.size} package(s) total"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Dotlayer
|
|
4
|
+
module Commands
|
|
5
|
+
class Update
|
|
6
|
+
include Output
|
|
7
|
+
|
|
8
|
+
def initialize(config:, detector: nil, dry_run: false, verbose: false)
|
|
9
|
+
@config = config
|
|
10
|
+
@detector = detector || Detector.new(config: @config)
|
|
11
|
+
@dry_run = dry_run
|
|
12
|
+
@verbose = verbose
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
pull_repos
|
|
17
|
+
restow_packages
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def pull_repos
|
|
23
|
+
heading "Pulling repos"
|
|
24
|
+
|
|
25
|
+
@config.repos.each do |repo|
|
|
26
|
+
next unless Dir.exist?(File.join(repo.path, ".git"))
|
|
27
|
+
|
|
28
|
+
print " #{File.basename(repo.path)}... "
|
|
29
|
+
|
|
30
|
+
if @dry_run
|
|
31
|
+
warning("dry-run")
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
output, status = Open3.capture2e("git", "-C", repo.path, "pull", "--rebase")
|
|
36
|
+
if status.success?
|
|
37
|
+
ok
|
|
38
|
+
else
|
|
39
|
+
error("failed")
|
|
40
|
+
puts " #{output.strip}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
puts
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def restow_packages
|
|
48
|
+
detection = @detector.detect
|
|
49
|
+
resolver = Resolver.new(config: @config, detection: detection)
|
|
50
|
+
packages = resolver.resolve
|
|
51
|
+
stow = Stow.new(target: @config.target, dry_run: @dry_run, verbose: @verbose)
|
|
52
|
+
|
|
53
|
+
heading "Re-stowing packages"
|
|
54
|
+
|
|
55
|
+
packages.each do |repo_path, package|
|
|
56
|
+
restow_package(stow, repo_path, package, verb: "Restowing")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
puts
|
|
60
|
+
puts "Done! #{packages.size} package(s) restowed."
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Dotlayer
|
|
4
|
+
Repo = Data.define(:path, :private, :packages)
|
|
5
|
+
|
|
6
|
+
class Config
|
|
7
|
+
DEFAULT_PACKAGES = %w[stow bin git zsh config].freeze
|
|
8
|
+
DEFAULT_CONFIG_PATHS = %w[
|
|
9
|
+
~/.config/dotlayer/dotlayer.yml
|
|
10
|
+
~/.public_dotfiles/dotlayer.yml
|
|
11
|
+
~/.dotfiles/dotlayer.yml
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :path
|
|
15
|
+
|
|
16
|
+
def initialize(path = nil)
|
|
17
|
+
@path = path || discover_config
|
|
18
|
+
@data = load_config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def target
|
|
22
|
+
File.expand_path(@data.fetch("target", "~"))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def repos
|
|
26
|
+
@repos ||= @data.fetch("repos", [{ "path" => "~/.public_dotfiles" }]).filter_map do |entry|
|
|
27
|
+
path = entry["path"]&.to_s
|
|
28
|
+
next if path.nil? || path.empty?
|
|
29
|
+
|
|
30
|
+
Repo.new(
|
|
31
|
+
path: File.expand_path(path),
|
|
32
|
+
private: entry["private"] || false,
|
|
33
|
+
packages: entry["packages"]&.freeze
|
|
34
|
+
)
|
|
35
|
+
end.freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def packages
|
|
39
|
+
@packages ||= @data.fetch("packages") { DEFAULT_PACKAGES }.freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def profile_detect
|
|
43
|
+
@data.dig("profiles", "detect") || "hostnamectl chassis"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def profile_env
|
|
47
|
+
@data.dig("profiles", "env") || "DOTLAYER_PROFILE"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def distros
|
|
51
|
+
@data.fetch("distros", {})
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def groups
|
|
55
|
+
@data.fetch("groups", {})
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def system_files
|
|
59
|
+
@data.fetch("system_files", [])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def hooks
|
|
63
|
+
@data.fetch("hooks", {})
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def load_config
|
|
69
|
+
return {} unless @path && File.exist?(@path)
|
|
70
|
+
|
|
71
|
+
data = YAML.safe_load_file(@path, permitted_classes: [Symbol]) || {}
|
|
72
|
+
return data if data.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
abort "Error: #{@path} must contain a YAML mapping, got #{data.class}"
|
|
75
|
+
rescue Psych::SyntaxError => e
|
|
76
|
+
abort "Error: invalid YAML in #{@path}: #{e.message}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def discover_config
|
|
80
|
+
DEFAULT_CONFIG_PATHS
|
|
81
|
+
.map { |p| File.expand_path(p) }
|
|
82
|
+
.find { |p| File.exist?(p) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Dotlayer
|
|
4
|
+
Detection = Data.define(:os, :profile, :distros, :groups)
|
|
5
|
+
|
|
6
|
+
class Detector
|
|
7
|
+
def initialize(config: Config.new)
|
|
8
|
+
@config = config
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def detect
|
|
12
|
+
Detection.new(os: detect_os, profile: detect_profile, distros: detect_distros, groups: detect_groups)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def detect_os
|
|
18
|
+
case RbConfig::CONFIG["host_os"]
|
|
19
|
+
when /darwin/ then "macos"
|
|
20
|
+
when /linux/ then "linux"
|
|
21
|
+
else "unknown"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def detect_profile
|
|
26
|
+
from_env = ENV[@config.profile_env]
|
|
27
|
+
return from_env if from_env && !from_env.empty?
|
|
28
|
+
|
|
29
|
+
if @config.profile_detect && !@config.profile_detect.empty?
|
|
30
|
+
output, status = Open3.capture2(@config.profile_detect)
|
|
31
|
+
return output.strip if status.success? && !output.strip.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
"desktop"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def detect_distros
|
|
38
|
+
@config.distros.select { |_name, entry| command_detected?(entry) }.keys
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def detect_groups
|
|
42
|
+
@config.groups.select { |_name, entry| command_detected?(entry) }.keys
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def command_detected?(entry)
|
|
46
|
+
cmd = entry["detect"]
|
|
47
|
+
return false unless cmd.is_a?(String) && !cmd.empty?
|
|
48
|
+
|
|
49
|
+
_, status = Open3.capture2e("sh", "-c", cmd)
|
|
50
|
+
status.success?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Dotlayer
|
|
2
|
+
module Output
|
|
3
|
+
def heading(text)
|
|
4
|
+
puts "\e[1m#{text}\e[0m"
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def ok(text = "ok")
|
|
8
|
+
puts "\e[32m#{text}\e[0m"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def error(text)
|
|
12
|
+
puts "\e[31m#{text}\e[0m"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def warning(text)
|
|
16
|
+
puts "\e[33m#{text}\e[0m"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def info(text)
|
|
20
|
+
puts "\e[36m#{text}\e[0m"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def green(text)
|
|
24
|
+
"\e[32m#{text}\e[0m"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def red(text)
|
|
28
|
+
"\e[31m#{text}\e[0m"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def yellow(text)
|
|
32
|
+
"\e[33m#{text}\e[0m"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def bold(text)
|
|
36
|
+
"\e[1m#{text}\e[0m"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def restow_package(stow, repo_path, package, verb: "Stowing")
|
|
40
|
+
print " #{verb} #{green(package)}... "
|
|
41
|
+
if stow.dry_run?
|
|
42
|
+
warning("dry-run")
|
|
43
|
+
elsif stow.restow(repo_path, package)
|
|
44
|
+
ok
|
|
45
|
+
else
|
|
46
|
+
error("failed: #{stow.last_error}")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
module Dotlayer
|
|
4
|
+
class Resolver
|
|
5
|
+
def initialize(config:, detection:)
|
|
6
|
+
@config = config
|
|
7
|
+
@detection = detection
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def resolve
|
|
11
|
+
packages = []
|
|
12
|
+
|
|
13
|
+
@config.repos.each do |repo|
|
|
14
|
+
next unless Dir.exist?(repo.path)
|
|
15
|
+
|
|
16
|
+
base_packages = repo.packages || @config.packages
|
|
17
|
+
packages.concat(resolve_repo(repo, base_packages))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
packages
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def resolve_repo(repo, base_packages)
|
|
26
|
+
# Repos with explicit packages always use layered resolution,
|
|
27
|
+
# even if the directories don't exist yet
|
|
28
|
+
if repo.packages || base_packages.any? { |pkg| Dir.exist?(File.join(repo.path, pkg)) }
|
|
29
|
+
resolve_layered_repo(repo.path, base_packages)
|
|
30
|
+
else
|
|
31
|
+
resolve_all_packages(repo.path)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Repos with base packages use layered convention matching
|
|
36
|
+
# plus standalone directories that don't match any layer pattern
|
|
37
|
+
def resolve_layered_repo(repo_path, base_packages)
|
|
38
|
+
packages = []
|
|
39
|
+
|
|
40
|
+
base_packages.each do |pkg|
|
|
41
|
+
packages << [repo_path, pkg] if Dir.exist?(File.join(repo_path, pkg))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
dirs = top_level_dirs(repo_path)
|
|
45
|
+
layers, matched_dirs = resolve_layers(repo_path, base_packages, dirs)
|
|
46
|
+
packages.concat(layers)
|
|
47
|
+
|
|
48
|
+
standalone = resolve_standalone(repo_path, base_packages, matched_dirs, dirs)
|
|
49
|
+
packages.concat(standalone)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Repos without base packages stow all top-level directories
|
|
53
|
+
def resolve_all_packages(repo_path)
|
|
54
|
+
top_level_dirs(repo_path)
|
|
55
|
+
.sort
|
|
56
|
+
.map { |d| [repo_path, d] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def resolve_layers(repo_path, base_packages, dirs)
|
|
60
|
+
os_packages = []
|
|
61
|
+
distro_packages = []
|
|
62
|
+
distro_profile_packages = []
|
|
63
|
+
group_packages = []
|
|
64
|
+
matched_dirs = Set.new(base_packages)
|
|
65
|
+
|
|
66
|
+
dirs.each do |dir|
|
|
67
|
+
next if base_packages.include?(dir)
|
|
68
|
+
|
|
69
|
+
case dir
|
|
70
|
+
when suffix("-#{@detection.os}")
|
|
71
|
+
os_packages << [repo_path, dir]
|
|
72
|
+
matched_dirs << dir
|
|
73
|
+
when *@detection.distros.map { |d| suffix("-#{d}") }
|
|
74
|
+
distro_packages << [repo_path, dir]
|
|
75
|
+
matched_dirs << dir
|
|
76
|
+
when *@detection.distros.map { |d| suffix("-#{d}-#{@detection.profile}") }
|
|
77
|
+
distro_profile_packages << [repo_path, dir]
|
|
78
|
+
matched_dirs << dir
|
|
79
|
+
when *@detection.groups.map { |g| suffix("-#{g}") }
|
|
80
|
+
group_packages << [repo_path, dir]
|
|
81
|
+
matched_dirs << dir
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
layers = os_packages + distro_packages + distro_profile_packages + group_packages
|
|
86
|
+
[layers, matched_dirs]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def resolve_standalone(repo_path, base_packages, matched_dirs, dirs)
|
|
90
|
+
dirs
|
|
91
|
+
.reject { |d| matched_dirs.include?(d) || layer_variant?(d, base_packages) }
|
|
92
|
+
.sort
|
|
93
|
+
.map { |d| [repo_path, d] }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def top_level_dirs(repo_path)
|
|
97
|
+
Dir.children(repo_path)
|
|
98
|
+
.select { |d| File.directory?(File.join(repo_path, d)) }
|
|
99
|
+
.reject { |d| d.start_with?(".") }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns true if dir looks like a layer variant of any base package.
|
|
103
|
+
# By convention, any <base>-<suffix> directory is a layer variant
|
|
104
|
+
# for another OS, distro, profile, or group.
|
|
105
|
+
def layer_variant?(dir, base_packages)
|
|
106
|
+
base_packages.any? { |pkg| dir.start_with?("#{pkg}-") }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def suffix(s)
|
|
110
|
+
->(dir) { dir.end_with?(s) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Dotlayer
|
|
4
|
+
class Stow
|
|
5
|
+
attr_reader :last_error
|
|
6
|
+
|
|
7
|
+
def initialize(target: "~", dry_run: false, verbose: false)
|
|
8
|
+
@target = File.expand_path(target)
|
|
9
|
+
@dry_run = dry_run
|
|
10
|
+
@verbose = verbose
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def dry_run? = @dry_run
|
|
14
|
+
|
|
15
|
+
def restow(repo_path, package)
|
|
16
|
+
args = ["stow", "-R"]
|
|
17
|
+
args << "-v" if @verbose
|
|
18
|
+
args << "-d" << repo_path
|
|
19
|
+
args << "-t" << @target
|
|
20
|
+
args << package
|
|
21
|
+
|
|
22
|
+
if @verbose || @dry_run
|
|
23
|
+
$stderr.puts " #{args.join(" ")}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
return true if @dry_run
|
|
27
|
+
|
|
28
|
+
output, status = Open3.capture2e(*args)
|
|
29
|
+
@last_error = status.success? ? nil : output.strip
|
|
30
|
+
status.success?
|
|
31
|
+
rescue Errno::ENOENT
|
|
32
|
+
@last_error = "GNU Stow is not installed. Install it with your package manager."
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/dotlayer.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Dotlayer
|
|
2
|
+
VERSION = "0.2.0"
|
|
3
|
+
|
|
4
|
+
autoload :CLI, "dotlayer/cli"
|
|
5
|
+
autoload :Config, "dotlayer/config"
|
|
6
|
+
autoload :Detection, "dotlayer/detector"
|
|
7
|
+
autoload :Detector, "dotlayer/detector"
|
|
8
|
+
autoload :Output, "dotlayer/output"
|
|
9
|
+
autoload :Repo, "dotlayer/config"
|
|
10
|
+
autoload :Resolver, "dotlayer/resolver"
|
|
11
|
+
autoload :Stow, "dotlayer/stow"
|
|
12
|
+
|
|
13
|
+
module Commands
|
|
14
|
+
autoload :Adopt, "dotlayer/commands/adopt"
|
|
15
|
+
autoload :Doctor, "dotlayer/commands/doctor"
|
|
16
|
+
autoload :Install, "dotlayer/commands/install"
|
|
17
|
+
autoload :Status, "dotlayer/commands/status"
|
|
18
|
+
autoload :Update, "dotlayer/commands/update"
|
|
19
|
+
end
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: dotlayer
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Douglas Andrade
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: A convention-driven CLI wrapper around GNU Stow that adds layered package
|
|
13
|
+
resolution, auto-detection, private repo overlays, and system file management.
|
|
14
|
+
email:
|
|
15
|
+
- douglas@51street.dev
|
|
16
|
+
executables:
|
|
17
|
+
- dotlayer
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- exe/dotlayer
|
|
24
|
+
- lib/dotlayer.rb
|
|
25
|
+
- lib/dotlayer/cli.rb
|
|
26
|
+
- lib/dotlayer/commands/adopt.rb
|
|
27
|
+
- lib/dotlayer/commands/doctor.rb
|
|
28
|
+
- lib/dotlayer/commands/install.rb
|
|
29
|
+
- lib/dotlayer/commands/status.rb
|
|
30
|
+
- lib/dotlayer/commands/update.rb
|
|
31
|
+
- lib/dotlayer/config.rb
|
|
32
|
+
- lib/dotlayer/detector.rb
|
|
33
|
+
- lib/dotlayer/output.rb
|
|
34
|
+
- lib/dotlayer/resolver.rb
|
|
35
|
+
- lib/dotlayer/stow.rb
|
|
36
|
+
homepage: https://github.com/douglas/dotlayer
|
|
37
|
+
licenses:
|
|
38
|
+
- O-SaaSy
|
|
39
|
+
metadata:
|
|
40
|
+
homepage_uri: https://github.com/douglas/dotlayer
|
|
41
|
+
source_code_uri: https://github.com/douglas/dotlayer
|
|
42
|
+
rdoc_options: []
|
|
43
|
+
require_paths:
|
|
44
|
+
- lib
|
|
45
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '3.2'
|
|
50
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0'
|
|
55
|
+
requirements: []
|
|
56
|
+
rubygems_version: 4.0.3
|
|
57
|
+
specification_version: 4
|
|
58
|
+
summary: Layered dotfiles management with GNU Stow
|
|
59
|
+
test_files: []
|