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 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
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
3
+ require "dotlayer"
4
+
5
+ Dotlayer::CLI.new.run(ARGV)
@@ -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: []