kanseishitsu 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.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
|