kanseishitsu 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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