dry-cli-ui 0.4.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -2
- data/README.md +385 -26
- data/examples/.envrc +1 -0
- data/examples/.gitignore +1 -0
- data/examples/Gemfile +1 -1
- data/examples/Gemfile.lock +5 -5
- data/examples/README.md +24 -10
- data/examples/bin/mycli +270 -94
- data/lib/dry/cli/ui/configuration.rb +3 -3
- data/lib/dry/cli/ui/console.rb +8 -5
- data/lib/dry/cli/ui/version.rb +1 -1
- data/lib/dry/cli/ui/widgets/multi_progress.rb +11 -7
- data/lib/dry/cli/ui/widgets/progress.rb +37 -13
- data/sig/dry/cli/ui.rbs +1 -1
- metadata +3 -2
- data/SPECIFICATION.md +0 -411
data/examples/README.md
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
# Examples
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A small `dry-cli` app, `bin/mycli`, with two commands that show off the UI widgets.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
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 "
|
|
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
|
-
#
|
|
31
|
-
# body as it is stored, so the bytes counted
|
|
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)
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
120
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
primes << lower if lower > 1 && ::Prime.prime?(lower)
|
|
165
|
-
primes << upper if ::Prime.prime?(upper)
|
|
276
|
+
private
|
|
166
277
|
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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",
|
|
196
|
-
register "
|
|
197
|
-
register "
|
|
198
|
-
register "completion",
|
|
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"))
|
|
@@ -24,12 +24,12 @@ module Dry
|
|
|
24
24
|
# Anything not set reads from {DEFAULTS}.
|
|
25
25
|
class Configuration
|
|
26
26
|
# What a setting reads before it is set: a green `◼` for each finished
|
|
27
|
-
# part of a bar,
|
|
27
|
+
# part of a bar, with no background behind it.
|
|
28
28
|
DEFAULTS = {
|
|
29
29
|
spinner_format: :dots,
|
|
30
30
|
bar_format: { complete: "◼", incomplete: " " }.freeze,
|
|
31
31
|
bar_color: :green,
|
|
32
|
-
bar_background:
|
|
32
|
+
bar_background: nil
|
|
33
33
|
}.freeze
|
|
34
34
|
|
|
35
35
|
# Every style name Pastel knows, for checking colour settings.
|
|
@@ -59,7 +59,7 @@ module Dry
|
|
|
59
59
|
# @return [Symbol, nil]
|
|
60
60
|
# @!method bar_background(value = UNSET)
|
|
61
61
|
# Reads the background the whole bar is drawn on, or sets it.
|
|
62
|
-
# @param value [Symbol, nil] a Pastel style, such as :
|
|
62
|
+
# @param value [Symbol, nil] a Pastel style, such as :on_blue; nil for none
|
|
63
63
|
# @return [Symbol, nil]
|
|
64
64
|
DEFAULTS.each_key do |name|
|
|
65
65
|
define_method(name) do |value = UNSET|
|
data/lib/dry/cli/ui/console.rb
CHANGED
|
@@ -75,7 +75,7 @@ module Dry
|
|
|
75
75
|
@config = config
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
# A framed panel. Given a level, it takes that level's title, colour
|
|
78
|
+
# A framed panel, preceded by a blank line. Given a level, it takes that level's title, colour
|
|
79
79
|
# and stream; without one it is untitled unless given a title, and goes
|
|
80
80
|
# to `out`.
|
|
81
81
|
#
|
|
@@ -91,7 +91,7 @@ module Dry
|
|
|
91
91
|
theme = level && Theme.level(level)
|
|
92
92
|
terminal = theme ? stream(theme) : out
|
|
93
93
|
widget = Widgets::Box.new(terminal, width: width || box_width)
|
|
94
|
-
terminal.print(widget.render(paragraphs, title: title || theme&.title, color: theme&.color))
|
|
94
|
+
terminal.print("\n#{widget.render(paragraphs, title: title || theme&.title, color: theme&.color)}")
|
|
95
95
|
nil
|
|
96
96
|
end
|
|
97
97
|
|
|
@@ -171,13 +171,16 @@ module Dry
|
|
|
171
171
|
#
|
|
172
172
|
# @param label [String]
|
|
173
173
|
# @param total [Integer] units of work
|
|
174
|
+
# @param color [Symbol, nil] the finished part's Pastel style; nil for
|
|
175
|
+
# the configured `bar_color`
|
|
174
176
|
# @yieldparam progress [Widgets::Progress::Handle] call `advance` as units complete
|
|
175
177
|
# @return [Object] whatever the block returns
|
|
176
|
-
# @raise [ArgumentError] without a block,
|
|
177
|
-
|
|
178
|
+
# @raise [ArgumentError] without a block, when total is not a
|
|
179
|
+
# non-negative Integer, or when color is not a Pastel style
|
|
180
|
+
def progress(label, total:, color: nil, &)
|
|
178
181
|
raise ArgumentError, "progress needs a block" unless block_given?
|
|
179
182
|
|
|
180
|
-
Widgets::Progress.new(err, clock: clock, config: config).run(label, total: total, &)
|
|
183
|
+
Widgets::Progress.new(err, clock: clock, config: config).run(label, total: total, color: color, &)
|
|
181
184
|
end
|
|
182
185
|
|
|
183
186
|
# Runs several jobs at once, each with a progress bar of its own,
|
data/lib/dry/cli/ui/version.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Dry
|
|
|
14
14
|
# @example
|
|
15
15
|
# ui.multi_progress("Downloading") do |m|
|
|
16
16
|
# files.each do |file|
|
|
17
|
-
# m.progress(file.name, total: file.size) do |bar|
|
|
17
|
+
# m.progress(file.name, total: file.size, color: file.large? ? :yellow : nil) do |bar|
|
|
18
18
|
# download(file) { |bytes| bar.advance(bytes) }
|
|
19
19
|
# end
|
|
20
20
|
# end
|
|
@@ -33,14 +33,17 @@ module Dry
|
|
|
33
33
|
#
|
|
34
34
|
# @param label [String]
|
|
35
35
|
# @param total [Integer] units of work
|
|
36
|
+
# @param color [Symbol, nil] the finished part's Pastel style; nil for
|
|
37
|
+
# {Configuration#bar_color}
|
|
36
38
|
# @yieldparam progress [Progress::Handle] call `advance` as units complete
|
|
37
39
|
# @return [self]
|
|
38
|
-
# @raise [ArgumentError] without a block,
|
|
39
|
-
|
|
40
|
+
# @raise [ArgumentError] without a block, when total is not a
|
|
41
|
+
# non-negative Integer, or when color is not a Pastel style
|
|
42
|
+
def progress(label, total:, color: nil, &work)
|
|
40
43
|
raise ArgumentError, "progress #{label.inspect} needs a block" unless work
|
|
41
44
|
raise ArgumentError, "total must be a non-negative Integer, got #{total.inspect}" unless total.is_a?(Integer) && total >= 0
|
|
42
45
|
|
|
43
|
-
@jobs << Multi::Job.new(label, work, Progress::Handle.new(total, nil))
|
|
46
|
+
@jobs << Multi::Job.new(label, work, Progress::Handle.new(total, nil, color: Progress.color(color)))
|
|
44
47
|
self
|
|
45
48
|
end
|
|
46
49
|
end
|
|
@@ -55,7 +58,7 @@ module Dry
|
|
|
55
58
|
# @param width [Integer]
|
|
56
59
|
# @return [String]
|
|
57
60
|
def running(job, width)
|
|
58
|
-
"#{job.label.ljust(width)} #{meter(job.handle.current, job.handle.total, job.started)}"
|
|
61
|
+
"#{job.label.ljust(width)} #{meter(job.handle.current, job.handle.total, job.started, job.handle.color)}"
|
|
59
62
|
end
|
|
60
63
|
|
|
61
64
|
# @param job [Job]
|
|
@@ -83,10 +86,11 @@ module Dry
|
|
|
83
86
|
# @param done [Integer]
|
|
84
87
|
# @param all [Integer]
|
|
85
88
|
# @param since [Float, nil] when the work started, by the clock
|
|
89
|
+
# @param color [Symbol, nil] the bar's own colour, if any
|
|
86
90
|
# @return [String]
|
|
87
|
-
def meter(done, all, since)
|
|
91
|
+
def meter(done, all, since, color = nil)
|
|
88
92
|
ratio = all.zero? ? 1.0 : done.fdiv(all)
|
|
89
|
-
bar = Progress.bar(terminal.pastel, config, ratio, bar_columns)
|
|
93
|
+
bar = Progress.bar(terminal.pastel, config, ratio, bar_columns, color: color)
|
|
90
94
|
format("%<bar>s %<percent>3d%% %<count>s ETA %<eta>s",
|
|
91
95
|
bar: bar, percent: (ratio * 100).floor, count: "#{done}/#{all}".rjust(count_width), eta: eta(done, all, since))
|
|
92
96
|
end
|