kanseishitsu 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.md +21 -0
- data/README.md +141 -0
- data/bin/cron.rb +27 -0
- data/bin/watch.rb +27 -0
- data/lib/kanseishitsu/cron/argument_parser.rb +120 -0
- data/lib/kanseishitsu/cron/launch_agent_manager_rexml.rb +183 -0
- data/lib/kanseishitsu/cron.rb +53 -0
- data/lib/kanseishitsu/launch_agent_constants.rb +41 -0
- data/lib/kanseishitsu/launch_agent_manager.rb +224 -0
- data/lib/kanseishitsu/logging.rb +49 -0
- data/lib/kanseishitsu/plist_parser.rb +110 -0
- data/lib/kanseishitsu/version.rb +11 -0
- data/lib/kanseishitsu/watch/argument_parser.rb +138 -0
- data/lib/kanseishitsu/watch.rb +90 -0
- data/lib/kanseishitsu/which.rb +38 -0
- data/lib/kanseishitsu/xml_helper.rb +53 -0
- data/lib/kanseishitsu/xml_helper_rexml.rb +56 -0
- data/lib/kanseishitsu.rb +10 -0
- metadata +96 -0
@@ -0,0 +1,224 @@
|
|
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
|
+
puts "#{job[:schedule]} #{job[:command]}"
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
# List labels of launch agents
|
180
|
+
def list_launch_agent_labels
|
181
|
+
launch_agent_labels.each do |label|
|
182
|
+
puts label
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
# Remove plist Launch Agents by label
|
187
|
+
# rubocop: disable Metrics/MethodLength
|
188
|
+
def remove_launch_agent(label)
|
189
|
+
log.debug "Removing launch agent: #{label}"
|
190
|
+
plist_path = File.expand_path(File.join(LAUNCH_AGENTS_DIR_PATH, "#{label}.plist"))
|
191
|
+
log.debug "Removing launch agent plist definition file: #{plist_path}"
|
192
|
+
if File.exist?(plist_path)
|
193
|
+
execute(format(BOOTOUT_TEMPLATE, uid: Process.uid, label: label))
|
194
|
+
execute(format(REMOVE_TEMPLATE, uid: Process.uid, label: label))
|
195
|
+
FileUtils.rm(plist_path)
|
196
|
+
puts "Removed launch agent: #{label}"
|
197
|
+
else
|
198
|
+
warn "Not found; crontab or launch agent: #{label}"
|
199
|
+
end
|
200
|
+
end
|
201
|
+
# rubocop: enable Metrics/MethodLength
|
202
|
+
|
203
|
+
# Create a file monitor launch agent
|
204
|
+
def create_launch_agent(exe, exe_path, schedule)
|
205
|
+
args = extract_program_path(exe)
|
206
|
+
label = derive_job_label_from_file_path(exe_path || args.first)
|
207
|
+
definition = define_plist_contents(label, args, schedule)
|
208
|
+
doc = generate_plist_xml(definition)
|
209
|
+
plist_path = save_plist(label, doc)
|
210
|
+
load_launchd_job(plist_path)
|
211
|
+
puts format(SUCCESS_MESSAGE, label: label)
|
212
|
+
end
|
213
|
+
end
|
214
|
+
# module LaunchAgentManagementInstanceMethods
|
215
|
+
|
216
|
+
# Define class LaunchAgentManager
|
217
|
+
class LaunchAgentManager
|
218
|
+
include LaunchAgentManagementInstanceMethods
|
219
|
+
include XMLHelperInstanceMethods
|
220
|
+
|
221
|
+
def initialize(options)
|
222
|
+
@options = options
|
223
|
+
end
|
224
|
+
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,110 @@
|
|
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 the PlistParser class
|
14
|
+
# class PlistParser
|
15
|
+
# LOCAL_STRING_XPATH = './string'.freeze
|
16
|
+
# PROGRAM_ARGUMENTS_XPATH =
|
17
|
+
# '//key[text()="ProgramArguments"]/following-sibling::array[1]'.freeze
|
18
|
+
# LABEL_XPATH = '//key[text()="Label"]/following-sibling::*[1]'.freeze
|
19
|
+
# CALENDAR_XPATH = '//key[text()="StartCalendarInterval"]/following-sibling::*[1]'.freeze
|
20
|
+
# SCHEDULE_XPATH_TEMPLATE = './/key[text()="%<key>s"]/following-sibling::integer'.freeze
|
21
|
+
# ON_LOGIN = '@login'.freeze
|
22
|
+
|
23
|
+
# # Parse the Plist file at the given path
|
24
|
+
# def parse(plist_path)
|
25
|
+
# plist = {}
|
26
|
+
# doc = REXML::Document.new(File.read(plist_path))
|
27
|
+
# label = parse_plist_label(doc)
|
28
|
+
# command = parse_program_arguments(doc, label)
|
29
|
+
# cron_schedule = parse_calendar_interval(doc)
|
30
|
+
# plist[:label] = label unless label.nil?
|
31
|
+
# plist[:command] = command unless command.nil?
|
32
|
+
# plist[:schedule] = cron_schedule unless cron_schedule.nil?
|
33
|
+
# plist
|
34
|
+
# end
|
35
|
+
|
36
|
+
# # Parse the label from the Plist file document
|
37
|
+
# def parse_plist_label(doc)
|
38
|
+
# label_element = REXML::XPath.first(doc, LABEL_XPATH)
|
39
|
+
# return nil if label_element.nil?
|
40
|
+
|
41
|
+
# label = label_element.text
|
42
|
+
# return nil if label.empty?
|
43
|
+
|
44
|
+
# label
|
45
|
+
# end
|
46
|
+
|
47
|
+
# # Parse the program arguments from the Plist file document
|
48
|
+
# def parse_program_arguments(doc, label)
|
49
|
+
# program_args_elements = REXML::XPath.match(doc, PROGRAM_ARGUMENTS_XPATH)
|
50
|
+
# program_args = program_args_elements.flat_map do |node|
|
51
|
+
# REXML::XPath.match(node, LOCAL_STRING_XPATH).map(&:text)
|
52
|
+
# end
|
53
|
+
# program_args.empty? ? label : program_args.join(' ')
|
54
|
+
# end
|
55
|
+
|
56
|
+
# # Parse the calendar interval from the Plist file document
|
57
|
+
# def parse_calendar_interval(doc)
|
58
|
+
# intervals = doc.xpath(CALENDAR_XPATH).first
|
59
|
+
# return ON_LOGIN unless intervals
|
60
|
+
|
61
|
+
# Cron::INTERVALS.map do |interval|
|
62
|
+
# intervals.at_xpath(format(SCHEDULE_XPATH_TEMPLATE, key: interval)).text rescue '*'
|
63
|
+
# end.join(' ')
|
64
|
+
# end
|
65
|
+
# end
|
66
|
+
|
67
|
+
# Define class PlistParser
|
68
|
+
class PlistParser
|
69
|
+
EMPTY_STRING = ''.freeze
|
70
|
+
|
71
|
+
def initialize(file_path)
|
72
|
+
@file_path = file_path
|
73
|
+
end
|
74
|
+
|
75
|
+
def parse
|
76
|
+
xml = File.read(@file_path)
|
77
|
+
document = REXML::Document.new(xml)
|
78
|
+
plist = parse_element(document.root.elements[1]) # the root <dict> inside <plist>
|
79
|
+
LaunchAgentPlist.new(
|
80
|
+
plist['Label'],
|
81
|
+
plist['ProgramArguments'],
|
82
|
+
plist['StartInterval'] || plist['CalendarStartInterval']
|
83
|
+
)
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def parse_element(element)
|
89
|
+
case element.name.to_sym
|
90
|
+
when :dict then parse_dict(element)
|
91
|
+
when :array then parse_array(element)
|
92
|
+
when :string then element.text || EMPTY_STRING
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def parse_dict(dict_element)
|
97
|
+
dict = {}
|
98
|
+
dict_element.elements.each_slice(2) do |key, value|
|
99
|
+
dict[key.text] = parse_element(value)
|
100
|
+
end
|
101
|
+
dict
|
102
|
+
end
|
103
|
+
|
104
|
+
def parse_array(array_element)
|
105
|
+
array_element.elements.map do |element|
|
106
|
+
parse_element(element)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
# class PlistParser
|
@@ -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 #{Kanseishitsu::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,90 @@
|
|
1
|
+
#! /usr/bin/env ruby
|
2
|
+
# encoding: utf-8
|
3
|
+
# frozen_string_literal: false
|
4
|
+
|
5
|
+
# vi: set ft=ruby :
|
6
|
+
# -*- mode: ruby -*-
|
7
|
+
|
8
|
+
# Copyright Nels Nelson 2024 but freely usable (see license)
|
9
|
+
|
10
|
+
# Usage: watch.rb <path> [handler_program_path]
|
11
|
+
#
|
12
|
+
# Options:
|
13
|
+
# --show-all Show watchers
|
14
|
+
# -l, --list List launch agent labels
|
15
|
+
# -r, --remove=<label> Remove a watcher by label
|
16
|
+
#
|
17
|
+
# Examples:
|
18
|
+
#
|
19
|
+
# watch.rb ~/Desktop ~/.local/usr/bin/desktop.rb
|
20
|
+
# watch.rb --show-all
|
21
|
+
# watch.rb --list
|
22
|
+
# watch.rb --remove=com.local.desktop
|
23
|
+
|
24
|
+
require_relative 'launch_agent_manager'
|
25
|
+
require_relative 'watch/argument_parser'
|
26
|
+
|
27
|
+
# Define module Watcher
|
28
|
+
module Watcher
|
29
|
+
# Define class LaunchAgentManager
|
30
|
+
class LaunchAgentManager < ::LaunchAgentManager
|
31
|
+
# Define the plist contents
|
32
|
+
# rubocop: disable Metrics/MethodLength
|
33
|
+
def define_plist_contents(label, args, *watch_paths)
|
34
|
+
definition = {}
|
35
|
+
definition['Label'] = label
|
36
|
+
definition['WatchPaths'] = watch_paths
|
37
|
+
unless (system_path = ENV.fetch('PATH', nil)).nil? || system_path.empty?
|
38
|
+
definition['EnvironmentVariables'] = {}
|
39
|
+
definition['EnvironmentVariables']['PATH'] = system_path
|
40
|
+
end
|
41
|
+
if args.length == 1
|
42
|
+
definition['Program'] = args.first
|
43
|
+
else
|
44
|
+
definition['ProgramArguments'] = args
|
45
|
+
end
|
46
|
+
definition
|
47
|
+
end
|
48
|
+
# rubocop: enable Metrics/MethodLength
|
49
|
+
|
50
|
+
# Return a list of all user launch agent labels
|
51
|
+
def launch_agent_labels
|
52
|
+
labels = []
|
53
|
+
|
54
|
+
Dir.glob(File.join(LAUNCH_AGENTS_DIR_PATH, '*.plist')).each do |file_path|
|
55
|
+
doc = File.open(file_path) { |file| Nokogiri::XML(file) }
|
56
|
+
watch_paths = doc.xpath(WATCH_PATHS_XPATH)
|
57
|
+
next if watch_paths.nil? || watch_paths.empty?
|
58
|
+
|
59
|
+
label_node = doc.xpath(LABEL_XPATH)
|
60
|
+
labels << label_node.text unless label_node.empty?
|
61
|
+
end
|
62
|
+
|
63
|
+
labels
|
64
|
+
end
|
65
|
+
end
|
66
|
+
# class LaunchAgentManager
|
67
|
+
|
68
|
+
# rubocop: disable Metrics/MethodLength
|
69
|
+
def main(args = Watcher::ArgumentsParser.parse)
|
70
|
+
log.log_level = args[:log_level]
|
71
|
+
manager = Watcher::LaunchAgentManager.new(args)
|
72
|
+
if args[:show_all]
|
73
|
+
manager.show_all_launch_agents
|
74
|
+
elsif args[:list]
|
75
|
+
manager.list_launch_agent_labels
|
76
|
+
elsif args[:remove]
|
77
|
+
manager.remove_launch_agent(args[:remove])
|
78
|
+
else
|
79
|
+
manager.create_launch_agent(
|
80
|
+
args[:executable_path_with_args],
|
81
|
+
args[:watch_path].first,
|
82
|
+
*args[:watch_path]
|
83
|
+
)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
# rubocop: enable Metrics/MethodLength
|
87
|
+
end
|
88
|
+
# module Watcher
|
89
|
+
|
90
|
+
Object.new.extend(Watcher).main if $PROGRAM_NAME == __FILE__
|
@@ -0,0 +1,38 @@
|
|
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
|
+
# Define module Which
|
10
|
+
module Which
|
11
|
+
# Find the full path to a given executable in the PATH
|
12
|
+
# system environment variable
|
13
|
+
def find_executable_in_path(cmd)
|
14
|
+
return if cmd.nil?
|
15
|
+
|
16
|
+
directory = ENV['PATH'].split(File::PATH_SEPARATOR).find do |dir|
|
17
|
+
File.executable?(File.join(dir, cmd))
|
18
|
+
end
|
19
|
+
directory.nil? ? nil : File.join(directory, cmd)
|
20
|
+
end
|
21
|
+
|
22
|
+
def executable?(exe)
|
23
|
+
!exe.empty? && File.executable?(exe) && !File.directory?(exe)
|
24
|
+
end
|
25
|
+
|
26
|
+
def explicit_which(cmd)
|
27
|
+
exe = `which #{cmd}`.chomp
|
28
|
+
executable?(exe) ? exe : nil
|
29
|
+
rescue Errno::ENOENT => _e
|
30
|
+
nil
|
31
|
+
end
|
32
|
+
|
33
|
+
def portable_which(cmd)
|
34
|
+
explicit_which(cmd) || find_executable_in_path(cmd)
|
35
|
+
end
|
36
|
+
alias which portable_which
|
37
|
+
end
|
38
|
+
# module Which
|