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.
@@ -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,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 Kanseishitsu
10
+ VERSION = '0.1.1'.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 #{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