dry-cli-ui 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -2
- data/README.md +458 -40
- 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 +333 -102
- data/lib/dry/cli/ui/configuration.rb +3 -3
- data/lib/dry/cli/ui/console.rb +39 -10
- data/lib/dry/cli/ui/status_bar.rb +1 -1
- data/lib/dry/cli/ui/stop.rb +52 -0
- data/lib/dry/cli/ui/version.rb +1 -1
- data/lib/dry/cli/ui/widgets/multi.rb +63 -14
- data/lib/dry/cli/ui/widgets/multi_progress.rb +67 -20
- data/lib/dry/cli/ui/widgets/pool.rb +15 -11
- data/lib/dry/cli/ui/widgets/progress.rb +65 -19
- data/lib/dry/cli/ui.rb +1 -0
- data/sig/dry/cli/ui.rbs +10 -3
- metadata +4 -2
- data/SPECIFICATION.md +0 -411
data/examples/.envrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
PATH_add bin
|
data/examples/.gitignore
ADDED
|
@@ -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:
|
|
13
|
+
gem 'dry-cli-ui', path: '../'
|
|
14
14
|
end
|
data/examples/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
|
-
remote:
|
|
2
|
+
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
dry-cli-ui (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.
|
|
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.
|
|
87
|
-
dry-cli-ui (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
|
-
|
|
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,74 @@ module Foo
|
|
|
27
28
|
end
|
|
28
29
|
end
|
|
29
30
|
|
|
30
|
-
#
|
|
31
|
-
|
|
31
|
+
# Adds -c/--concurrency, how many items a command works on at once.
|
|
32
|
+
module Concurrency
|
|
33
|
+
DEFAULT = 10
|
|
34
|
+
MAX = 100
|
|
35
|
+
|
|
36
|
+
def self.included(command)
|
|
37
|
+
command.option :concurrency, aliases: ["-c"], type: :integer, default: DEFAULT, desc: "How many at once, from 1 to #{MAX}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# @return [Integer, nil] the value as an Integer; nil when out of range
|
|
43
|
+
def concurrency(value)
|
|
44
|
+
Integer(value, exception: false)&.then { |count| count if count.between?(1, MAX) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Used by download-urls: follows up to REDIRECTS redirects on every
|
|
49
|
+
# request, and asks for the body as it is stored, so the bytes counted
|
|
50
|
+
# match Content-Length.
|
|
32
51
|
module HTTP
|
|
33
52
|
HEADERS = { "Accept-Encoding" => "identity" }.freeze
|
|
34
53
|
REDIRECTS = 5
|
|
35
54
|
|
|
36
55
|
module_function
|
|
37
56
|
|
|
57
|
+
# Statuses a server answers HEAD with when it only serves GET.
|
|
58
|
+
NO_HEAD = %w[403 405 501].freeze
|
|
59
|
+
|
|
38
60
|
# @return [Array(URI, Integer, nil)] where the URL ends up, and its size when the server says
|
|
39
61
|
# @raise [RuntimeError] for a response that is neither a redirect nor a success
|
|
40
62
|
def resolve(url, hops = REDIRECTS)
|
|
41
63
|
uri = URI(url)
|
|
42
64
|
response = start(uri) { |http| http.head(uri.request_uri, HEADERS) }
|
|
43
|
-
if response.is_a?(Net::HTTPRedirection)
|
|
44
|
-
|
|
45
|
-
end
|
|
65
|
+
return resolve(redirect(uri, response, hops), hops - 1) if response.is_a?(Net::HTTPRedirection)
|
|
66
|
+
return [uri, nil] if NO_HEAD.include?(response.code) # the GET finds out
|
|
46
67
|
raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
47
68
|
|
|
48
69
|
[uri, response.content_length]
|
|
49
70
|
end
|
|
50
71
|
|
|
51
|
-
# @yieldparam
|
|
52
|
-
#
|
|
72
|
+
# @yieldparam response [Net::HTTPSuccess] the response, whose body is
|
|
73
|
+
# read in the block
|
|
74
|
+
# @return [Object] what the block returned
|
|
53
75
|
# @raise [RuntimeError] for a response that is not a success
|
|
54
|
-
def
|
|
55
|
-
|
|
76
|
+
def get(uri, hops = REDIRECTS, &)
|
|
77
|
+
result = nil
|
|
78
|
+
location = nil
|
|
56
79
|
start(uri) do |http|
|
|
57
80
|
http.request_get(uri.request_uri, HEADERS) do |response|
|
|
81
|
+
next location = redirect(uri, response, hops) if response.is_a?(Net::HTTPRedirection)
|
|
58
82
|
raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
59
83
|
|
|
60
|
-
|
|
61
|
-
body << chunk
|
|
62
|
-
yield chunk.bytesize if block_given?
|
|
63
|
-
end
|
|
84
|
+
result = yield response
|
|
64
85
|
end
|
|
65
86
|
end
|
|
66
|
-
|
|
87
|
+
return get(URI(location), hops - 1, &) if location
|
|
88
|
+
|
|
89
|
+
result
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [String] the absolute URL a redirect points to
|
|
93
|
+
# @raise [RuntimeError] once there are no hops left, or without a Location
|
|
94
|
+
def redirect(uri, response, hops)
|
|
95
|
+
raise "Too many redirects" unless hops.positive?
|
|
96
|
+
raise "HTTP #{response.code} without a Location" unless response["location"]
|
|
97
|
+
|
|
98
|
+
URI.join(uri, response["location"]).to_s
|
|
67
99
|
end
|
|
68
100
|
|
|
69
101
|
def start(uri, &)
|
|
@@ -71,136 +103,335 @@ module Foo
|
|
|
71
103
|
end
|
|
72
104
|
end
|
|
73
105
|
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
106
|
+
# Downloads URLs, each to its own file as its body arrives, at most
|
|
107
|
+
# --concurrency at once and in the order given, under a spinner or a
|
|
108
|
+
# progress bar.
|
|
109
|
+
class DownloadUrls < Dry::CLI::Command
|
|
77
110
|
include Dry::CLI::UI
|
|
78
111
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
112
|
+
# File extensions for the media types a URL's own name may not reveal.
|
|
113
|
+
EXTENSIONS = {
|
|
114
|
+
"application/gzip" => ".gz",
|
|
115
|
+
"application/javascript" => ".js",
|
|
116
|
+
"application/json" => ".json",
|
|
117
|
+
"application/pdf" => ".pdf",
|
|
118
|
+
"application/xml" => ".xml",
|
|
119
|
+
"application/zip" => ".zip",
|
|
120
|
+
"image/gif" => ".gif",
|
|
121
|
+
"image/jpeg" => ".jpg",
|
|
122
|
+
"image/png" => ".png",
|
|
123
|
+
"image/svg+xml" => ".svg",
|
|
124
|
+
"image/webp" => ".webp",
|
|
125
|
+
"text/css" => ".css",
|
|
126
|
+
"text/csv" => ".csv",
|
|
127
|
+
"text/html" => ".html",
|
|
128
|
+
"text/javascript" => ".js",
|
|
129
|
+
"text/markdown" => ".md",
|
|
130
|
+
"text/plain" => ".txt",
|
|
131
|
+
"text/xml" => ".xml"
|
|
132
|
+
}.freeze
|
|
133
|
+
|
|
134
|
+
desc "Download URLs, each to its own file"
|
|
135
|
+
argument :urls, type: :array, required: false, desc: "URLs to download, separated by spaces"
|
|
136
|
+
option :'urls-file', aliases: ["-u"], desc: "A file with one URL per line; blank lines and # comments are skipped"
|
|
137
|
+
option :output, aliases: ["-o"], default: ".", desc: "A directory to save the files in, created when missing"
|
|
138
|
+
option :progress, aliases: ["-p"], type: :flag, default: true, desc: "Show a progress bar per URL"
|
|
139
|
+
option :spinner, aliases: ["-s"], type: :flag, default: false, desc: "Show a spinner per URL; used when neither flag is given"
|
|
140
|
+
include Concurrency
|
|
141
|
+
|
|
142
|
+
def call(urls: [], progress: false, spinner: false, **options)
|
|
143
|
+
progress = false if spinner
|
|
144
|
+
@concurrency = concurrency(options[:concurrency]) or
|
|
145
|
+
return ui.error("--concurrency must be from 1 to #{Concurrency::MAX}, got #{options[:concurrency]}")
|
|
146
|
+
|
|
147
|
+
urls_file = options[:'urls-file']
|
|
148
|
+
save_to = options.fetch(:output, ".")
|
|
149
|
+
|
|
150
|
+
begin
|
|
151
|
+
urls += read_urls(urls_file) if urls_file
|
|
152
|
+
rescue SystemCallError => e
|
|
153
|
+
return ui.error("Cannot read #{urls_file}", e.message)
|
|
90
154
|
end
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
155
|
+
urls, invalid = urls.partition { |url| valid?(url) }
|
|
156
|
+
download(urls, save_to, progress)
|
|
157
|
+
ui.warn "Skipped #{invalid.size} invalid URLs", invalid.join("\n") if invalid.any?
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def download(urls, save_to, progress)
|
|
163
|
+
return ui.error("No URLs to download", "Pass them as arguments, or in a file with --urls-file") if urls.empty?
|
|
164
|
+
|
|
165
|
+
begin
|
|
166
|
+
FileUtils.mkdir_p(save_to)
|
|
167
|
+
rescue SystemCallError => e
|
|
168
|
+
return ui.error("Cannot create #{save_to}", e.message)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Ctrl-C lets the downloads running finish, and starts no more.
|
|
172
|
+
files = ui.stoppable do |stop|
|
|
173
|
+
@stop = stop
|
|
174
|
+
(progress ? with_progress(urls, save_to) : with_spinner(urls, save_to)).compact
|
|
103
175
|
end
|
|
176
|
+
return ui.info("Stopped", "Downloaded #{files.size} of #{urls.size} URLs into #{save_to}") if @stop.stopped?
|
|
104
177
|
|
|
105
|
-
|
|
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)
|
|
178
|
+
ui.success "Wrote #{files.size} of #{urls.size} URLs", files.join("\n")
|
|
109
179
|
end
|
|
110
|
-
end
|
|
111
180
|
|
|
112
|
-
|
|
113
|
-
class FetchUrls < Dry::CLI::Command
|
|
114
|
-
include Dry::CLI::UI
|
|
181
|
+
# An http or https URL with a host.
|
|
115
182
|
|
|
116
|
-
|
|
117
|
-
|
|
183
|
+
def valid?(url)
|
|
184
|
+
uri = URI.parse(url)
|
|
185
|
+
uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
186
|
+
rescue URI::InvalidURIError
|
|
187
|
+
false
|
|
188
|
+
end
|
|
118
189
|
|
|
119
|
-
|
|
120
|
-
|
|
190
|
+
# A spinner per URL; a URL that fails is marked failed.
|
|
191
|
+
#
|
|
192
|
+
# @return [Array<String, nil>] the file written for each URL
|
|
193
|
+
def with_spinner(urls, save_to)
|
|
194
|
+
ui.multi_spinner("Downloading #{urls.size} URLs", concurrent: @concurrency, stop: @stop) do |m|
|
|
121
195
|
urls.each do |url|
|
|
122
196
|
m.spinner(url) do |line|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
line.detail = "downloading #{uri.host}"
|
|
126
|
-
sleep rand(1..4) # slow enough to watch
|
|
127
|
-
HTTP.download(uri)
|
|
197
|
+
received = 0
|
|
198
|
+
save(url, URI(url), save_to) { |bytes| line.detail = "#{received += bytes} bytes" }
|
|
128
199
|
rescue StandardError => e
|
|
129
200
|
line.fail(e.message)
|
|
130
201
|
nil
|
|
131
202
|
end
|
|
132
203
|
end
|
|
133
204
|
end
|
|
205
|
+
end
|
|
134
206
|
|
|
135
|
-
|
|
136
|
-
|
|
207
|
+
# A bar per URL, and a headline bar counting the URLs done. When a
|
|
208
|
+
# URL's turn comes, a HEAD request finds its size for the bar's total,
|
|
209
|
+
# then the file is downloaded.
|
|
210
|
+
#
|
|
211
|
+
# @return [Array<String, nil>] the file written for each URL
|
|
212
|
+
def with_progress(urls, save_to)
|
|
213
|
+
# A bar cannot be marked failed, so a download that fails is
|
|
214
|
+
# reported once every bar has finished.
|
|
215
|
+
errors = Concurrent::Array.new
|
|
216
|
+
files = ui.multi_progress("Downloading #{urls.size} URLs", concurrent: @concurrency, count: :jobs, stop: @stop) do |m|
|
|
217
|
+
urls.each do |url|
|
|
218
|
+
m.progress(url, total: nil) do |bar|
|
|
219
|
+
uri, size = HTTP.resolve(url)
|
|
220
|
+
bar.total = size if size
|
|
221
|
+
save(url, uri, save_to) { |bytes| bar.advance(bytes) }.tap { bar.total = bar.current unless size }
|
|
222
|
+
rescue StandardError => e
|
|
223
|
+
errors << "#{url}: #{e.message}"
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
ui.warn "#{errors.size} URLs failed", errors.join("\n") if errors.any?
|
|
229
|
+
files
|
|
230
|
+
end
|
|
137
231
|
|
|
138
|
-
|
|
232
|
+
# Downloads a URL straight into its file, removing what was written
|
|
233
|
+
# when the download fails.
|
|
234
|
+
#
|
|
235
|
+
# @param url [String] what the file is named after
|
|
236
|
+
# @param uri [URI] where to download it from
|
|
237
|
+
# @yieldparam bytes [Integer] the size of each chunk as it is written
|
|
238
|
+
# @return [String] the file written
|
|
239
|
+
def save(url, uri, save_to)
|
|
240
|
+
file = nil
|
|
241
|
+
HTTP.get(uri) do |response|
|
|
242
|
+
file = File.join(save_to, filename(url, response.content_type))
|
|
243
|
+
File.open(file, "wb") do |io|
|
|
244
|
+
response.read_body do |chunk|
|
|
245
|
+
io.write(chunk)
|
|
246
|
+
yield chunk.bytesize
|
|
247
|
+
end
|
|
248
|
+
end
|
|
139
249
|
end
|
|
140
|
-
|
|
250
|
+
file
|
|
251
|
+
rescue StandardError
|
|
252
|
+
FileUtils.rm_f(file) if file
|
|
253
|
+
raise
|
|
141
254
|
end
|
|
142
255
|
|
|
143
|
-
|
|
256
|
+
def read_urls(file)
|
|
257
|
+
File.readlines(file, chomp: true).map(&:strip).reject { |line| line.empty? || line.start_with?("#") }
|
|
258
|
+
end
|
|
144
259
|
|
|
145
|
-
|
|
146
|
-
|
|
260
|
+
# The URL's host, path and query as one safe name, keeping the path's
|
|
261
|
+
# extension. Without one, the extension comes from the media type,
|
|
262
|
+
# and is .txt when that is unknown too.
|
|
263
|
+
def filename(url, type)
|
|
264
|
+
uri = URI(url)
|
|
265
|
+
extension = File.extname(uri.path)
|
|
266
|
+
stem = "#{uri.host}#{uri.path.delete_suffix(extension)}#{"_#{uri.query}" if uri.query}"
|
|
267
|
+
extension = EXTENSIONS.fetch(type, ".txt") if extension.empty?
|
|
268
|
+
"#{stem.gsub(/[^\w.-]+/, '_').delete_suffix('_')}#{extension}"
|
|
147
269
|
end
|
|
148
270
|
end
|
|
149
271
|
|
|
150
|
-
|
|
272
|
+
# Scans the local /24 subnet for hosts that answer on TCP ports, with a
|
|
273
|
+
# spinner or a progress bar for every address.
|
|
274
|
+
class FindHosts < Dry::CLI::Command
|
|
151
275
|
include Dry::CLI::UI
|
|
152
276
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
277
|
+
# Well-known TCP ports: SSH, DNS, HTTP, NTP, NetBIOS, HTTPS, SMB, IPP,
|
|
278
|
+
# RDP, and the two AirPlay and Synology use.
|
|
279
|
+
COMMON_PORTS = [22, 53, 80, 123, 139, 443, 445, 631, 3389, 5000, 7000].freeze
|
|
280
|
+
|
|
281
|
+
desc "Find hosts on the local network that answer on TCP ports"
|
|
282
|
+
option :port, type: :integer, desc: "Probe only this TCP port, instead of #{COMMON_PORTS.join(', ')}"
|
|
283
|
+
option :timeout, type: :float, default: 0.5, aliases: ["-t"], desc: "Seconds to wait for each port"
|
|
284
|
+
option :output, aliases: ["-o"], desc: "Also write the hosts to this file"
|
|
285
|
+
option :progress, aliases: ["-p"], type: :flag, default: true, desc: "Show progress bars"
|
|
286
|
+
option :spinner, aliases: ["-s"], type: :flag, default: false, desc: "Show spinners"
|
|
287
|
+
include Concurrency
|
|
288
|
+
|
|
289
|
+
def call(timeout:, port: nil, output: nil, progress: false, spinner: false, concurrency: Concurrency::DEFAULT, **)
|
|
290
|
+
progress = false if spinner
|
|
291
|
+
@concurrency = concurrency(concurrency) or return ui.error("--concurrency must be from 1 to #{Concurrency::MAX}, got #{concurrency}")
|
|
292
|
+
|
|
293
|
+
open_files_for(@concurrency * COMMON_PORTS.size)
|
|
294
|
+
|
|
295
|
+
ip = local_ip or return ui.error("No local IPv4 address found")
|
|
296
|
+
subnet = ip.split(".").first(3).join(".")
|
|
297
|
+
ips = (1..254).map { |i| "#{subnet}.#{i}" }
|
|
298
|
+
ports = port ? [port.to_i] : COMMON_PORTS
|
|
299
|
+
ui.info "Local IP: #{ip}, scanning #{subnet}.1-254 on #{ports.size == 1 ? 'port' : 'ports'} #{ports.join(', ')}"
|
|
300
|
+
|
|
301
|
+
progress ||= port && !spinner
|
|
302
|
+
@scanned = Concurrent::AtomicFixnum.new
|
|
303
|
+
# Ctrl-C lets the addresses being probed finish, and probes no more.
|
|
304
|
+
results = ui.stoppable do |stop|
|
|
305
|
+
@stop = stop
|
|
306
|
+
progress ? with_progress(ips, ports, timeout.to_f) : with_spinner(ips, ports, timeout.to_f)
|
|
307
|
+
end
|
|
308
|
+
hosts = ips.zip(results).select { |_, open| open }
|
|
309
|
+
lines = hosts.map { |host, open| "#{host}: #{open.empty? ? 'no open ports' : open.join(', ')}" }
|
|
310
|
+
File.write(output, lines.join("\n") << "\n") if output && hosts.any?
|
|
311
|
+
if @stop.stopped?
|
|
312
|
+
return ui.info("Stopped", "Scanned #{@scanned.value} of #{ips.size} addresses, and found #{hosts.size} hosts", *lines)
|
|
313
|
+
end
|
|
314
|
+
return ui.warn("No hosts answered on #{ports.join(', ')}") if hosts.empty?
|
|
157
315
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
lower = n - distance
|
|
161
|
-
upper = n + distance
|
|
316
|
+
ui.success "Found #{hosts.size} hosts", lines.join("\n"), *("Written to #{output}" if output)
|
|
317
|
+
end
|
|
162
318
|
|
|
163
|
-
|
|
164
|
-
primes << lower if lower > 1 && ::Prime.prime?(lower)
|
|
165
|
-
primes << upper if ::Prime.prime?(upper)
|
|
319
|
+
private
|
|
166
320
|
|
|
167
|
-
|
|
168
|
-
|
|
321
|
+
# Each address probes its ports all at once, so raise the open file limit
|
|
322
|
+
# when the default, often 256, is too low; a probe that cannot open a
|
|
323
|
+
# socket would read as no answer.
|
|
324
|
+
def open_files_for(sockets)
|
|
325
|
+
soft, hard = Process.getrlimit(:NOFILE)
|
|
326
|
+
wanted = sockets + 64
|
|
327
|
+
Process.setrlimit(:NOFILE, [wanted, hard].min, hard) if soft < wanted
|
|
328
|
+
rescue SystemCallError
|
|
329
|
+
nil
|
|
330
|
+
end
|
|
169
331
|
|
|
170
|
-
|
|
171
|
-
|
|
332
|
+
def local_ip
|
|
333
|
+
Socket.getifaddrs
|
|
334
|
+
.map(&:addr)
|
|
335
|
+
.find { |addr| addr&.ipv4? && !addr.ipv4_loopback? }
|
|
336
|
+
&.ip_address
|
|
172
337
|
end
|
|
173
338
|
|
|
174
|
-
|
|
175
|
-
|
|
339
|
+
# A spinner per address, counting the ports answered so far. An
|
|
340
|
+
# address where nothing answered is marked failed.
|
|
341
|
+
def with_spinner(ips, ports, timeout)
|
|
342
|
+
ui.multi_spinner("Probing #{ips.size} addresses", concurrent: @concurrency, stop: @stop) do |m|
|
|
343
|
+
ips.each do |ip|
|
|
344
|
+
m.spinner(ip) do |line|
|
|
345
|
+
answered = Concurrent::AtomicFixnum.new
|
|
346
|
+
open = scan(ip, ports, timeout) { line.detail = "#{answered.increment} of #{ports.size} ports" }
|
|
347
|
+
line.fail("no answer") unless open
|
|
348
|
+
open
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
176
353
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
354
|
+
# A progress bar per address, advancing as each port answers or times out.
|
|
355
|
+
# Two bars, one counting the addresses that answered and one those
|
|
356
|
+
# that did not. Each bar's block is given only its own handle, so the
|
|
357
|
+
# "No answer" job hands its bar to the "Answered" job, which runs the
|
|
358
|
+
# scan, and then waits for the scan to end.
|
|
359
|
+
def with_progress(ips, ports, timeout)
|
|
360
|
+
silent_bar = Concurrent::Promises.resolvable_future
|
|
361
|
+
scanned = Concurrent::Promises.resolvable_future
|
|
362
|
+
results = nil
|
|
363
|
+
ui.multi_progress("Probing #{ips.size} addresses", total: ips.size) do |m|
|
|
364
|
+
m.progress("Answered", total: ips.size, color: :green) do |answered|
|
|
365
|
+
silent = silent_bar.value!
|
|
366
|
+
results = in_parallel(ips) do |ip|
|
|
367
|
+
scan(ip, ports, timeout) { nil }.tap { |open| (open ? answered : silent).advance }
|
|
186
368
|
end
|
|
369
|
+
ensure
|
|
370
|
+
scanned.fulfill(true)
|
|
371
|
+
end
|
|
372
|
+
m.progress("No answer", total: ips.size, color: :red) do |silent|
|
|
373
|
+
silent_bar.fulfill(silent)
|
|
374
|
+
scanned.wait
|
|
187
375
|
end
|
|
188
376
|
end
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
377
|
+
results
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Runs the block for each item, at most --concurrency at once.
|
|
381
|
+
#
|
|
382
|
+
# @return [Array] what the block returned for each item, in order
|
|
383
|
+
def in_parallel(items)
|
|
384
|
+
queue = Queue.new
|
|
385
|
+
items.each_with_index { |item, index| queue << [item, index] }
|
|
386
|
+
queue.close
|
|
387
|
+
results = Array.new(items.size)
|
|
388
|
+
Array.new(@concurrency) do
|
|
389
|
+
Thread.new do
|
|
390
|
+
while !@stop.stopped? && (pair = queue.pop)
|
|
391
|
+
item, index = pair
|
|
392
|
+
results[index] = yield item
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end.each(&:join)
|
|
396
|
+
results
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Probes every port on an address at once.
|
|
400
|
+
#
|
|
401
|
+
# @yield once for each port, as its probe ends
|
|
402
|
+
# @return [Array<Integer>, nil] the open ports, empty when every port
|
|
403
|
+
# refused; nil when nothing answered at all
|
|
404
|
+
def scan(ip, ports, timeout, &)
|
|
405
|
+
states = ports.map do |port|
|
|
406
|
+
Thread.new do
|
|
407
|
+
probe(ip, port, timeout).tap(&)
|
|
408
|
+
end
|
|
409
|
+
end.map(&:value)
|
|
410
|
+
@scanned.increment
|
|
411
|
+
return if states.all?(:silent)
|
|
412
|
+
|
|
413
|
+
ports.zip(states).filter_map { |port, state| port if state == :open }
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# A refused connection still means something is at that address.
|
|
417
|
+
#
|
|
418
|
+
# @return [Symbol] :open, :closed, or :silent when nothing answered
|
|
419
|
+
def probe(ip, port, timeout)
|
|
420
|
+
Socket.tcp(ip, port, connect_timeout: timeout).close
|
|
421
|
+
:open
|
|
422
|
+
rescue Errno::ECONNREFUSED
|
|
423
|
+
:closed
|
|
424
|
+
rescue SystemCallError, IOError, SocketError
|
|
425
|
+
:silent
|
|
192
426
|
end
|
|
193
427
|
end
|
|
194
428
|
|
|
195
|
-
register "version",
|
|
196
|
-
register "
|
|
197
|
-
register "
|
|
198
|
-
register "completion",
|
|
199
|
-
register "primes", ComputePrimes
|
|
429
|
+
register "version", Version, aliases: ["v", "-v", "--version"]
|
|
430
|
+
register "download-urls", DownloadUrls
|
|
431
|
+
register "find-hosts", FindHosts
|
|
432
|
+
register "completion", ::Dry::CLI::Autocomplete::Command[::Foo::CLI::Commands], hidden: true
|
|
200
433
|
end
|
|
201
434
|
end
|
|
202
435
|
end
|
|
203
436
|
|
|
204
437
|
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|
|