crontab.rb 0.1.2

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,228 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: false
3
+
4
+ # -*- mode: ruby -*-
5
+ # vi: set ft=ruby :
6
+
7
+ # Copyright Nels Nelson 2024 but freely usable (see license)
8
+
9
+ require 'nokogiri'
10
+
11
+ require 'fileutils'
12
+
13
+ require_relative 'launch_agent_constants'
14
+ require_relative 'logging'
15
+ require_relative 'which'
16
+ require_relative 'xml_helper'
17
+
18
+ # Define module LaunchAgentManagementInstanceMethods
19
+ module LaunchAgentManagementInstanceMethods
20
+ include LaunchAgentConstants
21
+ include Which
22
+
23
+ # Extract program path and arguments, verifying executable
24
+ # rubocop: disable Metrics/MethodLength
25
+ def extract_program_path(executable_with_args)
26
+ *args = if executable_with_args.respond_to?(:split)
27
+ executable_with_args.split
28
+ else
29
+ executable_with_args
30
+ end
31
+ exe_path = which(args.shift)
32
+ if exe_path.nil? || !File.exist?(exe_path)
33
+ abort "Cannot find executable in path: #{exe_path}"
34
+ end
35
+ if exe_path.nil? || !File.executable?(exe_path) || File.directory?(exe_path)
36
+ abort "Given file is not executable: #{exe_path}"
37
+ end
38
+ args.unshift(exe_path)
39
+ args
40
+ end
41
+ # rubocop: enable Metrics/MethodLength
42
+
43
+ # Create the job name based on the given file path
44
+ def derive_job_label_from_file_path(file_path)
45
+ label = LABEL_NAMESPACE.dup
46
+ label << File.basename(file_path, '.*')
47
+ label.join('.')
48
+ end
49
+
50
+ # Define cron settings for plist
51
+ def define_calendar_interval(cron_schedule)
52
+ cron_fields = cron_schedule.split
53
+ calendar_interval = {}
54
+ INTERVALS.each do |key|
55
+ value = cron_fields.shift
56
+ raise "Invalid cron string: #{cron_schedule}" if value.nil? || value.empty?
57
+
58
+ calendar_interval[key] = value
59
+ end
60
+ calendar_interval
61
+ end
62
+
63
+ # Define the plist contents
64
+ # rubocop: disable Metrics/MethodLength
65
+ def define_plist_contents(label, args, cron_schedule)
66
+ definition = {}
67
+ definition['Label'] = label
68
+ definition['StartCalendarInterval'] = define_calendar_interval(cron_schedule)
69
+ unless (system_path = ENV.fetch('PATH', nil)).nil? || system_path.empty?
70
+ definition['EnvironmentVariables'] = {}
71
+ definition['EnvironmentVariables']['PATH'] = system_path
72
+ end
73
+ if args.length == 1
74
+ definition['Program'] = args.first
75
+ else
76
+ definition['ProgramArguments'] = args
77
+ end
78
+ definition
79
+ end
80
+ # rubocop: enable Metrics/MethodLength
81
+
82
+ # Save plist file to LaunchAgents directory; do not overwrite an
83
+ # already existing file with the same name at the generated path
84
+ def save_plist(label, doc)
85
+ FileUtils.mkdir_p(LAUNCH_AGENTS_DIR_PATH)
86
+ plist_path = File.join(LAUNCH_AGENTS_DIR_PATH, "#{label}.plist")
87
+ return plist_path if File.exist?(plist_path)
88
+
89
+ log.debug "Contents of plist xml document:\n#{doc.to_xml}"
90
+ File.write(plist_path, doc.to_xml)
91
+ plist_path
92
+ end
93
+
94
+ def execute(command)
95
+ log.debug "Executing command: #{command}"
96
+ system(command)
97
+ end
98
+
99
+ # Load the launchd job
100
+ def load_launchd_job(plist_path)
101
+ execute(format(LAUNCHCTL_TEMPLATE, uid: Process.uid, plist: plist_path))
102
+ end
103
+
104
+ def doctype
105
+ format(DOCTYPE_ELEMENT, doctypes: DOCTYPES.join(' '))
106
+ end
107
+
108
+ # Use Nokogiri to create a launchd plist XML document
109
+ def generate_plist_xml(definition_hash)
110
+ doc = Nokogiri::XML(doctype)
111
+ doc.encoding = DEFAULT_ENCODING
112
+ plist = doc.create_element('plist', version: '1.0')
113
+ root = doc.create_element('dict')
114
+
115
+ definition_hash.each do |key, value|
116
+ create_xml_tag(doc, root, key, value)
117
+ end
118
+
119
+ plist << root
120
+ doc << plist
121
+ doc
122
+ end
123
+
124
+ # Return a list of all user launch agent labels
125
+ def launch_agent_labels
126
+ labels = []
127
+
128
+ Dir.glob(File.join(LAUNCH_AGENTS_DIR_PATH, '*.plist')).each do |file_path|
129
+ doc = File.open(file_path) { |file| Nokogiri::XML(file) }
130
+ label_node = doc.xpath(LABEL_XPATH)
131
+ labels << label_node.text unless label_node.empty?
132
+ end
133
+
134
+ labels
135
+ end
136
+
137
+ # Parse the calendar interval
138
+ def parse_schedule(doc)
139
+ watch_paths_node = doc.xpath(WATCH_PATHS_XPATH)
140
+ watch_paths = watch_paths_node.xpath(LOCAL_STRING_XPATH).map(&:text)
141
+ return watch_paths.first unless watch_paths.empty?
142
+
143
+ intervals = doc.xpath(START_CALENDAR_INTERVAL_XPATH).first
144
+ return ON_LOGIN unless intervals
145
+
146
+ INTERVALS.map do |interval|
147
+ xpath = format(INTERVAL_XPATH_TEMPLATE, key: interval)
148
+ intervals.at_xpath(xpath).text rescue '*'
149
+ end.join(' ')
150
+ end
151
+
152
+ # Function to parse plist and extract crontab-like schedule
153
+ # rubocop: disable Metrics/AbcSize
154
+ # rubocop: disable Metrics/MethodLength
155
+ def parse_plist(plist_path)
156
+ plist = {}
157
+ doc = Nokogiri::XML(File.read(plist_path))
158
+ label = doc.xpath(LABEL_XPATH).text
159
+ watch_paths = doc.xpath(WATCH_PATHS_XPATH).xpath(LOCAL_STRING_XPATH).map(&:text)
160
+ program_args = doc.xpath(PROGRAM_ARGUMENTS_XPATH).xpath(LOCAL_STRING_XPATH).map(&:text)
161
+ schedule = parse_schedule(doc)
162
+ plist[:label] = label unless label.nil?
163
+ plist[:command] = program_args.empty? ? label : program_args.join(' ')
164
+ plist[:watch_paths] = watch_paths unless watch_paths.nil?
165
+ plist[:schedule] = schedule unless schedule.nil?
166
+ plist
167
+ end
168
+ # rubocop: enable Metrics/AbcSize
169
+ # rubocop: enable Metrics/MethodLength
170
+
171
+ # Show the relevant launch agents
172
+ def show_all_launch_agents
173
+ Dir.glob(File.join(LAUNCH_AGENTS_DIR_PATH, '*.plist')).map do |plist_path|
174
+ job = parse_plist(plist_path)
175
+ if job[:command].empty?
176
+ puts "@disabled #{plist_path}"
177
+ else
178
+ puts "#{job[:schedule]} #{job[:command]}"
179
+ end
180
+ end
181
+ end
182
+
183
+ # List labels of launch agents
184
+ def list_launch_agent_labels
185
+ launch_agent_labels.each do |label|
186
+ puts label
187
+ end
188
+ end
189
+
190
+ # Remove plist Launch Agents by label
191
+ # rubocop: disable Metrics/MethodLength
192
+ def remove_launch_agent(label)
193
+ log.debug "Removing launch agent: #{label}"
194
+ plist_path = File.expand_path(File.join(LAUNCH_AGENTS_DIR_PATH, "#{label}.plist"))
195
+ log.debug "Removing launch agent plist definition file: #{plist_path}"
196
+ if File.exist?(plist_path)
197
+ execute(format(BOOTOUT_TEMPLATE, uid: Process.uid, label: label))
198
+ execute(format(REMOVE_TEMPLATE, uid: Process.uid, label: label))
199
+ FileUtils.rm(plist_path)
200
+ puts "Removed launch agent: #{label}"
201
+ else
202
+ warn "Not found; crontab or launch agent: #{label}"
203
+ end
204
+ end
205
+ # rubocop: enable Metrics/MethodLength
206
+
207
+ # Create a file monitor launch agent
208
+ def create_launch_agent(exe, exe_path, schedule)
209
+ args = extract_program_path(exe)
210
+ label = derive_job_label_from_file_path(exe_path || args.first)
211
+ definition = define_plist_contents(label, args, schedule)
212
+ doc = generate_plist_xml(definition)
213
+ plist_path = save_plist(label, doc)
214
+ load_launchd_job(plist_path)
215
+ puts format(SUCCESS_MESSAGE, label: label)
216
+ end
217
+ end
218
+ # module LaunchAgentManagementInstanceMethods
219
+
220
+ # Define class LaunchAgentManager
221
+ class LaunchAgentManager
222
+ include LaunchAgentManagementInstanceMethods
223
+ include XMLHelperInstanceMethods
224
+
225
+ def initialize(options)
226
+ @options = options
227
+ end
228
+ end
@@ -0,0 +1,49 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: false
3
+
4
+ # -*- mode: ruby -*-
5
+ # vi: set ft=ruby :
6
+
7
+ # Copyright Nels Nelson 2024 but freely usable (see license)
8
+
9
+ require 'logger'
10
+ require 'syslog/logger'
11
+
12
+ # Define class MultiLogger
13
+ class MultiLogger
14
+ def initialize(invoking_class)
15
+ @invoking_class = invoking_class
16
+ end
17
+
18
+ %i[info warn debug error fatal].each do |method|
19
+ define_method(method) do |message|
20
+ MultiLogger.loggers.each do |logger|
21
+ logger.add(Logger.const_get(method.upcase), message, @invoking_class || 'Unknown')
22
+ end
23
+ end
24
+ end
25
+
26
+ def log_level=(level)
27
+ MultiLogger.loggers.each { |logger| logger.level = level }
28
+ end
29
+
30
+ def self.loggers
31
+ @loggers ||= begin
32
+ stdout_logger = Logger.new($stdout)
33
+ stdout_logger.level = Logger::INFO
34
+ stdout_logger.formatter = proc do |severity, datetime, progname, msg|
35
+ "#{datetime} #{severity} [#{progname}] #{msg}\n"
36
+ end
37
+ syslog_logger = Syslog::Logger.new(PROJECT)
38
+ syslog_logger.level = Logger::INFO
39
+ [stdout_logger, syslog_logger]
40
+ end
41
+ end
42
+ end
43
+
44
+ # Re-open class Object
45
+ class Object
46
+ def log
47
+ @log ||= MultiLogger.new(self.class.name)
48
+ end
49
+ end
@@ -0,0 +1,138 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: false
3
+
4
+ # -*- mode: ruby -*-
5
+ # vi: set ft=ruby :
6
+
7
+ # Copyright Nels Nelson 2024 but freely usable (see license)
8
+
9
+ require 'optparse'
10
+
11
+ require_relative '../which'
12
+ require_relative '../version'
13
+
14
+ # Define module Login
15
+ module Login
16
+ # Define the ArgumentsParser class
17
+ class ArgumentsParser
18
+ UNDERSCORE_PATTERN = %r{_}
19
+ HYPHEN_STRING = '-'.freeze
20
+ FLAGS = %i[banner show_all list remove verbose].freeze
21
+ POSITIONAL = %i[watch_path executable_path_with_args].freeze
22
+ attr_reader :parser, :options
23
+
24
+ def initialize(parser = OptionParser.new)
25
+ @parser = parser
26
+ @options = {}
27
+ FLAGS.each { |method_name| self.method(method_name).call }
28
+ end
29
+
30
+ def banner
31
+ @parser.banner = "Usage: #{File.basename($PROGRAM_NAME)} " \
32
+ '<watch_path> <executable_path_with_args>'
33
+ @parser.separator ''
34
+ @parser.separator 'Options:'
35
+ end
36
+
37
+ def show_all
38
+ @parser.on('--show-all', 'Show login jobs') do
39
+ @options[:show_all] = true
40
+ end
41
+ end
42
+
43
+ def list
44
+ @parser.on('-l', '--list', 'List login job labels') do
45
+ @options[:list] = true
46
+ end
47
+ end
48
+
49
+ def remove
50
+ @parser.on('-r', '--remove=<label>', 'Remove a login job by label') do |label|
51
+ @options[:remove] = label
52
+ end
53
+ end
54
+
55
+ def verbose
56
+ @options[:log_level] ||= Logger::INFO
57
+ @parser.on_tail('-v', '--verbose', 'Increase verbosity') do
58
+ @options[:log_level] -= 1
59
+ end
60
+ end
61
+
62
+ def version
63
+ @parser.on_tail('--version', 'Show version') do
64
+ puts "#{File.basename($PROGRAM_NAME)} version #{CrontabRb::VERSION}"
65
+ exit
66
+ end
67
+ end
68
+
69
+ def watch_path(args)
70
+ return if (v = args.shift).nil?
71
+
72
+ watch_path = File.expand_path(v)
73
+ unless File.exist?(watch_path) &&
74
+ File.directory?(watch_path)
75
+ message = "Directory not found: #{watch_path}"
76
+ raise OptionParser::InvalidArgument, message
77
+ end
78
+ @options[:watch_path] = [watch_path]
79
+ end
80
+
81
+ def executable_path_with_args(args)
82
+ executable, *args = args
83
+ return if executable.nil?
84
+
85
+ executable_path = Object.new.extend(Which).which(executable)
86
+ if executable_path.nil?
87
+ message = "Executable not found: #{executable_path}"
88
+ raise OptionParser::InvalidArgument, message
89
+ end
90
+ @options[:executable_path_with_args] = [executable_path] + args
91
+ end
92
+
93
+ def demand(arg, positional: false)
94
+ return @options[arg] unless @options[arg].nil?
95
+
96
+ required_arg = if positional then "<#{arg}>"
97
+ else "--#{arg.to_s.gsub(UNDERSCORE_PATTERN, HYPHEN_STRING)}"
98
+ end
99
+ raise OptionParser::MissingArgument, "Required argument: #{required_arg}"
100
+ end
101
+
102
+ def positional!(args)
103
+ POSITIONAL.each do |opt|
104
+ self.method(opt).call(args)
105
+ self.demand(opt, positional: true)
106
+ end
107
+ end
108
+
109
+ def options?
110
+ ArgumentsParser::FLAGS.any? { |flag| @options.include?(flag) }
111
+ end
112
+
113
+ def usage!
114
+ puts @parser
115
+ exit
116
+ end
117
+
118
+ # rubocop: disable Metrics/MethodLength
119
+ def self.parse(args = ARGV, _file_path = ARGF, arguments_parser = ArgumentsParser.new)
120
+ arguments_parser.parser.parse!(args)
121
+ if !arguments_parser.options? && ARGV.length != 2
122
+ message = 'A directory and executable handler is required'
123
+ raise OptionParser::MissingArgument, message
124
+ elsif !args.empty?
125
+ arguments_parser.positional!(args)
126
+ end
127
+ arguments_parser.options
128
+ rescue OptionParser::AmbiguousOption => e
129
+ abort e.message
130
+ rescue OptionParser::ParseError => e
131
+ puts e.message
132
+ arguments_parser.usage!
133
+ end
134
+ # rubocop: enable Metrics/MethodLength
135
+ end
136
+ # class ArgumentsParser
137
+ end
138
+ # module Login
@@ -0,0 +1,56 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: false
3
+
4
+ # vi: set ft=ruby :
5
+ # -*- mode: ruby -*-
6
+
7
+ # Copyright Nels Nelson 2024 but freely usable (see license)
8
+
9
+ require 'rexml/document'
10
+
11
+ require_relative 'cron'
12
+
13
+ # Define class PlistParser
14
+ class PlistParser
15
+ EMPTY_STRING = ''.freeze
16
+
17
+ def initialize(file_path)
18
+ @file_path = file_path
19
+ end
20
+
21
+ def parse
22
+ xml = File.read(@file_path)
23
+ document = REXML::Document.new(xml)
24
+ plist = parse_element(document.root.elements[1]) # the root <dict> inside <plist>
25
+ LaunchAgentPlist.new(
26
+ plist['Label'],
27
+ plist['ProgramArguments'],
28
+ plist['StartInterval'] || plist['CalendarStartInterval']
29
+ )
30
+ end
31
+
32
+ private
33
+
34
+ def parse_element(element)
35
+ case element.name.to_sym
36
+ when :dict then parse_dict(element)
37
+ when :array then parse_array(element)
38
+ when :string then element.text || EMPTY_STRING
39
+ end
40
+ end
41
+
42
+ def parse_dict(dict_element)
43
+ dict = {}
44
+ dict_element.elements.each_slice(2) do |key, value|
45
+ dict[key.text] = parse_element(value)
46
+ end
47
+ dict
48
+ end
49
+
50
+ def parse_array(array_element)
51
+ array_element.elements.map do |element|
52
+ parse_element(element)
53
+ end
54
+ end
55
+ end
56
+ # class PlistParser
@@ -0,0 +1,11 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: false
3
+
4
+ # vi: set ft=ruby :
5
+ # -*- mode: ruby -*-
6
+
7
+ # Copyright Nels Nelson 2024 but freely usable (see license)
8
+
9
+ module CrontabRb
10
+ VERSION = '0.1.2'.freeze
11
+ end
@@ -0,0 +1,138 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: false
3
+
4
+ # -*- mode: ruby -*-
5
+ # vi: set ft=ruby :
6
+
7
+ # Copyright Nels Nelson 2024 but freely usable (see license)
8
+
9
+ require 'optparse'
10
+
11
+ require_relative '../which'
12
+ require_relative '../version'
13
+
14
+ # Define module Watcher
15
+ module Watcher
16
+ # Define the ArgumentsParser class
17
+ class ArgumentsParser
18
+ UNDERSCORE_PATTERN = %r{_}
19
+ HYPHEN_STRING = '-'.freeze
20
+ FLAGS = %i[banner show_all list remove verbose].freeze
21
+ POSITIONAL = %i[watch_path executable_path_with_args].freeze
22
+ attr_reader :parser, :options
23
+
24
+ def initialize(parser = OptionParser.new)
25
+ @parser = parser
26
+ @options = {}
27
+ FLAGS.each { |method_name| self.method(method_name).call }
28
+ end
29
+
30
+ def banner
31
+ @parser.banner = "Usage: #{File.basename($PROGRAM_NAME)} " \
32
+ '<watch_path> <executable_path_with_args>'
33
+ @parser.separator ''
34
+ @parser.separator 'Options:'
35
+ end
36
+
37
+ def show_all
38
+ @parser.on('--show-all', 'Show watchers') do
39
+ @options[:show_all] = true
40
+ end
41
+ end
42
+
43
+ def list
44
+ @parser.on('-l', '--list', 'List watcher labels') do
45
+ @options[:list] = true
46
+ end
47
+ end
48
+
49
+ def remove
50
+ @parser.on('-r', '--remove=<label>', 'Remove a watcher by label') do |label|
51
+ @options[:remove] = label
52
+ end
53
+ end
54
+
55
+ def verbose
56
+ @options[:log_level] ||= Logger::INFO
57
+ @parser.on_tail('-v', '--verbose', 'Increase verbosity') do
58
+ @options[:log_level] -= 1
59
+ end
60
+ end
61
+
62
+ def version
63
+ @parser.on_tail('--version', 'Show version') do
64
+ puts "#{File.basename($PROGRAM_NAME)} version #{CrontabRb::VERSION}"
65
+ exit
66
+ end
67
+ end
68
+
69
+ def watch_path(args)
70
+ return if (v = args.shift).nil?
71
+
72
+ watch_path = File.expand_path(v)
73
+ unless File.exist?(watch_path) &&
74
+ File.directory?(watch_path)
75
+ message = "Directory not found: #{watch_path}"
76
+ raise OptionParser::InvalidArgument, message
77
+ end
78
+ @options[:watch_path] = [watch_path]
79
+ end
80
+
81
+ def executable_path_with_args(args)
82
+ executable, *args = args
83
+ return if executable.nil?
84
+
85
+ executable_path = Object.new.extend(Which).which(executable)
86
+ if executable_path.nil?
87
+ message = "Executable not found: #{executable_path}"
88
+ raise OptionParser::InvalidArgument, message
89
+ end
90
+ @options[:executable_path_with_args] = [executable_path] + args
91
+ end
92
+
93
+ def demand(arg, positional: false)
94
+ return @options[arg] unless @options[arg].nil?
95
+
96
+ required_arg = if positional then "<#{arg}>"
97
+ else "--#{arg.to_s.gsub(UNDERSCORE_PATTERN, HYPHEN_STRING)}"
98
+ end
99
+ raise OptionParser::MissingArgument, "Required argument: #{required_arg}"
100
+ end
101
+
102
+ def positional!(args)
103
+ POSITIONAL.each do |opt|
104
+ self.method(opt).call(args)
105
+ self.demand(opt, positional: true)
106
+ end
107
+ end
108
+
109
+ def options?
110
+ ArgumentsParser::FLAGS.any? { |flag| @options.include?(flag) }
111
+ end
112
+
113
+ def usage!
114
+ puts @parser
115
+ exit
116
+ end
117
+
118
+ # rubocop: disable Metrics/MethodLength
119
+ def self.parse(args = ARGV, _file_path = ARGF, arguments_parser = ArgumentsParser.new)
120
+ arguments_parser.parser.parse!(args)
121
+ if !arguments_parser.options? && ARGV.length != 2
122
+ message = 'A directory and executable handler is required'
123
+ raise OptionParser::MissingArgument, message
124
+ elsif !args.empty?
125
+ arguments_parser.positional!(args)
126
+ end
127
+ arguments_parser.options
128
+ rescue OptionParser::AmbiguousOption => e
129
+ abort e.message
130
+ rescue OptionParser::ParseError => e
131
+ puts e.message
132
+ arguments_parser.usage!
133
+ end
134
+ # rubocop: enable Metrics/MethodLength
135
+ end
136
+ # class ArgumentsParser
137
+ end
138
+ # module Watcher
@@ -0,0 +1,89 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: false
3
+
4
+ # vi: set ft=ruby :
5
+ # -*- mode: ruby -*-
6
+
7
+ # Copyright Nels Nelson 2024 but freely usable (see license)
8
+
9
+ # Usage: watch.rb <path> [handler_program_path]
10
+ #
11
+ # Options:
12
+ # --show-all Show watchers
13
+ # -l, --list List launch agent labels
14
+ # -r, --remove=<label> Remove a watcher by label
15
+ #
16
+ # Examples:
17
+ #
18
+ # watch.rb ~/Desktop ~/.local/usr/bin/desktop.rb
19
+ # watch.rb --show-all
20
+ # watch.rb --list
21
+ # watch.rb --remove=com.local.desktop
22
+
23
+ require_relative 'launch_agent_manager'
24
+ require_relative 'watch/argument_parser'
25
+
26
+ # Define module Watcher
27
+ module Watcher
28
+ # Define class LaunchAgentManager
29
+ class LaunchAgentManager < ::LaunchAgentManager
30
+ # Define the plist contents
31
+ # rubocop: disable Metrics/MethodLength
32
+ def define_plist_contents(label, args, *watch_paths)
33
+ definition = {}
34
+ definition['Label'] = label
35
+ definition['WatchPaths'] = watch_paths
36
+ unless (system_path = ENV.fetch('PATH', nil)).nil? || system_path.empty?
37
+ definition['EnvironmentVariables'] = {}
38
+ definition['EnvironmentVariables']['PATH'] = system_path
39
+ end
40
+ if args.length == 1
41
+ definition['Program'] = args.first
42
+ else
43
+ definition['ProgramArguments'] = args
44
+ end
45
+ definition
46
+ end
47
+ # rubocop: enable Metrics/MethodLength
48
+
49
+ # Return a list of all user launch agent labels
50
+ def launch_agent_labels
51
+ labels = []
52
+
53
+ Dir.glob(File.join(LAUNCH_AGENTS_DIR_PATH, '*.plist')).each do |file_path|
54
+ doc = File.open(file_path) { |file| Nokogiri::XML(file) }
55
+ watch_paths = doc.xpath(WATCH_PATHS_XPATH)
56
+ next if watch_paths.nil? || watch_paths.empty?
57
+
58
+ label_node = doc.xpath(LABEL_XPATH)
59
+ labels << label_node.text unless label_node.empty?
60
+ end
61
+
62
+ labels
63
+ end
64
+ end
65
+ # class LaunchAgentManager
66
+
67
+ # rubocop: disable Metrics/MethodLength
68
+ def main(args = Watcher::ArgumentsParser.parse)
69
+ log.log_level = args[:log_level]
70
+ manager = Watcher::LaunchAgentManager.new(args)
71
+ if args[:show_all]
72
+ manager.show_all_launch_agents
73
+ elsif args[:list]
74
+ manager.list_launch_agent_labels
75
+ elsif args[:remove]
76
+ manager.remove_launch_agent(args[:remove])
77
+ else
78
+ manager.create_launch_agent(
79
+ args[:executable_path_with_args],
80
+ args[:watch_path].first,
81
+ *args[:watch_path]
82
+ )
83
+ end
84
+ end
85
+ # rubocop: enable Metrics/MethodLength
86
+ end
87
+ # module Watcher
88
+
89
+ Object.new.extend(Watcher).main if $PROGRAM_NAME == __FILE__