dry-cli-ui 0.3.1 → 0.5.0

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.
data/examples/.envrc ADDED
@@ -0,0 +1 @@
1
+ PATH_add bin
@@ -0,0 +1 @@
1
+ *.txt
data/examples/Gemfile CHANGED
@@ -10,5 +10,5 @@ group :development, :test do
10
10
  # multi_spinner, multi_progress and status_bar live on the
11
11
  # kig/add-multi-spinner-progress branch until it is merged; switch this
12
12
  # back to path: "../" then.
13
- gem 'dry-cli-ui', path: File.expand_path("~/.agents.worktrees/dry-cli-ui-add-multi-spinner-progress")
13
+ gem 'dry-cli-ui', path: '../'
14
14
  end
@@ -1,7 +1,7 @@
1
1
  PATH
2
- remote: /Users/kig/.agents.worktrees/dry-cli-ui-add-multi-spinner-progress
2
+ remote: ..
3
3
  specs:
4
- dry-cli-ui (0.3.0)
4
+ dry-cli-ui (0.5.0)
5
5
  concurrent-ruby (~> 1.3)
6
6
  dry-cli (>= 1.0)
7
7
  pastel (~> 0.8)
@@ -22,7 +22,7 @@ GEM
22
22
  dry-cli-autocomplete (0.1.3)
23
23
  dry-cli (>= 1.0)
24
24
  dry-inflector (>= 1.0)
25
- dry-cli-help (0.2.0)
25
+ dry-cli-help (0.2.1)
26
26
  dry-cli (>= 1.1.1, < 2)
27
27
  pastel (~> 0.8)
28
28
  dry-inflector (1.3.1)
@@ -83,8 +83,8 @@ CHECKSUMS
83
83
  concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
84
84
  dry-cli (1.4.1) sha256=b8015bb76c708aa8705a36faf694973e75eeeffca39b89c8e172dc6f66a7d874
85
85
  dry-cli-autocomplete (0.1.3) sha256=14f76447bda0000c71739b585c827dc0a0a763e30a68a8b4b2dff91f640768e4
86
- dry-cli-help (0.2.0)
87
- dry-cli-ui (0.3.0)
86
+ dry-cli-help (0.2.1) sha256=eaaaf6edb787ffe810425b4e4bb1a675d89397ccc759102d70b5b26e7bc692da
87
+ dry-cli-ui (0.5.0)
88
88
  dry-inflector (1.3.1) sha256=7fb0c2bb04f67638f25c52e7ba39ab435d922a3a5c3cd196120f63accb682dcc
89
89
  forwardable (1.4.0) sha256=f1cd40cc9812937980e1c76f1aa053660990a7c9b6a98fc37d945468afcce838
90
90
  pastel (0.8.0) sha256=481da9fb7d2f6e6b1a08faf11fa10363172dc40fd47848f096ae21209f805a75
data/examples/README.md CHANGED
@@ -1,15 +1,13 @@
1
1
  # Examples
2
2
 
3
- These examples offers two commands that are are indicative of the added behavior.
3
+ A small `dry-cli` app, `bin/mycli`, with two commands that show off the UI widgets.
4
4
 
5
- But first,
5
+ First:
6
6
 
7
7
  ```bash
8
8
  bundle install
9
9
  ```
10
10
 
11
- Then:
12
-
13
11
  ## Help
14
12
 
