ruby-progress 1.0.1 → 1.1.2
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/.editorconfig +14 -0
- data/.markdownlint.json +9 -0
- data/CHANGELOG.md +63 -1
- data/Gemfile.lock +1 -1
- data/README.md +200 -45
- data/Rakefile +121 -0
- data/bin/prg +584 -65
- data/bin/ripple +6 -143
- data/bin/twirl +10 -0
- data/bin/worm +6 -76
- data/examples/bash_daemon_demo.sh +67 -0
- data/examples/daemon_demo.rb +67 -0
- data/examples/utils_demo.rb +21 -20
- data/lib/ruby-progress/daemon.rb +60 -0
- data/lib/ruby-progress/utils.rb +2 -2
- data/lib/ruby-progress/version.rb +1 -1
- data/lib/ruby-progress/worm.rb +78 -6
- data/lib/ruby-progress.rb +1 -0
- data/ruby-progress.gemspec +2 -2
- data/worm.rb +1 -0
- metadata +11 -3
data/bin/prg
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
require 'ruby-progress'
|
|
5
|
+
require 'fileutils'
|
|
5
6
|
require 'optparse'
|
|
7
|
+
require 'json'
|
|
8
|
+
require 'English'
|
|
6
9
|
|
|
7
10
|
# Unified progress indicator CLI
|
|
8
11
|
module PrgCLI
|
|
@@ -13,16 +16,22 @@ module PrgCLI
|
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
subcommand = ARGV.shift.downcase
|
|
16
|
-
|
|
19
|
+
|
|
17
20
|
case subcommand
|
|
18
21
|
when 'ripple'
|
|
19
22
|
RippleCLI.run
|
|
20
23
|
when 'worm'
|
|
21
24
|
WormCLI.run
|
|
25
|
+
when 'twirl'
|
|
26
|
+
TwirlCLI.run
|
|
27
|
+
when '--list-styles'
|
|
28
|
+
show_styles
|
|
29
|
+
exit
|
|
22
30
|
when '-v', '--version'
|
|
23
31
|
puts "prg version #{RubyProgress::VERSION}"
|
|
24
|
-
puts
|
|
25
|
-
puts
|
|
32
|
+
puts ' ripple - Text ripple animation with color effects'
|
|
33
|
+
puts ' worm - Unicode wave animation with customizable styles'
|
|
34
|
+
puts ' twirl - Spinner animation with various indicator styles'
|
|
26
35
|
exit
|
|
27
36
|
when '-h', '--help', 'help'
|
|
28
37
|
show_help
|
|
@@ -42,21 +51,63 @@ module PrgCLI
|
|
|
42
51
|
prg <subcommand> [options]
|
|
43
52
|
|
|
44
53
|
SUBCOMMANDS:
|
|
45
|
-
ripple Text ripple animation with
|
|
54
|
+
ripple Text ripple animation with color effects
|
|
46
55
|
worm Unicode wave animation with customizable dot styles
|
|
56
|
+
twirl Spinner-based animation with various indicator styles
|
|
47
57
|
|
|
48
58
|
GLOBAL OPTIONS:
|
|
49
59
|
-v, --version Show version information
|
|
50
60
|
-h, --help Show this help message
|
|
61
|
+
--list-styles List all available styles for all subcommands
|
|
51
62
|
|
|
52
63
|
EXAMPLES:
|
|
53
|
-
prg ripple "Loading..." --rainbow --speed fast
|
|
64
|
+
prg ripple "Loading..." --style rainbow --speed fast
|
|
54
65
|
prg worm --message "Processing" --style blocks --checkmark
|
|
66
|
+
prg twirl --message "Spinning" --style dots --checkmark
|
|
55
67
|
prg ripple --command "sleep 3" --success "Done!" --checkmark
|
|
56
68
|
|
|
57
69
|
Run 'prg <subcommand> --help' for specific subcommand options.
|
|
58
70
|
HELP
|
|
59
71
|
end
|
|
72
|
+
|
|
73
|
+
# Detach the current process into a background daemon so the calling shell
|
|
74
|
+
# doesn't track it as a job (prevents 'job ... has ended' notifications).
|
|
75
|
+
# Intentionally keeps stdio open so the daemon can print a final message.
|
|
76
|
+
def self.daemonize
|
|
77
|
+
return if ENV['PRG_NO_DAEMONIZE'] == '1'
|
|
78
|
+
|
|
79
|
+
pid = fork
|
|
80
|
+
if pid
|
|
81
|
+
# Parent exits immediately; child continues
|
|
82
|
+
exit 0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Become session leader and detach from controlling terminal
|
|
86
|
+
Process.setsid
|
|
87
|
+
|
|
88
|
+
# Second fork to avoid reacquiring a controlling terminal
|
|
89
|
+
pid2 = fork
|
|
90
|
+
exit 0 if pid2
|
|
91
|
+
|
|
92
|
+
# Do not chdir or close stdio: we want to be able to emit completion output
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.show_styles
|
|
96
|
+
puts '== ripple'
|
|
97
|
+
puts 'rainbow'
|
|
98
|
+
puts 'inverse'
|
|
99
|
+
puts 'caps'
|
|
100
|
+
puts ''
|
|
101
|
+
puts '== worm'
|
|
102
|
+
puts 'circles'
|
|
103
|
+
puts 'blocks'
|
|
104
|
+
puts 'geometric'
|
|
105
|
+
puts ''
|
|
106
|
+
puts '== twirl'
|
|
107
|
+
RubyProgress::INDICATORS.each_key do |name|
|
|
108
|
+
puts name
|
|
109
|
+
end
|
|
110
|
+
end
|
|
60
111
|
end
|
|
61
112
|
|
|
62
113
|
# Enhanced Ripple CLI with unified flags
|
|
@@ -70,11 +121,8 @@ module RippleCLI
|
|
|
70
121
|
options = {
|
|
71
122
|
speed: :medium,
|
|
72
123
|
direction: :bidirectional,
|
|
73
|
-
|
|
74
|
-
spinner: false,
|
|
75
|
-
spinner_position: :before,
|
|
124
|
+
styles: [],
|
|
76
125
|
caps: false,
|
|
77
|
-
inverse: false,
|
|
78
126
|
command: nil,
|
|
79
127
|
success_message: nil,
|
|
80
128
|
fail_message: nil,
|
|
@@ -101,38 +149,14 @@ module RippleCLI
|
|
|
101
149
|
options[:message] = msg
|
|
102
150
|
end
|
|
103
151
|
|
|
104
|
-
opts.on('
|
|
105
|
-
options[:
|
|
152
|
+
opts.on('--style STYLES', 'Animation styles (rainbow, inverse, caps - can be comma-separated)') do |styles|
|
|
153
|
+
options[:styles] = styles.split(',').map(&:strip).map(&:to_sym)
|
|
106
154
|
end
|
|
107
155
|
|
|
108
156
|
opts.on('-d', '--direction DIRECTION', 'Animation direction (forward/bidirectional or f/b)') do |f|
|
|
109
157
|
options[:format] = f =~ /^f/i ? :forward_only : :bidirectional
|
|
110
158
|
end
|
|
111
159
|
|
|
112
|
-
opts.on('-i', '--inverse', 'Enable inverse highlighting mode') do
|
|
113
|
-
options[:inverse] = true
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
opts.separator ''
|
|
117
|
-
opts.separator 'Spinner Options:'
|
|
118
|
-
|
|
119
|
-
opts.on('--spinner TYPE', 'Use spinner animation instead of text ripple') do |type|
|
|
120
|
-
options[:spinner] = type.normalize_type
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
opts.on('--spinner-pos POSITION', 'Spinner position (before/after or b/a)') do |pos|
|
|
124
|
-
options[:spinner_position] = pos =~ /^a/i ? :after : :before
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
opts.on('--caps', 'Enable case transformation mode') do
|
|
128
|
-
options[:caps] = true
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
opts.on('--list-spinners', 'List all available spinner types') do
|
|
132
|
-
show_spinners
|
|
133
|
-
exit
|
|
134
|
-
end
|
|
135
|
-
|
|
136
160
|
opts.separator ''
|
|
137
161
|
opts.separator 'Command Execution:'
|
|
138
162
|
|
|
@@ -160,6 +184,40 @@ module RippleCLI
|
|
|
160
184
|
options[:output] = :quiet
|
|
161
185
|
end
|
|
162
186
|
|
|
187
|
+
opts.separator ''
|
|
188
|
+
opts.separator 'Daemon Mode:'
|
|
189
|
+
|
|
190
|
+
opts.on('--daemon', 'Run in background daemon mode') do
|
|
191
|
+
options[:daemon] = true
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
opts.on('--pid-file FILE', 'Write process ID to file (default: /tmp/ruby-progress/progress.pid)') do |file|
|
|
195
|
+
options[:pid_file] = file
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
opts.on('--stop', 'Stop daemon (uses default PID file unless --pid-file specified)') do
|
|
199
|
+
options[:stop] = true
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
opts.on('--status', 'Show daemon status (running/not running)') do
|
|
203
|
+
options[:status] = true
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
opts.on('--stop-success MESSAGE', 'When stopping, show this success message') do |msg|
|
|
207
|
+
options[:stop_success] = msg
|
|
208
|
+
end
|
|
209
|
+
opts.on('--stop-error MESSAGE', 'When stopping, show this error message') do |msg|
|
|
210
|
+
options[:stop_error] = msg
|
|
211
|
+
end
|
|
212
|
+
opts.on('--stop-checkmark', 'When stopping, include a success/error checkmark') do
|
|
213
|
+
options[:stop_checkmark] = true
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
opts.separator ''
|
|
217
|
+
opts.separator 'Daemon notes:'
|
|
218
|
+
opts.separator ' - Do not append &; prg detaches itself and returns immediately.'
|
|
219
|
+
opts.separator ' - Use --status/--stop with optional --pid-file to control it.'
|
|
220
|
+
|
|
163
221
|
opts.separator ''
|
|
164
222
|
opts.separator 'General:'
|
|
165
223
|
|
|
@@ -174,26 +232,49 @@ module RippleCLI
|
|
|
174
232
|
end
|
|
175
233
|
end.parse!
|
|
176
234
|
|
|
177
|
-
#
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
235
|
+
# Daemon/status/stop handling (process these without requiring text)
|
|
236
|
+
if options[:status]
|
|
237
|
+
pid_file = options[:pid_file] || RubyProgress::Daemon.default_pid_file
|
|
238
|
+
RubyProgress::Daemon.show_status(pid_file)
|
|
239
|
+
exit
|
|
240
|
+
elsif options[:stop]
|
|
241
|
+
pid_file = options[:pid_file] || RubyProgress::Daemon.default_pid_file
|
|
242
|
+
stop_msg = options[:stop_error] || options[:stop_success]
|
|
243
|
+
is_error = !options[:stop_error].nil?
|
|
244
|
+
RubyProgress::Daemon.stop_daemon_by_pid_file(
|
|
245
|
+
pid_file,
|
|
246
|
+
message: stop_msg,
|
|
247
|
+
checkmark: options[:stop_checkmark],
|
|
248
|
+
error: is_error
|
|
249
|
+
)
|
|
250
|
+
exit
|
|
251
|
+
elsif options[:daemon]
|
|
252
|
+
# For daemon mode, detach so shell has no tracked job
|
|
253
|
+
PrgCLI.daemonize
|
|
254
|
+
|
|
255
|
+
# For daemon mode, default message if none provided
|
|
256
|
+
text = options[:message] || ARGV.join(' ')
|
|
257
|
+
text = 'Processing' if text.nil? || text.empty?
|
|
258
|
+
run_daemon_mode(text, options)
|
|
188
259
|
else
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
260
|
+
# Non-daemon path requires text
|
|
261
|
+
text = options[:message] || ARGV.join(' ')
|
|
262
|
+
if text.empty?
|
|
263
|
+
puts 'Error: Please provide text to animate via argument or --message flag'
|
|
264
|
+
puts "Example: prg ripple 'Loading...' or prg ripple --message 'Loading...'"
|
|
265
|
+
exit 1
|
|
266
|
+
end
|
|
192
267
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
268
|
+
# Convert styles array to individual flags for backward compatibility
|
|
269
|
+
options[:rainbow] = options[:styles].include?(:rainbow)
|
|
270
|
+
options[:inverse] = options[:styles].include?(:inverse)
|
|
271
|
+
options[:caps] = options[:styles].include?(:caps)
|
|
272
|
+
|
|
273
|
+
if options[:command]
|
|
274
|
+
run_with_command(text, options)
|
|
275
|
+
else
|
|
276
|
+
run_indefinitely(text, options)
|
|
277
|
+
end
|
|
197
278
|
end
|
|
198
279
|
end
|
|
199
280
|
|
|
@@ -203,11 +284,9 @@ module RippleCLI
|
|
|
203
284
|
captured_output = `#{options[:command]} 2>&1`
|
|
204
285
|
end
|
|
205
286
|
|
|
206
|
-
success =
|
|
287
|
+
success = $CHILD_STATUS.success?
|
|
207
288
|
|
|
208
|
-
if options[:output] == :stdout
|
|
209
|
-
puts captured_output
|
|
210
|
-
end
|
|
289
|
+
puts captured_output if options[:output] == :stdout
|
|
211
290
|
if options[:success_message] || options[:complete_checkmark]
|
|
212
291
|
message = success ? options[:success_message] : options[:fail_message] || options[:success_message]
|
|
213
292
|
RubyProgress::Ripple.complete(text, message, options[:complete_checkmark], success)
|
|
@@ -219,14 +298,64 @@ module RippleCLI
|
|
|
219
298
|
rippler = RubyProgress::Ripple.new(text, options)
|
|
220
299
|
RubyProgress::Utils.hide_cursor
|
|
221
300
|
begin
|
|
222
|
-
|
|
223
|
-
rippler.advance
|
|
224
|
-
end
|
|
301
|
+
loop { rippler.advance }
|
|
225
302
|
ensure
|
|
226
303
|
RubyProgress::Utils.show_cursor
|
|
227
304
|
RubyProgress::Ripple.complete(text, options[:success_message], options[:complete_checkmark], true)
|
|
228
305
|
end
|
|
229
306
|
end
|
|
307
|
+
|
|
308
|
+
def self.run_daemon_mode(text, options)
|
|
309
|
+
pid_file = options[:pid_file] || RubyProgress::Daemon.default_pid_file
|
|
310
|
+
FileUtils.mkdir_p(File.dirname(pid_file))
|
|
311
|
+
File.write(pid_file, Process.pid.to_s)
|
|
312
|
+
|
|
313
|
+
begin
|
|
314
|
+
# For Ripple, re-use the existing animation loop via a simple loop
|
|
315
|
+
RubyProgress::Utils.hide_cursor
|
|
316
|
+
rippler = RubyProgress::Ripple.new(text, options)
|
|
317
|
+
stop_requested = false
|
|
318
|
+
|
|
319
|
+
Signal.trap('INT') { stop_requested = true }
|
|
320
|
+
Signal.trap('USR1') { stop_requested = true }
|
|
321
|
+
Signal.trap('TERM') { stop_requested = true }
|
|
322
|
+
Signal.trap('HUP') { stop_requested = true }
|
|
323
|
+
|
|
324
|
+
rippler.advance until stop_requested
|
|
325
|
+
ensure
|
|
326
|
+
RubyProgress::Utils.clear_line
|
|
327
|
+
RubyProgress::Utils.show_cursor
|
|
328
|
+
|
|
329
|
+
# If a control message file exists, output its message with optional checkmark
|
|
330
|
+
cmf = RubyProgress::Daemon.control_message_file(pid_file)
|
|
331
|
+
if File.exist?(cmf)
|
|
332
|
+
begin
|
|
333
|
+
data = JSON.parse(File.read(cmf))
|
|
334
|
+
message = data['message']
|
|
335
|
+
check = data.key?('checkmark') ? !!data['checkmark'] : false
|
|
336
|
+
success_val = data.key?('success') ? !!data['success'] : true
|
|
337
|
+
if message
|
|
338
|
+
RubyProgress::Utils.display_completion(
|
|
339
|
+
message,
|
|
340
|
+
success: success_val,
|
|
341
|
+
show_checkmark: check,
|
|
342
|
+
output_stream: :stdout
|
|
343
|
+
)
|
|
344
|
+
end
|
|
345
|
+
rescue StandardError
|
|
346
|
+
# ignore
|
|
347
|
+
ensure
|
|
348
|
+
begin
|
|
349
|
+
File.delete(cmf)
|
|
350
|
+
rescue StandardError
|
|
351
|
+
nil
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
File.delete(pid_file) if File.exist?(pid_file)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
230
359
|
end
|
|
231
360
|
|
|
232
361
|
# Enhanced Worm CLI with unified flags
|
|
@@ -234,12 +363,67 @@ module WormCLI
|
|
|
234
363
|
def self.run
|
|
235
364
|
options = parse_cli_options
|
|
236
365
|
|
|
366
|
+
if options[:status]
|
|
367
|
+
pid_file = resolve_pid_file(options, :status_name)
|
|
368
|
+
RubyProgress::Daemon.show_status(pid_file)
|
|
369
|
+
exit
|
|
370
|
+
elsif options[:stop]
|
|
371
|
+
pid_file = resolve_pid_file(options, :stop_name)
|
|
372
|
+
stop_msg = options[:stop_error] || options[:stop_success]
|
|
373
|
+
is_error = !options[:stop_error].nil?
|
|
374
|
+
RubyProgress::Daemon.stop_daemon_by_pid_file(
|
|
375
|
+
pid_file,
|
|
376
|
+
message: stop_msg,
|
|
377
|
+
checkmark: options[:stop_checkmark],
|
|
378
|
+
error: is_error
|
|
379
|
+
)
|
|
380
|
+
exit
|
|
381
|
+
elsif options[:daemon]
|
|
382
|
+
# Detach before starting daemon logic so there's no tracked shell job
|
|
383
|
+
PrgCLI.daemonize
|
|
384
|
+
run_daemon_mode(options)
|
|
385
|
+
else
|
|
386
|
+
progress = RubyProgress::Worm.new(options)
|
|
387
|
+
|
|
388
|
+
if options[:command]
|
|
389
|
+
progress.run_with_command
|
|
390
|
+
else
|
|
391
|
+
progress.run_indefinitely
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def self.run_daemon_mode(options)
|
|
397
|
+
# Use daemon name or default PID file if none specified
|
|
398
|
+
pid_file = resolve_pid_file(options, :daemon_name)
|
|
399
|
+
|
|
400
|
+
# Ensure directory exists
|
|
401
|
+
FileUtils.mkdir_p(File.dirname(pid_file))
|
|
402
|
+
|
|
403
|
+
# Write PID file
|
|
404
|
+
File.write(pid_file, Process.pid.to_s)
|
|
405
|
+
|
|
237
406
|
progress = RubyProgress::Worm.new(options)
|
|
238
407
|
|
|
239
|
-
|
|
240
|
-
progress.
|
|
408
|
+
begin
|
|
409
|
+
progress.run_daemon_mode(
|
|
410
|
+
success_message: options[:success],
|
|
411
|
+
show_checkmark: options[:checkmark],
|
|
412
|
+
control_message_file: RubyProgress::Daemon.control_message_file(pid_file)
|
|
413
|
+
)
|
|
414
|
+
ensure
|
|
415
|
+
# Clean up PID file
|
|
416
|
+
File.delete(pid_file) if File.exist?(pid_file)
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def self.resolve_pid_file(options, name_key)
|
|
421
|
+
return options[:pid_file] if options[:pid_file]
|
|
422
|
+
|
|
423
|
+
if options[name_key]
|
|
424
|
+
"/tmp/ruby-progress/#{options[name_key]}.pid"
|
|
241
425
|
else
|
|
242
|
-
|
|
426
|
+
RubyProgress::Daemon.default_pid_file
|
|
243
427
|
end
|
|
244
428
|
end
|
|
245
429
|
|
|
@@ -290,6 +474,56 @@ module WormCLI
|
|
|
290
474
|
options[:stdout] = true
|
|
291
475
|
end
|
|
292
476
|
|
|
477
|
+
opts.separator ''
|
|
478
|
+
opts.separator 'Daemon Mode:'
|
|
479
|
+
|
|
480
|
+
opts.on('--daemon', 'Run in background daemon mode') do
|
|
481
|
+
options[:daemon] = true
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
opts.on('--daemon-as NAME', 'Run in daemon mode with custom name (creates /tmp/ruby-progress/NAME.pid)') do |name|
|
|
485
|
+
options[:daemon] = true
|
|
486
|
+
options[:daemon_name] = name
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
opts.on('--pid-file FILE', 'Write process ID to file (default: /tmp/ruby-progress/progress.pid)') do |file|
|
|
490
|
+
options[:pid_file] = file
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
opts.on('--stop', 'Stop daemon (uses default PID file unless --pid-file specified)') do
|
|
494
|
+
options[:stop] = true
|
|
495
|
+
end
|
|
496
|
+
opts.on('--stop-id NAME', 'Stop daemon by name (automatically implies --stop)') do |name|
|
|
497
|
+
options[:stop] = true
|
|
498
|
+
options[:stop_name] = name
|
|
499
|
+
end
|
|
500
|
+
opts.on('--status', 'Show daemon status (uses default PID file unless --pid-file specified)') do
|
|
501
|
+
options[:status] = true
|
|
502
|
+
end
|
|
503
|
+
opts.on('--status-id NAME', 'Show daemon status by name') do |name|
|
|
504
|
+
options[:status] = true
|
|
505
|
+
options[:status_name] = name
|
|
506
|
+
end
|
|
507
|
+
opts.on('--stop-success MESSAGE', 'Stop daemon with success message (automatically implies --stop)') do |msg|
|
|
508
|
+
options[:stop] = true
|
|
509
|
+
options[:stop_success] = msg
|
|
510
|
+
end
|
|
511
|
+
opts.on('--stop-error MESSAGE', 'Stop daemon with error message (automatically implies --stop)') do |msg|
|
|
512
|
+
options[:stop] = true
|
|
513
|
+
options[:stop_error] = msg
|
|
514
|
+
end
|
|
515
|
+
opts.on('--stop-checkmark', 'When stopping, include a success checkmark') { options[:stop_checkmark] = true }
|
|
516
|
+
|
|
517
|
+
opts.on('--stop-pid FILE', 'Stop daemon by reading PID from file (deprecated: use --stop [--pid-file])') do |file|
|
|
518
|
+
RubyProgress::Daemon.stop_daemon_by_pid_file(file)
|
|
519
|
+
exit
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
opts.separator ''
|
|
523
|
+
opts.separator 'Daemon notes:'
|
|
524
|
+
opts.separator ' - Do not append &; prg detaches itself and returns immediately.'
|
|
525
|
+
opts.separator ' - Use --daemon-as NAME for named daemons, or --stop-id/--status-id for named control.'
|
|
526
|
+
|
|
293
527
|
opts.separator ''
|
|
294
528
|
opts.separator 'General:'
|
|
295
529
|
|
|
@@ -308,4 +542,289 @@ module WormCLI
|
|
|
308
542
|
end
|
|
309
543
|
end
|
|
310
544
|
|
|
311
|
-
|
|
545
|
+
# Twirl CLI - spinner-based progress indicator
|
|
546
|
+
module TwirlCLI
|
|
547
|
+
def self.run
|
|
548
|
+
options = parse_cli_options
|
|
549
|
+
|
|
550
|
+
if options[:status]
|
|
551
|
+
pid_file = resolve_pid_file(options, :status_name)
|
|
552
|
+
RubyProgress::Daemon.show_status(pid_file)
|
|
553
|
+
exit
|
|
554
|
+
elsif options[:stop]
|
|
555
|
+
pid_file = resolve_pid_file(options, :stop_name)
|
|
556
|
+
stop_msg = options[:stop_error] || options[:stop_success]
|
|
557
|
+
is_error = !options[:stop_error].nil?
|
|
558
|
+
RubyProgress::Daemon.stop_daemon_by_pid_file(
|
|
559
|
+
pid_file,
|
|
560
|
+
message: stop_msg,
|
|
561
|
+
checkmark: options[:stop_checkmark],
|
|
562
|
+
error: is_error
|
|
563
|
+
)
|
|
564
|
+
exit
|
|
565
|
+
elsif options[:daemon]
|
|
566
|
+
PrgCLI.daemonize
|
|
567
|
+
run_daemon_mode(options)
|
|
568
|
+
elsif options[:command]
|
|
569
|
+
run_with_command(options)
|
|
570
|
+
else
|
|
571
|
+
run_indefinitely(options)
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def self.run_with_command(options)
|
|
576
|
+
message = options[:message] || 'Processing'
|
|
577
|
+
captured_output = nil
|
|
578
|
+
|
|
579
|
+
spinner = TwirlSpinner.new(message, options)
|
|
580
|
+
success = false
|
|
581
|
+
|
|
582
|
+
begin
|
|
583
|
+
RubyProgress::Utils.hide_cursor
|
|
584
|
+
spinner_thread = Thread.new { spinner.animate }
|
|
585
|
+
|
|
586
|
+
captured_output = `#{options[:command]} 2>&1`
|
|
587
|
+
success = $CHILD_STATUS.success?
|
|
588
|
+
|
|
589
|
+
spinner_thread.kill
|
|
590
|
+
RubyProgress::Utils.clear_line
|
|
591
|
+
ensure
|
|
592
|
+
RubyProgress::Utils.show_cursor
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
puts captured_output if options[:stdout]
|
|
596
|
+
|
|
597
|
+
if options[:success] || options[:error] || options[:checkmark]
|
|
598
|
+
final_msg = success ? options[:success] : options[:error]
|
|
599
|
+
final_msg ||= success ? 'Success' : 'Failed'
|
|
600
|
+
|
|
601
|
+
RubyProgress::Utils.display_completion(
|
|
602
|
+
final_msg,
|
|
603
|
+
success: success,
|
|
604
|
+
show_checkmark: options[:checkmark]
|
|
605
|
+
)
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
exit success ? 0 : 1
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def self.run_indefinitely(options)
|
|
612
|
+
message = options[:message] || 'Processing'
|
|
613
|
+
spinner = TwirlSpinner.new(message, options)
|
|
614
|
+
|
|
615
|
+
begin
|
|
616
|
+
RubyProgress::Utils.hide_cursor
|
|
617
|
+
loop { spinner.animate }
|
|
618
|
+
ensure
|
|
619
|
+
RubyProgress::Utils.show_cursor
|
|
620
|
+
if options[:success] || options[:checkmark]
|
|
621
|
+
RubyProgress::Utils.display_completion(
|
|
622
|
+
options[:success] || 'Complete',
|
|
623
|
+
success: true,
|
|
624
|
+
show_checkmark: options[:checkmark]
|
|
625
|
+
)
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def self.run_daemon_mode(options)
|
|
631
|
+
pid_file = resolve_pid_file(options, :daemon_name)
|
|
632
|
+
FileUtils.mkdir_p(File.dirname(pid_file))
|
|
633
|
+
File.write(pid_file, Process.pid.to_s)
|
|
634
|
+
|
|
635
|
+
message = options[:message] || 'Processing'
|
|
636
|
+
spinner = TwirlSpinner.new(message, options)
|
|
637
|
+
stop_requested = false
|
|
638
|
+
|
|
639
|
+
Signal.trap('INT') { stop_requested = true }
|
|
640
|
+
Signal.trap('USR1') { stop_requested = true }
|
|
641
|
+
Signal.trap('TERM') { stop_requested = true }
|
|
642
|
+
Signal.trap('HUP') { stop_requested = true }
|
|
643
|
+
|
|
644
|
+
begin
|
|
645
|
+
RubyProgress::Utils.hide_cursor
|
|
646
|
+
spinner.animate until stop_requested
|
|
647
|
+
ensure
|
|
648
|
+
RubyProgress::Utils.clear_line
|
|
649
|
+
RubyProgress::Utils.show_cursor
|
|
650
|
+
|
|
651
|
+
# Check for control message
|
|
652
|
+
cmf = RubyProgress::Daemon.control_message_file(pid_file)
|
|
653
|
+
if File.exist?(cmf)
|
|
654
|
+
begin
|
|
655
|
+
data = JSON.parse(File.read(cmf))
|
|
656
|
+
message = data['message']
|
|
657
|
+
check = data.key?('checkmark') ? data['checkmark'] : false
|
|
658
|
+
success_val = data.key?('success') ? data['success'] : true
|
|
659
|
+
if message
|
|
660
|
+
RubyProgress::Utils.display_completion(
|
|
661
|
+
message,
|
|
662
|
+
success: success_val,
|
|
663
|
+
show_checkmark: check,
|
|
664
|
+
output_stream: :stdout
|
|
665
|
+
)
|
|
666
|
+
end
|
|
667
|
+
rescue StandardError
|
|
668
|
+
# ignore
|
|
669
|
+
ensure
|
|
670
|
+
begin
|
|
671
|
+
File.delete(cmf)
|
|
672
|
+
rescue StandardError
|
|
673
|
+
nil
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
File.delete(pid_file) if File.exist?(pid_file)
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def self.resolve_pid_file(options, name_key)
|
|
683
|
+
return options[:pid_file] if options[:pid_file]
|
|
684
|
+
|
|
685
|
+
if options[name_key]
|
|
686
|
+
"/tmp/ruby-progress/#{options[name_key]}.pid"
|
|
687
|
+
else
|
|
688
|
+
RubyProgress::Daemon.default_pid_file
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def self.parse_cli_options
|
|
693
|
+
options = {}
|
|
694
|
+
|
|
695
|
+
OptionParser.new do |opts|
|
|
696
|
+
opts.banner = 'Usage: prg twirl [options]'
|
|
697
|
+
opts.separator ''
|
|
698
|
+
opts.separator 'Animation Options:'
|
|
699
|
+
|
|
700
|
+
opts.on('-s', '--speed SPEED', 'Animation speed (1-10, fast/medium/slow, or f/m/s)') do |speed|
|
|
701
|
+
options[:speed] = speed
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
opts.on('-m', '--message MESSAGE', 'Message to display before spinner') do |message|
|
|
705
|
+
options[:message] = message
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
opts.on('--style STYLE', 'Spinner style (see --list-styles for options)') do |style|
|
|
709
|
+
options[:style] = style.to_sym
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
opts.separator ''
|
|
713
|
+
opts.separator 'Command Execution:'
|
|
714
|
+
|
|
715
|
+
opts.on('-c', '--command COMMAND', 'Command to run (optional - runs indefinitely without)') do |command|
|
|
716
|
+
options[:command] = command
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
opts.on('--success MESSAGE', 'Success message to display') do |text|
|
|
720
|
+
options[:success] = text
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
opts.on('--error MESSAGE', 'Error message to display') do |text|
|
|
724
|
+
options[:error] = text
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
opts.on('--checkmark', 'Show checkmarks (✅ success, 🛑 failure)') do
|
|
728
|
+
options[:checkmark] = true
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
opts.on('--stdout', 'Output captured command result to STDOUT') do
|
|
732
|
+
options[:stdout] = true
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
opts.separator ''
|
|
736
|
+
opts.separator 'Daemon Mode:'
|
|
737
|
+
|
|
738
|
+
opts.on('--daemon', 'Run in background daemon mode') do
|
|
739
|
+
options[:daemon] = true
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
opts.on('--daemon-as NAME', 'Run in daemon mode with custom name (creates /tmp/ruby-progress/NAME.pid)') do |name|
|
|
743
|
+
options[:daemon] = true
|
|
744
|
+
options[:daemon_name] = name
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
opts.on('--pid-file FILE', 'Write process ID to file (default: /tmp/ruby-progress/progress.pid)') do |file|
|
|
748
|
+
options[:pid_file] = file
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
opts.on('--stop', 'Stop daemon (uses default PID file unless --pid-file specified)') do
|
|
752
|
+
options[:stop] = true
|
|
753
|
+
end
|
|
754
|
+
opts.on('--stop-id NAME', 'Stop daemon by name (automatically implies --stop)') do |name|
|
|
755
|
+
options[:stop] = true
|
|
756
|
+
options[:stop_name] = name
|
|
757
|
+
end
|
|
758
|
+
opts.on('--status', 'Show daemon status (uses default PID file unless --pid-file specified)') do
|
|
759
|
+
options[:status] = true
|
|
760
|
+
end
|
|
761
|
+
opts.on('--status-id NAME', 'Show daemon status by name') do |name|
|
|
762
|
+
options[:status] = true
|
|
763
|
+
options[:status_name] = name
|
|
764
|
+
end
|
|
765
|
+
opts.on('--stop-success MESSAGE', 'Stop daemon with success message (automatically implies --stop)') do |msg|
|
|
766
|
+
options[:stop] = true
|
|
767
|
+
options[:stop_success] = msg
|
|
768
|
+
end
|
|
769
|
+
opts.on('--stop-error MESSAGE', 'Stop daemon with error message (automatically implies --stop)') do |msg|
|
|
770
|
+
options[:stop] = true
|
|
771
|
+
options[:stop_error] = msg
|
|
772
|
+
end
|
|
773
|
+
opts.on('--stop-checkmark', 'When stopping, include a success checkmark') { options[:stop_checkmark] = true }
|
|
774
|
+
|
|
775
|
+
opts.separator ''
|
|
776
|
+
opts.separator 'Daemon notes:'
|
|
777
|
+
opts.separator ' - Do not append &; prg detaches itself and returns immediately.'
|
|
778
|
+
opts.separator ' - Use --daemon-as NAME for named daemons, or --stop-id/--status-id for named control.'
|
|
779
|
+
|
|
780
|
+
opts.separator ''
|
|
781
|
+
opts.separator 'General:'
|
|
782
|
+
|
|
783
|
+
opts.on('-v', '--version', 'Show version') do
|
|
784
|
+
puts "Twirl version #{RubyProgress::VERSION}"
|
|
785
|
+
exit
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
opts.on('-h', '--help', 'Show this help') do
|
|
789
|
+
puts opts
|
|
790
|
+
exit
|
|
791
|
+
end
|
|
792
|
+
end.parse!
|
|
793
|
+
|
|
794
|
+
options
|
|
795
|
+
end
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
# Simple spinner class for Twirl
|
|
799
|
+
class TwirlSpinner
|
|
800
|
+
def initialize(message, options = {})
|
|
801
|
+
@message = message
|
|
802
|
+
@style = options[:style] || :dots
|
|
803
|
+
@speed = parse_speed(options[:speed] || 'medium')
|
|
804
|
+
@frames = RubyProgress::INDICATORS[@style] || RubyProgress::INDICATORS[:dots]
|
|
805
|
+
@index = 0
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
def animate
|
|
809
|
+
print "\r#{@message} #{@frames[@index]}"
|
|
810
|
+
@index = (@index + 1) % @frames.length
|
|
811
|
+
sleep @speed
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
private
|
|
815
|
+
|
|
816
|
+
def parse_speed(speed)
|
|
817
|
+
case speed.to_s.downcase
|
|
818
|
+
when /^f/, '1', '2', '3'
|
|
819
|
+
0.05
|
|
820
|
+
when /^m/, '4', '5', '6', '7'
|
|
821
|
+
0.1
|
|
822
|
+
when /^s/, '8', '9', '10'
|
|
823
|
+
0.2
|
|
824
|
+
else
|
|
825
|
+
speed.to_f > 0 ? (1.0 / speed.to_f) : 0.1
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
PrgCLI.run
|