dotsync 0.1.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.
@@ -0,0 +1,65 @@
1
+ module Dotsync
2
+ class MappingEntry
3
+ include Dotsync::PathUtils
4
+
5
+ attr_reader :original_src, :original_dest, :original_ignores
6
+
7
+ def initialize(hash)
8
+ @original_src = hash["src"]
9
+ @original_dest = hash["dest"]
10
+ @original_ignores = Array(hash["ignore"])
11
+ @force = hash["force"] || false
12
+
13
+ @sanitized_src = sanitize_path(@original_src)
14
+ @sanitized_dest = sanitize_path(@original_dest)
15
+ @sanitized_ignore = @original_ignores.map { |path| File.join(@sanitized_src, path) }
16
+ end
17
+
18
+ def src
19
+ @sanitized_src
20
+ end
21
+
22
+ def dest
23
+ @sanitized_dest
24
+ end
25
+
26
+ def ignores
27
+ @sanitized_ignore
28
+ end
29
+
30
+ def force?
31
+ @force
32
+ end
33
+
34
+ def valid?
35
+ File.exist?(@sanitized_src) && File.exist?(@sanitized_dest)
36
+ end
37
+
38
+ def to_s
39
+ force_icon = force? ? " #{icon_delete}" : ""
40
+ "#{original_src} → #{original_dest}#{force_icon}"
41
+ end
42
+
43
+ def applied_to(path)
44
+ relative_path = if Pathname.new(path).absolute?
45
+ path.delete_prefix(File.join(src, "/"))
46
+ else
47
+ path
48
+ end
49
+
50
+ Dotsync::MappingEntry.new(
51
+ "src" => File.join(@original_src, relative_path),
52
+ "dest" => File.join(@original_dest, relative_path),
53
+ "force" => @force,
54
+ "ignore" => @original_ignores
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ def icon_delete
61
+ Dotsync::Logger::ICONS[:delete]
62
+ end
63
+ end
64
+ end
65
+
@@ -0,0 +1,33 @@
1
+ module Dotsync
2
+ class PullActionConfig < BaseConfig
3
+ include XDGBaseDirectorySpec
4
+
5
+ def mappings
6
+ mappings_list = section["mappings"]
7
+ Array(mappings_list).map { |mapping| Dotsync::MappingEntry.new(mapping) }
8
+ end
9
+
10
+ def backups_root
11
+ File.join(xdg_data_home, "dotsync", "backups")
12
+ end
13
+
14
+ private
15
+
16
+ SECTION_NAME = "pull"
17
+
18
+ def section_name
19
+ SECTION_NAME
20
+ end
21
+
22
+ def validate!
23
+ validate_section_present!
24
+ validate_key_present! "mappings"
25
+
26
+ Array(section["mappings"]).each_with_index do |mapping, index|
27
+ unless mapping.is_a?(Hash) && mapping.key?("src") && mapping.key?("dest")
28
+ raise "Configuration error in mapping ##{index + 1}: Each mapping must have 'src' and 'dest' keys."
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,27 @@
1
+ module Dotsync
2
+ class PushActionConfig < BaseConfig
3
+ def mappings
4
+ mappings_list = section["mappings"]
5
+ Array(mappings_list).map { |mapping| Dotsync::MappingEntry.new(mapping) }
6
+ end
7
+
8
+ private
9
+
10
+ SECTION_NAME = "push"
11
+
12
+ def section_name
13
+ SECTION_NAME
14
+ end
15
+
16
+ def validate!
17
+ validate_section_present!
18
+ validate_key_present! "mappings"
19
+
20
+ Array(section["mappings"]).each_with_index do |mapping, index|
21
+ unless mapping.is_a?(Hash) && mapping.key?("src") && mapping.key?("dest")
22
+ raise "Configuration error in mapping ##{index + 1}: Each mapping must have 'src' and 'dest' keys."
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,12 @@
1
+ module Dotsync
2
+ class WatchActionConfig < PushActionConfig
3
+
4
+ private
5
+
6
+ SECTION_NAME = "watch"
7
+
8
+ def section_name
9
+ SECTION_NAME
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,20 @@
1
+ module Dotsync
2
+ # https://specifications.freedesktop.org/basedir-spec/latest/
3
+ module XDGBaseDirectorySpec
4
+ def xdg_data_home
5
+ File.expand_path(ENV["XDG_DATA_HOME"] || "$HOME/.local/share")
6
+ end
7
+
8
+ def xdf_config_home
9
+ File.expand_path(ENV["XDG_CONFIG_HOME"] || "$HOME/.config")
10
+ end
11
+
12
+ def xdf_cache_home
13
+ File.expand_path(ENV["XDG_CACHE_HOME"] || "$HOME/.cache")
14
+ end
15
+
16
+ def xdf_bin_home
17
+ File.expand_path(ENV["XDG_BIN_HOME"] || "$HOME/.local/bin")
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,74 @@
1
+ module Dotsync
2
+ class PullAction < BaseAction
3
+ def_delegator :@config, :mappings
4
+ def_delegator :@config, :backups_root
5
+
6
+ def execute
7
+ show_config
8
+ if create_backup
9
+ show_backup
10
+ purge_old_backups
11
+ end
12
+ pull_dotfiles
13
+ end
14
+
15
+ private
16
+
17
+ def show_config
18
+ info("Mappings:", icon: :source_dest)
19
+ mappings.each do |mapping|
20
+ force_icon = mapping.force? ? " #{icon_delete}" : ""
21
+ info(" src: #{mapping.original_src} -> dest: #{mapping.original_dest}#{force_icon}", icon: :copy)
22
+ info(" ignores: #{mapping.ignores.join(', ')}", icon: :exclude) if mapping.ignores.any?
23
+ end
24
+ end
25
+
26
+ def show_backup
27
+ action("Backup created:", icon: :backup)
28
+ info(" #{backup_path}")
29
+ end
30
+
31
+ def timestamp
32
+ Time.now.strftime('%Y%m%d%H%M%S')
33
+ end
34
+
35
+ def backup_path
36
+ @backup_path ||= File.join(backups_root, timestamp)
37
+ end
38
+
39
+ def create_backup
40
+ return false unless mappings.any? { |mapping| File.exist?(mapping.dest) }
41
+ FileUtils.mkdir_p(backup_path)
42
+ mappings.each do |mapping|
43
+ next unless File.exist?(mapping.dest)
44
+ if File.file?(mapping.src)
45
+ FileUtils.cp(mapping.dest, File.join(backup_path, File.basename(mapping.dest)))
46
+ else
47
+ FileUtils.cp_r(mapping.dest, File.join(backup_path, File.basename(mapping.dest)))
48
+ end
49
+ end
50
+ true
51
+ end
52
+
53
+ def purge_old_backups
54
+ backups = Dir[File.join(backups_root, '*')].sort.reverse
55
+ if backups.size > 10
56
+ info("Maximum of 10 backups retained")
57
+
58
+ backups[10..].each do |path|
59
+ FileUtils.rm_rf(path)
60
+ info("Old backup deleted: #{path}", icon: :delete)
61
+ end
62
+ end
63
+ end
64
+
65
+ def pull_dotfiles
66
+ mappings.each { |mapping| Dotsync::FileTransfer.new(mapping).transfer }
67
+ action("Dotfiles pulled", icon: :copy)
68
+ end
69
+
70
+ def icon_delete
71
+ Dotsync::Logger::ICONS[:delete]
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,30 @@
1
+ module Dotsync
2
+ class PushAction < BaseAction
3
+ def_delegator :@config, :mappings
4
+
5
+ def execute
6
+ show_config
7
+ push_dotfiles
8
+ end
9
+
10
+ private
11
+
12
+ def show_config
13
+ info("Mappings:", icon: :source_dest)
14
+ mappings.each do |mapping|
15
+ force_icon = mapping.force? ? " #{icon_delete}" : ""
16
+ info(" src: #{mapping.original_src} -> dest: #{mapping.original_dest}#{force_icon}", icon: :copy)
17
+ info(" ignores: #{mapping.original_ignores.join(', ')}", icon: :exclude) if mapping.ignores.any?
18
+ end
19
+ end
20
+
21
+ def push_dotfiles
22
+ mappings.each { |mapping| Dotsync::FileTransfer.new(mapping).transfer }
23
+ action("Dotfiles pushed", icon: :copy)
24
+ end
25
+
26
+ def icon_delete
27
+ Dotsync::Logger::ICONS[:delete]
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,75 @@
1
+ module Dotsync
2
+ class WatchAction < BaseAction
3
+ def_delegator :@config, :mappings
4
+
5
+ def initialize(config, logger)
6
+ super
7
+ setup_listeners
8
+ setup_logger_thread
9
+ setup_signal_trap
10
+ end
11
+
12
+ def execute
13
+ show_config
14
+
15
+ @listeners.each(&:start)
16
+
17
+ logger.action("Listening for changes...", icon: :listen)
18
+ info("Press Ctrl+C to exit.")
19
+ sleep
20
+ end
21
+
22
+ private
23
+
24
+ def show_config
25
+ info("Mappings:", icon: :watch)
26
+ mappings.each do |mapping|
27
+ info(" #{mapping}", icon: :copy)
28
+ info(" Excludes: #{mapping.ignores.join(', ')}", icon: :exclude) if mapping.ignores.any?
29
+ end
30
+ end
31
+
32
+ def setup_listeners
33
+ @listeners = mappings.map do |mapping|
34
+ src = mapping.src
35
+
36
+ # Determine the base directory to watch. If it's a directory, use it directly.
37
+ # Otherwise, use its parent directory.
38
+ base = File.directory?(src) ? src : File.dirname(src)
39
+
40
+ options = {}
41
+ # If the watched path is a file, create a pattern to match its name.
42
+ options[:pattern] = /^#{Regexp.escape(File.basename(src))}$/ unless File.directory?(src)
43
+ options[:ignore] = Regexp.union(mapping.ignores) if mapping.ignores.any?
44
+
45
+ Listen.to(base, options) do |modified, added, removed|
46
+ handle_file_changes(mapping, modified, added, removed)
47
+ end
48
+ end
49
+ end
50
+
51
+ def handle_file_changes(mapping, modified, added, removed)
52
+ (modified + added).each do |path|
53
+ new_mapping = mapping.applied_to(path)
54
+ logger.info("Copied file: #{new_mapping.original_src}", icon: :copy)
55
+ Dotsync::FileTransfer.new(new_mapping).transfer
56
+ end
57
+ removed.each do |path|
58
+ logger.info("File removed: #{path}", icon: :delete)
59
+ end
60
+ end
61
+
62
+ def setup_signal_trap
63
+ listeners = @listeners.dup
64
+ Signal.trap("INT") do
65
+ # Using a new thread to handle the signal trap context,
66
+ # as Signal.trap runs in a more restrictive environment
67
+ Thread.new do
68
+ logger.action("Shutting down listeners...", icon: :bell)
69
+ listeners.each(&:stop)
70
+ exit
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,3 @@
1
+ module Dotsync
2
+ class ConfigError < StandardError; end
3
+ end
@@ -0,0 +1,49 @@
1
+ module Dotsync
2
+ class FileTransfer
3
+ attr_reader :ignores
4
+
5
+ def initialize(config)
6
+ @src = config.src
7
+ @dest = config.dest
8
+ @force = config.force?
9
+ @ignores = config.ignores || []
10
+ end
11
+
12
+ def transfer
13
+ if File.file?(@src)
14
+ transfer_file(@src, @dest)
15
+ else
16
+ FileUtils.rm_rf(Dir.glob(File.join(@dest, '*'))) if @force
17
+ transfer_folder(@src, @dest)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def transfer_file(file_src, file_dest)
24
+ FileUtils.mkdir_p(File.dirname(file_dest))
25
+ FileUtils.cp(file_src, file_dest)
26
+ end
27
+
28
+ def transfer_folder(folder_src, folder_dest)
29
+ FileUtils.mkdir_p(folder_dest)
30
+ Dir.glob("#{folder_src}/*", File::FNM_DOTMATCH).each do |path|
31
+ next if ['.', '..'].include?(File.basename(path))
32
+
33
+ full_path = File.expand_path(path)
34
+ next if ignore?(full_path)
35
+
36
+ target = File.join(folder_dest, File.basename(path))
37
+ if File.file?(full_path)
38
+ FileUtils.cp(full_path, target)
39
+ else
40
+ transfer_folder(full_path, target)
41
+ end
42
+ end
43
+ end
44
+
45
+ def ignore?(path)
46
+ @ignores.any? { |ignore| path.start_with?(ignore) }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,61 @@
1
+ module Dotsync
2
+ class Logger
3
+ attr_accessor :output
4
+
5
+ # 🎨 Nerd Font Icons
6
+ ICONS = {
7
+ info: " ",
8
+ listen: " ",
9
+ error: " ",
10
+ watch: " ",
11
+ source: " ", #  ",
12
+ dest: " ", # " ",
13
+ delete: " ",
14
+ bell: " ",
15
+ copy: " ",
16
+ skip: " ",
17
+ done: " ",
18
+ backup: " ",
19
+ clean: " ",
20
+ }
21
+
22
+ def initialize(output = $stdout)
23
+ @output = output
24
+ end
25
+
26
+ def info(message, options = {})
27
+ log(:info, message, options)
28
+ end
29
+
30
+ def action(message, options = {})
31
+ log(:action, message, options)
32
+ end
33
+
34
+ def success(message)
35
+ log(:success, message, icon: :done)
36
+ end
37
+
38
+ def error(message)
39
+ log(:error, message, icon: :error)
40
+ end
41
+
42
+ def warning(message, options = {})
43
+ log(:warning, message, options)
44
+ end
45
+
46
+ def log(type, message, options = {})
47
+ icon = options[:icon]
48
+ color = {
49
+ info: 103, action: 153, error: 196, event: 141, warning: 31, copy: 32,
50
+ skip: 33, done: 32, backup: 35,
51
+ clean: 34
52
+ }[type] || 0
53
+
54
+ if icon.nil?
55
+ @output.puts message
56
+ else
57
+ @output.puts "\e[38;5;#{color}m\e[1m#{ICONS[icon]}#{message}\e[0m"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,26 @@
1
+ module Dotsync
2
+ module PathUtils
3
+ def expand_env_vars(path)
4
+ path.gsub(/\$(\w+)/) { ENV[$1] }
5
+ end
6
+
7
+ # Translates /tmp paths to /private/tmp paths on macOS
8
+ # Retains other paths as-is
9
+ # @param [String] path The input path to translate
10
+ # @return [String] The translated path
11
+ def translate_tmp_path(path)
12
+ if path.start_with?('/tmp') && RUBY_PLATFORM.include?('darwin')
13
+ path.sub('/tmp', '/private/tmp')
14
+ else
15
+ path
16
+ end
17
+ end
18
+
19
+ # Sanitizes a given path by expanding it and translating /tmp to /private/tmp
20
+ # @param [String] path The input path to sanitize
21
+ # @return [String] The sanitized path
22
+ def sanitize_path(path)
23
+ translate_tmp_path(File.expand_path(expand_env_vars(path)))
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,36 @@
1
+ module Dotsync
2
+ class Runner
3
+ def initialize(logger: nil)
4
+ @logger = logger || Dotsync::Logger.new
5
+ end
6
+
7
+ # action_name should be a symbol, e.g., :pull, :watch, :sync
8
+ def run(action_name)
9
+ begin
10
+ action_class = Dotsync.const_get("#{camelize(action_name.to_s)}Action")
11
+ config_class = Dotsync.const_get("#{camelize(action_name.to_s)}ActionConfig")
12
+
13
+ config = config_class.new(Dotsync.config_path)
14
+ action = action_class.new(config, @logger)
15
+ action.execute
16
+ rescue ConfigError => e
17
+ @logger.error("[#{action_name}] config error:")
18
+ @logger.info(e.message)
19
+ rescue NameError => e
20
+ @logger.error("Unknown action '#{action_name}':")
21
+ @logger.info(e.message)
22
+ rescue => e
23
+ @logger.error("Error running '#{action_name}':")
24
+ @logger.info(e.message)
25
+ raise
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ # Utility to convert 'pull' to 'Pull', 'sync' to 'Sync', etc.
32
+ def camelize(str)
33
+ str.split('_').map(&:capitalize).join
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,10 @@
1
+ desc "Sync Dotfiles"
2
+ task :sync do
3
+ ds = Dotsync::SyncAction.new
4
+ end
5
+
6
+ desc "Watch Dotfiles"
7
+ task :watch do
8
+ action = Dotsync::WatchAction.new
9
+ action.start
10
+ end
@@ -0,0 +1,3 @@
1
+ module Dotsync
2
+ VERSION = "0.1.0"
3
+ end
data/lib/dotsync.rb ADDED
@@ -0,0 +1,45 @@
1
+ # Libs dependencies
2
+ require 'fileutils'
3
+ require 'listen'
4
+ require 'toml-rb'
5
+ require 'logger'
6
+ require 'forwardable' # Ruby standard library
7
+ require 'ostruct'
8
+
9
+ # Errors
10
+ require_relative "dotsync/errors"
11
+
12
+ # Utils
13
+ require_relative 'dotsync/logger'
14
+ require_relative 'dotsync/file_transfer'
15
+ require_relative 'dotsync/path_utils'
16
+
17
+ # Config
18
+ require_relative "dotsync/actions/config/xdg_base_directory_spec"
19
+ require_relative "dotsync/actions/config/mapping_entry"
20
+ require_relative "dotsync/actions/config/base_config"
21
+ require_relative "dotsync/actions/config/pull_action_config"
22
+ require_relative "dotsync/actions/config/push_action_config"
23
+ require_relative "dotsync/actions/config/watch_action_config"
24
+
25
+ # Actions
26
+ require_relative "dotsync/actions/base_action"
27
+ require_relative "dotsync/actions/pull_action"
28
+ require_relative "dotsync/actions/push_action"
29
+ require_relative "dotsync/actions/watch_action"
30
+
31
+ require_relative 'dotsync/runner'
32
+
33
+ require_relative "dotsync/version"
34
+
35
+ module Dotsync
36
+ class Error < StandardError; end
37
+
38
+ class << self
39
+ attr_writer :config_path
40
+
41
+ def config_path
42
+ @config_path ||= ENV['DOTSYNC_CONFIG'] || "~/.config/dotsync.toml"
43
+ end
44
+ end
45
+ end