15
13
  ```bash
@@ -19,9 +17,8 @@ USAGE
19
17
 
20
18
  COMMANDS
21
19
  version, v Print version
22
- urls_progress Fetch URLs with a progress bar each, and write them all to urls.txt
23
- urls_spinner Fetch URLs with a spinner each, and write each to its own file
24
- primes Compute closest prime to a given number in the array
20
+ download-urls Download URLs, each to its own file
21
+ find-hosts Find hosts on the local network that answer on TCP ports
25
22
 
26
23
  OPTIONS
27
24
  -h, --help Show help
@@ -30,6 +27,23 @@ OPTIONS
30
27
 
31
28
  ## Commands to Try
32
29
 
33
- - primes
34
- - urls_progress url1 url2 ...
35
- - urls_spinner url1 url2 ...
30
+ - `download-urls URL1 URL2 ...` downloads each URL to its own file in the current folder, under a spinner per URL.
31
+ - `download-urls -u urls.txt` reads the URLs from `urls.txt`, one per line, skipping blank lines and `#` comments. URLs given as arguments are downloaded too. Invalid URLs, from either place, are skipped and listed in a warning at the end.
32
+ - `download-urls --progress --save-to=downloads URL1 URL2 ...` shows a progress bar per URL instead, and saves the files in `downloads/`, creating it when missing.
33
+ - `find-hosts` probes every address on the local /24 subnet on the common TCP ports (22, 53, 80, 123, 139, 443, 445, 631, 3389, 5000, 7000), 10 addresses at a time, with a spinner per address. It ends with a box listing each host that answered and its open ports.
34
+ - `find-hosts --progress --output=hosts.txt` shows two bars instead, one counting the addresses that answered and one those that did not, and also writes the result to `hosts.txt`.
35
+ - `find-hosts --port=22` probes only port 22, with the two bars.
36
+
37
+ `download-urls` downloads at most 10 URLs at a time. `--progress` and `--spinner` are mutually exclusive. `download-urls` defaults to a spinner. `find-hosts` defaults to spinners, or to progress bars with `--port`.
38
+
39
+ ## File names
40
+
41
+ `download-urls` names each file after the URL's host, path and query, with anything unsafe replaced by `_`:
42
+
43
+ | URL | File |
44
+ | ------------------------------------------------------- | ----------------------------------------------- |
45
+ | `https://www.ruby-lang.org/images/header-ruby-logo.png` | `www.ruby-lang.org_images_header-ruby-logo.png` |
46
+ | `https://example.com` | `example.com.html` |
47
+ | `https://httpbin.org/json` | `httpbin.org_json.json` |
48
+
49
+ A file keeps the extension its URL path has. Without one, the extension comes from the response's `Content-Type`, and is `.txt` for a type the command does not know.
data/examples/bin/mycli CHANGED
@@ -12,7 +12,8 @@ require "dry/cli/help"
12
12
  require "dry/cli/autocomplete/command"
13
13
  require "dry/cli/ui"
14
14
  require "net/http"
15
- require "prime"
15
+ require "concurrent"
16
+ require "socket"
16
17
 
17
18
  module Foo
18
19
  module CLI
@@ -27,43 +28,61 @@ module Foo
27
28
  end
28
29
  end
29
30
 
30
- # Shared by the two URL commands: follows redirects, and asks for the
31
- # body as it is stored, so the bytes counted match Content-Length.
31
+ # Used by download-urls: follows up to REDIRECTS redirects on every
32
+ # request, and asks for the body as it is stored, so the bytes counted
33
+ # match Content-Length.
32
34
  module HTTP
33
35
  HEADERS = { "Accept-Encoding" => "identity" }.freeze
34
36
  REDIRECTS = 5
35
37
 
36
38
  module_function
37
39
 
40
+ # Statuses a server answers HEAD with when it only serves GET.
41
+ NO_HEAD = %w[403 405 501].freeze
42
+
38
43
  # @return [Array(URI, Integer, nil)] where the URL ends up, and its size when the server says
39
44
  # @raise [RuntimeError] for a response that is neither a redirect nor a success
40
45
  def resolve(url, hops = REDIRECTS)
41
46
  uri = URI(url)
42
47
  response = start(uri) { |http| http.head(uri.request_uri, HEADERS) }
43
- if response.is_a?(Net::HTTPRedirection) && hops.positive?
44
- return resolve(URI.join(uri, response["location"]).to_s, hops - 1)
45
- end
48
+ return resolve(redirect(uri, response, hops), hops - 1) if response.is_a?(Net::HTTPRedirection)
49
+ return [uri, nil] if NO_HEAD.include?(response.code) # the GET finds out
46
50
  raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
47
51
 
48
52
  [uri, response.content_length]
49
53
  end
50
54
 
51
55
  # @yieldparam bytes [Integer] the size of each chunk as it arrives
52
- # @return [String] the body
56
+ # @return [Array(String, String, nil)] the body, and its media type when the server says
53
57
  # @raise [RuntimeError] for a response that is not a success
54
- def download(uri)
58
+ def download(uri, hops = REDIRECTS, &)
55
59
  body = +""
60
+ type = nil
61
+ location = nil
56
62
  start(uri) do |http|
57
63
  http.request_get(uri.request_uri, HEADERS) do |response|
64
+ next location = redirect(uri, response, hops) if response.is_a?(Net::HTTPRedirection)
58
65
  raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
59
66
 
67
+ type = response.content_type
60
68
  response.read_body do |chunk|
61
69
  body << chunk
62
70
  yield chunk.bytesize if block_given?
63
71
  end
64
72
  end
65
73
  end
66
- body
74
+ return download(URI(location), hops - 1, &) if location
75
+
76
+ [body, type]
77
+ end
78
+
79
+ # @return [String] the absolute URL a redirect points to
80
+ # @raise [RuntimeError] once there are no hops left, or without a Location
81
+ def redirect(uri, response, hops)
82
+ raise "Too many redirects" unless hops.positive?
83
+ raise "HTTP #{response.code} without a Location" unless response["location"]
84
+
85
+ URI.join(uri, response["location"]).to_s
67
86
  end
68
87
 
69
88
  def start(uri, &)
@@ -71,59 +90,93 @@ module Foo
71
90
  end
72
91
  end
73
92
 
74
- # One progress bar per URL, filling as bytes arrive, under a headline
75
- # bar that counts every byte.
76
- class FetchUrlsProgress < Dry::CLI::Command
93
+ # Downloads URLs, each to its own file, under a spinner or a progress bar.
94
+ class DownloadUrls < Dry::CLI::Command
77
95
  include Dry::CLI::UI
78
96
 
79
- desc "Fetch URLs with a progress bar each, and write them all to urls.txt"
80
- argument :urls, type: :array, required: true, desc: "URLs to fetch, separated by spaces"
81
-
82
- def call(urls:, **)
83
- # A bar's total is fixed when it is declared, so ask each URL for its
84
- # size first, with a HEAD request. A URL that fails here is left out.
85
- found = urls.filter_map do |url|
86
- [url, *HTTP.resolve(url)]
87
- rescue StandardError => e
88
- ui.status "#{url}: #{e.message}", level: :warn
89
- nil
97
+ # File extensions for the media types a URL's own name may not reveal.
98
+ EXTENSIONS = {
99
+ "application/gzip" => ".gz",
100
+ "application/javascript" => ".js",
101
+ "application/json" => ".json",
102
+ "application/pdf" => ".pdf",
103
+ "application/xml" => ".xml",
104
+ "application/zip" => ".zip",
105
+ "image/gif" => ".gif",
106
+ "image/jpeg" => ".jpg",
107
+ "image/png" => ".png",
108
+ "image/svg+xml" => ".svg",
109
+ "image/webp" => ".webp",
110
+ "text/css" => ".css",
111
+ "text/csv" => ".csv",
112
+ "text/html" => ".html",
113
+ "text/javascript" => ".js",
114
+ "text/markdown" => ".md",
115
+ "text/plain" => ".txt",
116
+ "text/xml" => ".xml"
117
+ }.freeze
118
+
119
+ # The most URLs downloaded at once.
120
+ CONCURRENCY = 10
121
+
122
+ desc "Download URLs, each to its own file"
123
+ argument :urls, type: :array, required: false, desc: "URLs to download, separated by spaces"
124
+ option :'urls-file', aliases: ["-u"], desc: "A file with one URL per line; blank lines and # comments are skipped"
125
+ option :'save-to', default: ".", desc: "Directory to save the files in, created when missing"
126
+ option :progress, type: :flag, default: false, desc: "Show a progress bar per URL"
127
+ option :spinner, type: :flag, default: false, desc: "Show a spinner per URL; used when neither flag is given"
128
+
129
+ def call(urls: [], progress: false, spinner: false, **options)
130
+ return ui.error("Pick one of --progress and --spinner, not both") if progress && spinner
131
+
132
+ urls_file = options[:'urls-file']
133
+ save_to = options.fetch(:'save-to', ".")
134
+
135
+ begin
136
+ urls += read_urls(urls_file) if urls_file
137
+ rescue SystemCallError => e
138
+ return ui.error("Cannot read #{urls_file}", e.message)
90
139
  end
91
- return ui.error("None of the #{urls.size} URLs could be fetched") if found.empty?
140
+ urls, invalid = urls.partition { |url| valid?(url) }
141
+ download(urls, save_to, progress)
142
+ ui.warn "Skipped #{invalid.size} invalid URLs", invalid.join("\n") if invalid.any?
143
+ end
92
144
 
93
- bodies = ui.multi_progress("Fetching #{found.size} URLs", concurrent: 8) do |m|
94
- found.each do |url, uri, size|
95
- m.progress(url, total: size || 1) do |bar|
96
- sleep rand(1..4)
97
- # Move the bar one byte at a time, as each chunk arrives.
98
- body = HTTP.download(uri) { |bytes| bytes.times { bar.advance } if size }
99
- bar.advance unless size # no Content-Length: done in one step
100
- body
101
- end
102
- end
145
+ private
146
+
147
+ def download(urls, save_to, progress)
148
+ return ui.error("No URLs to download", "Pass them as arguments, or in a file with --urls-file") if urls.empty?
149
+
150
+ begin
151
+ FileUtils.mkdir_p(save_to)
152
+ rescue SystemCallError => e
153
+ return ui.error("Cannot create #{save_to}", e.message)
103
154
  end
104
155
 
105
- File.write("urls.txt", bodies.join("\n"))
106
- ui.success "Wrote #{bodies.sum(&:bytesize)} bytes from #{found.size} of #{urls.size} URLs to urls.txt"
107
- rescue StandardError => e
108
- ui.error("Fetching failed", e.message)
109
- end
110
- end
156
+ downloads = progress ? with_progress(urls) : with_spinner(urls)
157
+ files = urls.zip(downloads).filter_map do |url, (body, type)|
158
+ next unless body
111
159
 
112
- # One spinner per URL, each saying what it is doing, and one file per URL.
113
- class FetchUrls < Dry::CLI::Command
114
- include Dry::CLI::UI
160
+ File.join(save_to, filename(url, type)).tap { |file| File.write(file, body) }
161
+ end
162
+ ui.success "Wrote #{files.size} of #{urls.size} URLs", files.join("\n")
163
+ end
115
164
 
116
- desc "Fetch URLs with a spinner each, and write each to its own file"
117
- argument :urls, type: :array, required: true, desc: "URLs to fetch, separated by spaces"
165
+ # An http or https URL with a host.
166
+ def valid?(url)
167
+ uri = URI.parse(url)
168
+ uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
169
+ rescue URI::InvalidURIError
170
+ false
171
+ end
118
172
 
119
- def call(urls:, **)
120
- bodies = ui.multi_spinner("Fetching #{urls.size} URLs", concurrent: 8) do |m|
173
+ def with_spinner(urls)
174
+ ui.multi_spinner("Downloading #{urls.size} URLs", concurrent: CONCURRENCY) do |m|
121
175
  urls.each do |url|
122
176
  m.spinner(url) do |line|
123
177
  line.detail = "resolving"
124
178
  uri, = HTTP.resolve(url)
125
179
  line.detail = "downloading #{uri.host}"
126
- sleep rand(1..4) # slow enough to watch
127
180
  HTTP.download(uri)
128
181
  rescue StandardError => e
129
182
  line.fail(e.message)
@@ -131,76 +184,199 @@ module Foo
131
184
  end
132
185
  end
133
186
  end
187
+ end
134
188
 
135
- files = urls.zip(bodies).filter_map do |url, body|
136
- next unless body
137
-
138
- "#{friendly_filename(url)}.txt".tap { |file| File.write(file, body) }
189
+ # A bar's total is fixed when it is declared, so ask each URL for its
190
+ # size first, with a HEAD request. A URL that fails here is left out.
191
+ def with_progress(urls)
192
+ found = urls.to_h do |url|
193
+ [url, HTTP.resolve(url)]
194
+ rescue StandardError => e
195
+ ui.status "#{url}: #{e.message}", level: :warn
196
+ [url, nil]
139
197
  end
140
- ui.success "Wrote #{files.size} of #{urls.size} URLs", files.join("\n")
198
+ ready = found.select { |_, (uri, _)| uri }
199
+ return urls.map { nil } if ready.empty?
200
+
201
+ # A bar cannot be marked failed, so a download that fails is
202
+ # reported once every bar has finished.
203
+ errors = Concurrent::Array.new
204
+ bodies = ui.multi_progress("Downloading #{ready.size} URLs", concurrent: CONCURRENCY) do |m|
205
+ ready.each do |url, (uri, size)|
206
+ m.progress(url, total: size || 1) do |bar|
207
+ download = HTTP.download(uri) { |bytes| bar.advance(bytes) if size }
208
+ bar.advance unless size # no Content-Length: done in one step
209
+ download
210
+ rescue StandardError => e
211
+ errors << "#{url}: #{e.message}"
212
+ nil
213
+ end
214
+ end
215
+ end
216
+ errors.each { |error| ui.status error, level: :warn }
217
+ downloads = ready.keys.zip(bodies).to_h
218
+ urls.map { |url| downloads[url] }
141
219
  end
142
220
 
143
- private
221
+ def read_urls(file)
222
+ File.readlines(file, chomp: true).map(&:strip).reject { |line| line.empty? || line.start_with?("#") }
223
+ end
144
224
 
145
- def friendly_filename(url)
146
- url.sub(%r{\Ahttps?://}, "").gsub(/[^\w.-]+/, "_").delete_suffix("_")
225
+ # The URL's host, path and query as one safe name, keeping the path's
226
+ # extension. Without one, the extension comes from the media type,
227
+ # and is .txt when that is unknown too.
228
+ def filename(url, type)
229
+ uri = URI(url)
230
+ extension = File.extname(uri.path)
231
+ stem = "#{uri.host}#{uri.path.delete_suffix(extension)}#{"_#{uri.query}" if uri.query}"
232
+ extension = EXTENSIONS.fetch(type, ".txt") if extension.empty?
233
+ "#{stem.gsub(/[^\w.-]+/, '_').delete_suffix('_')}#{extension}"
147
234
  end
148
235
  end
149
236
 
150
- class ComputePrimes < Dry::CLI::Command
237
+ # Scans the local /24 subnet for hosts that answer on TCP ports, with a
238
+ # spinner or a progress bar for every address.
239
+ class FindHosts < Dry::CLI::Command
151
240
  include Dry::CLI::UI
152
241
 
153
- def self.closest_prime(n)
154
- # Primes must be greater than 1
155
- return 2 if n <= 2
156
- return n if ::Prime.prime?(n)
157
-
158
- distance = 1
159
- loop do
160
- lower = n - distance
161
- upper = n + distance
242
+ # Well-known TCP ports: SSH, DNS, HTTP, NTP, NetBIOS, HTTPS, SMB, IPP,
243
+ # RDP, and the two AirPlay and Synology use.
244
+ COMMON_PORTS = [22, 53, 80, 123, 139, 443, 445, 631, 3389, 5000, 7000].freeze
245
+
246
+ # How many addresses are probed at once. Each probes its ports all at
247
+ # once, so this keeps open sockets well under the default limit of 256.
248
+ CONCURRENCY = 10
249
+
250
+ desc "Find hosts on the local network that answer on TCP ports"
251
+ option :port, type: :integer, desc: "Probe only this TCP port, instead of #{COMMON_PORTS.join(', ')}"
252
+ option :timeout, type: :float, default: 0.5, aliases: ["-t"], desc: "Seconds to wait for each port"
253
+ option :output, aliases: ["-o"], desc: "Also write the hosts to this file"
254
+ option :progress, type: :flag, default: false, aliases: ["-p"], desc: "Show progress bars; the default with --port"
255
+ option :spinner, type: :flag, default: false, aliases: ["-s"], desc: "Show spinners; the default without --port"
256
+
257
+ def call(timeout:, port: nil, output: nil, progress: false, spinner: false, **)
258
+ return ui.error("Pick one of --progress and --spinner, not both") if progress && spinner
259
+
260
+ ip = local_ip or return ui.error("No local IPv4 address found")
261
+ subnet = ip.split(".").first(3).join(".")
262
+ ips = (1..254).map { |i| "#{subnet}.#{i}" }
263
+ ports = port ? [port.to_i] : COMMON_PORTS
264
+ ui.info "Local IP: #{ip}, scanning #{subnet}.1-254 on #{ports.size == 1 ? 'port' : 'ports'} #{ports.join(', ')}"
265
+
266
+ progress ||= port && !spinner
267
+ results = progress ? with_progress(ips, ports, timeout.to_f) : with_spinner(ips, ports, timeout.to_f)
268
+ hosts = ips.zip(results).select { |_, open| open }
269
+ return ui.warn("No hosts answered on #{ports.join(', ')}") if hosts.empty?
270
+
271
+ lines = hosts.map { |host, open| "#{host}: #{open.empty? ? 'no open ports' : open.join(', ')}" }
272
+ File.write(output, lines.join("\n") << "\n") if output
273
+ ui.success "Found #{hosts.size} hosts", lines.join("\n"), *("Written to #{output}" if output)
274
+ end
162
275
 
163
- primes = []
164
- primes << lower if lower > 1 && ::Prime.prime?(lower)
165
- primes << upper if ::Prime.prime?(upper)
276
+ private
166
277
 
167
- # Return the prime(s) found at the shortest distance
168
- return primes.size == 1 ? primes.first : primes if primes.any?
278
+ def local_ip
279
+ Socket.getifaddrs
280
+ .map(&:addr)
281
+ .find { |addr| addr&.ipv4? && !addr.ipv4_loopback? }
282
+ &.ip_address
283
+ end
169
284
 
170
- distance += 1
285
+ # A spinner per address, counting the ports answered so far. An
286
+ # address where nothing answered is marked failed.
287
+ def with_spinner(ips, ports, timeout)
288
+ ui.multi_spinner("Probing #{ips.size} addresses", concurrent: CONCURRENCY) do |m|
289
+ ips.each do |ip|
290
+ m.spinner(ip) do |line|
291
+ answered = Concurrent::AtomicFixnum.new
292
+ open = scan(ip, ports, timeout) { line.detail = "#{answered.increment} of #{ports.size} ports" }
293
+ line.fail("no answer") unless open
294
+ open
295
+ end
296
+ end
171
297
  end
172
298
  end
173
299
 
174
- desc 'Compute closest prime to a given number in the array'
175
- option :max, default: 100_000, desc: "Maximum integer to compute closest primes for", type: :integer
176
-
177
- def call(max:)
178
- ui.info "Computing closest primes..."
179
- File.open('primes.txt', 'w') do |f|
180
- integers = (1..max.to_i)
181
- ui.progress("Computing Primes", total: integers.size) do |bar|
182
- integers.each do |i|
183
- p = ComputePrimes.closest_prime(i)
184
- f.puts "closest prime to #{i} is #{p}"
185
- bar.advance
300
+ # A progress bar per address, advancing as each port answers or times out.
301
+ # Two bars, one counting the addresses that answered and one those
302
+ # that did not. Each bar's block is given only its own handle, so the
303
+ # "No answer" job hands its bar to the "Answered" job, which runs the
304
+ # scan, and then waits for the scan to end.
305
+ def with_progress(ips, ports, timeout)
306
+ silent_bar = Concurrent::Promises.resolvable_future
307
+ scanned = Concurrent::Promises.resolvable_future
308
+ results = nil
309
+ ui.multi_progress("Probing #{ips.size} addresses") do |m|
310
+ m.progress("Answered", total: ips.size, color: :green) do |answered|
311
+ silent = silent_bar.value!
312
+ results = in_parallel(ips) do |ip|
313
+ scan(ip, ports, timeout) { nil }.tap { |open| (open ? answered : silent).advance }
186
314
  end
315
+ ensure
316
+ scanned.fulfill(true)
317
+ end
318
+ m.progress("No answer", total: ips.size, color: :red) do |silent|
319
+ silent_bar.fulfill(silent)
320
+ scanned.wait
187
321
  end
188
322
  end
189
- ui.success("Computed #{max} primes, they are in the 'primes.txt' files.")
190
- rescue StandardError => e
191
- ui.error("Import failed", e.message)
323
+ results
324
+ end
325
+
326
+ # Runs the block for each item, at most CONCURRENCY at once.
327
+ #
328
+ # @return [Array] what the block returned for each item, in order
329
+ def in_parallel(items)
330
+ queue = Queue.new
331
+ items.each_with_index { |item, index| queue << [item, index] }
332
+ queue.close
333
+ results = Array.new(items.size)
334
+ Array.new(CONCURRENCY) do
335
+ Thread.new do
336
+ while (pair = queue.pop)
337
+ item, index = pair
338
+ results[index] = yield item
339
+ end
340
+ end
341
+ end.each(&:join)
342
+ results
343
+ end
344
+
345
+ # Probes every port on an address at once.
346
+ #
347
+ # @yield once for each port, as its probe ends
348
+ # @return [Array<Integer>, nil] the open ports, empty when every port
349
+ # refused; nil when nothing answered at all
350
+ def scan(ip, ports, timeout, &)
351
+ states = ports.map do |port|
352
+ Thread.new do
353
+ probe(ip, port, timeout).tap(&)
354
+ end
355
+ end.map(&:value)
356
+ return if states.all?(:silent)
357
+
358
+ ports.zip(states).filter_map { |port, state| port if state == :open }
359
+ end
360
+
361
+ # A refused connection still means something is at that address.
362
+ #
363
+ # @return [Symbol] :open, :closed, or :silent when nothing answered
364
+ def probe(ip, port, timeout)
365
+ Socket.tcp(ip, port, connect_timeout: timeout).close
366
+ :open
367
+ rescue Errno::ECONNREFUSED
368
+ :closed
369
+ rescue SystemCallError, IOError, SocketError
370
+ :silent
192
371
  end
193
372
  end
194
373
 
195
- register "version", Version, aliases: ["v", "-v", "--version"]
196
- register "urls_progress", FetchUrlsProgress
197
- register "urls_spinner", FetchUrls
198
- register "completion", ::Dry::CLI::Autocomplete::Command[::Foo::CLI::Commands], hidden: true
199
- register "primes", ComputePrimes
374
+ register "version", Version, aliases: ["v", "-v", "--version"]
375
+ register "download-urls", DownloadUrls
376
+ register "find-hosts", FindHosts
377
+ register "completion", ::Dry::CLI::Autocomplete::Command[::Foo::CLI::Commands], hidden: true
200
378
  end
201
379
  end
202
380
  end
203
381
 
204
382
  Dry::CLI.new(Foo::CLI::Commands).call
205
-
206
- FileUtils.rm_f(Dir.glob("#{File.expand_path('../', __dir__)}/*.txt"))