ruby-progress 1.2.0 → 1.3.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.
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require_relative 'twirl_spinner'
6
+ require_relative '../output_capture'
7
+
8
+ # Top-level runtime helper module for the Twirl CLI.
9
+ #
10
+ # Contains helper methods used by the `TwirlCLI` dispatcher. These
11
+ # methods implement the runtime behavior (running a command, running
12
+ # indefinitely, or launching in daemon mode) and were extracted to
13
+ # reduce module size and improve testability.
14
+ module TwirlRunner
15
+ def self.run_with_command(options)
16
+ message = options[:message]
17
+ captured_output = nil
18
+
19
+ spinner = TwirlSpinner.new(message, options)
20
+ success = false
21
+
22
+ begin
23
+ RubyProgress::Utils.hide_cursor
24
+ spinner_thread = Thread.new { loop { spinner.animate } }
25
+
26
+ if $stdout.tty? && options[:stdout]
27
+ oc = RubyProgress::OutputCapture.new(
28
+ command: options[:command],
29
+ lines: options[:output_lines] || 3,
30
+ position: options[:output_position] || :above
31
+ )
32
+ oc.start
33
+
34
+ spinner.instance_variable_set(:@output_capture, oc)
35
+
36
+ # wait for command while spinner thread runs
37
+ oc.wait
38
+ captured_lines = oc.lines
39
+ captured_output = captured_lines.join("\n")
40
+ success = true
41
+ else
42
+ captured_output = `#{options[:command]} 2>&1`
43
+ success = $CHILD_STATUS.success?
44
+ end
45
+
46
+ spinner_thread.kill
47
+ RubyProgress::Utils.clear_line
48
+ ensure
49
+ RubyProgress::Utils.show_cursor
50
+ end
51
+
52
+ puts captured_output if options[:stdout]
53
+
54
+ if options[:success] || options[:error] || options[:checkmark]
55
+ final_msg = success ? options[:success] : options[:error]
56
+ final_msg ||= success ? 'Success' : 'Failed'
57
+
58
+ RubyProgress::Utils.display_completion(
59
+ final_msg,
60
+ success: success,
61
+ show_checkmark: options[:checkmark]
62
+ )
63
+ end
64
+
65
+ exit success ? 0 : 1
66
+ end
67
+
68
+ def self.run_indefinitely(options)
69
+ message = options[:message]
70
+ spinner = TwirlSpinner.new(message, options)
71
+
72
+ begin
73
+ RubyProgress::Utils.hide_cursor
74
+ loop { spinner.animate }
75
+ ensure
76
+ RubyProgress::Utils.show_cursor
77
+ if options[:success] || options[:checkmark]
78
+ RubyProgress::Utils.display_completion(
79
+ options[:success] || 'Complete',
80
+ success: true,
81
+ show_checkmark: options[:checkmark]
82
+ )
83
+ end
84
+ end
85
+ end
86
+
87
+ def self.run_daemon_mode(options)
88
+ pid_file = resolve_pid_file(options, :daemon_name)
89
+ FileUtils.mkdir_p(File.dirname(pid_file))
90
+ File.write(pid_file, Process.pid.to_s)
91
+
92
+ message = options[:message]
93
+ spinner = TwirlSpinner.new(message, options)
94
+ stop_requested = false
95
+
96
+ Signal.trap('INT') { stop_requested = true }
97
+ Signal.trap('USR1') { stop_requested = true }
98
+ Signal.trap('TERM') { stop_requested = true }
99
+ Signal.trap('HUP') { stop_requested = true }
100
+
101
+ begin
102
+ RubyProgress::Utils.hide_cursor
103
+
104
+ # Start job processor thread for twirl
105
+ job_dir = RubyProgress::Daemon.job_dir_for_pid(pid_file)
106
+ job_thread = Thread.new do
107
+ RubyProgress::Daemon.process_jobs(job_dir) do |job|
108
+ oc = RubyProgress::OutputCapture.new(
109
+ command: job['command'],
110
+ lines: options[:output_lines] || 3,
111
+ position: options[:output_position] || :above
112
+ )
113
+ oc.start
114
+
115
+ spinner.instance_variable_set(:@output_capture, oc)
116
+ oc.wait
117
+ captured = oc.lines.join("\n")
118
+ exit_status = oc.exit_status
119
+ spinner.instance_variable_set(:@output_capture, nil)
120
+
121
+ success = exit_status.to_i.zero?
122
+ if job['message']
123
+ RubyProgress::Utils.display_completion(
124
+ job['message'],
125
+ success: success,
126
+ show_checkmark: job['checkmark'] || false,
127
+ output_stream: :stdout
128
+ )
129
+ end
130
+
131
+ { 'exit_status' => exit_status, 'output' => captured }
132
+ rescue StandardError
133
+ # ignore
134
+ end
135
+ end
136
+
137
+ spinner.animate until stop_requested
138
+ ensure
139
+ RubyProgress::Utils.clear_line
140
+ RubyProgress::Utils.show_cursor
141
+
142
+ # Check for control message
143
+ cmf = RubyProgress::Daemon.control_message_file(pid_file)
144
+ if File.exist?(cmf)
145
+ begin
146
+ data = JSON.parse(File.read(cmf))
147
+ message = data['message']
148
+ check = data.key?('checkmark') ? data['checkmark'] : false
149
+ success_val = data.key?('success') ? data['success'] : true
150
+ if message
151
+ RubyProgress::Utils.display_completion(
152
+ message,
153
+ success: success_val,
154
+ show_checkmark: check,
155
+ output_stream: :stdout
156
+ )
157
+ end
158
+ rescue StandardError
159
+ # ignore
160
+ ensure
161
+ begin
162
+ File.delete(cmf)
163
+ rescue StandardError
164
+ nil
165
+ end
166
+ end
167
+ end
168
+
169
+ job_thread&.kill
170
+ FileUtils.rm_f(pid_file)
171
+ end
172
+ end
173
+
174
+ def self.resolve_pid_file(options, name_key)
175
+ return options[:pid_file] if options[:pid_file]
176
+
177
+ if options[name_key]
178
+ "/tmp/ruby-progress/#{options[name_key]}.pid"
179
+ else
180
+ RubyProgress::Daemon.default_pid_file
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../utils'
4
+
5
+ # Minimal spinner implementation used by the Twirl CLI
6
+ #
7
+ # This small class handles animation frame selection and printing
8
+ # for the Twirl command. It was extracted to keep runtime logic
9
+ # out of the CLI dispatcher module.
10
+ class TwirlSpinner
11
+ def initialize(message, options = {})
12
+ @message = message
13
+ @style = parse_style(options[:style] || 'dots')
14
+ @speed = parse_speed(options[:speed] || 'medium')
15
+ @frames = RubyProgress::INDICATORS[@style] || RubyProgress::INDICATORS[:dots]
16
+ @start_chars, @end_chars = RubyProgress::Utils.parse_ends(options[:ends])
17
+ @index = 0
18
+ end
19
+
20
+ def animate
21
+ @output_capture&.redraw($stderr)
22
+ if @message && !@message.empty?
23
+ $stderr.print "\r\e[2K#{@start_chars}#{@message} #{@frames[@index]}#{@end_chars}"
24
+ else
25
+ $stderr.print "\r\e[2K#{@start_chars}#{@frames[@index]}#{@end_chars}"
26
+ end
27
+ $stderr.flush
28
+ @index = (@index + 1) % @frames.length
29
+ sleep @speed
30
+ end
31
+
32
+ private
33
+
34
+ def parse_style(style_input)
35
+ return :dots unless style_input && !style_input.to_s.strip.empty?
36
+
37
+ style_lower = style_input.to_s.downcase.strip
38
+
39
+ indicator_keys = RubyProgress::INDICATORS.keys.map(&:to_s)
40
+ return style_lower.to_sym if indicator_keys.include?(style_lower)
41
+
42
+ prefix_matches = indicator_keys.select { |key| key.downcase.start_with?(style_lower) }
43
+ return prefix_matches.min_by(&:length).to_sym unless prefix_matches.empty?
44
+
45
+ fuzzy_matches = indicator_keys.select do |key|
46
+ key_chars = key.downcase.chars
47
+ input_chars = style_lower.chars
48
+ input_chars.all? do |char|
49
+ idx = key_chars.index(char)
50
+ if idx
51
+ key_chars = key_chars[idx + 1..-1]
52
+ true
53
+ else
54
+ false
55
+ end
56
+ end
57
+ end
58
+
59
+ return fuzzy_matches.min_by(&:length).to_sym unless fuzzy_matches.empty?
60
+
61
+ substring_matches = indicator_keys.select { |key| key.downcase.include?(style_lower) }
62
+ return substring_matches.min_by(&:length).to_sym unless substring_matches.empty?
63
+
64
+ :dots
65
+ end
66
+
67
+ def parse_speed(speed)
68
+ case speed.to_s.downcase
69
+ when /^f/, '1', '2', '3'
70
+ 0.05
71
+ when /^m/, '4', '5', '6', '7'
72
+ 0.1
73
+ when /^s/, '8', '9', '10'
74
+ 0.2
75
+ else
76
+ speed.to_f.positive? ? (1.0 / speed.to_f) : 0.1
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'optparse'
5
+ require_relative 'worm_options'
6
+
7
+ # Enhanced Worm CLI (extracted from bin/prg)
8
+ module WormCLI
9
+ def self.resolve_pid_file(options, name_key = :daemon_name)
10
+ return options[:pid_file] if options[:pid_file]
11
+
12
+ return "/tmp/ruby-progress/#{options[name_key]}.pid" if options[name_key]
13
+
14
+ RubyProgress::Daemon.default_pid_file
15
+ end
16
+
17
+ def self.run
18
+ options = WormCLI::Options.parse_cli_options
19
+
20
+ if options[:status]
21
+ pid_file = resolve_pid_file(options, :status_name)
22
+ RubyProgress::Daemon.show_status(pid_file)
23
+ exit
24
+ elsif options[:stop]
25
+ pid_file = resolve_pid_file(options, :stop_name)
26
+ stop_msg = options[:stop_error] || options[:stop_success]
27
+ is_error = !options[:stop_error].nil?
28
+ RubyProgress::Daemon.stop_daemon_by_pid_file(
29
+ pid_file,
30
+ message: stop_msg,
31
+ checkmark: options[:stop_checkmark],
32
+ error: is_error
33
+ )
34
+ exit
35
+ elsif options[:daemon]
36
+ # Detach before starting daemon logic so there's no tracked shell job
37
+ PrgCLI.daemonize
38
+ run_daemon_mode(options)
39
+ else
40
+ progress = RubyProgress::Worm.new(options)
41
+
42
+ if options[:command]
43
+ progress.run_with_command
44
+ else
45
+ progress.run_indefinitely
46
+ end
47
+ end
48
+ end
49
+
50
+ def self.run_daemon_mode(options)
51
+ pid_file = resolve_pid_file(options, :daemon_name)
52
+ FileUtils.mkdir_p(File.dirname(pid_file))
53
+ File.write(pid_file, Process.pid.to_s)
54
+
55
+ progress = RubyProgress::Worm.new(options)
56
+
57
+ begin
58
+ # Start job processor thread for worm
59
+ job_dir = RubyProgress::Daemon.job_dir_for_pid(pid_file)
60
+ job_thread = Thread.new do
61
+ RubyProgress::Daemon.process_jobs(job_dir) do |job|
62
+ jid = job['id'] || SecureRandom.uuid
63
+ log_path = begin
64
+ File.join(File.dirname(job_dir), "#{jid}.log")
65
+ rescue StandardError
66
+ nil
67
+ end
68
+
69
+ oc = RubyProgress::OutputCapture.new(
70
+ command: job['command'],
71
+ lines: options[:output_lines] || 3,
72
+ position: options[:output_position] || :above,
73
+ log_path: log_path
74
+ )
75
+ oc.start
76
+
77
+ progress.instance_variable_set(:@output_capture, oc)
78
+ oc.wait
79
+ captured = oc.lines.join("\n")
80
+ exit_status = oc.exit_status
81
+ progress.instance_variable_set(:@output_capture, nil)
82
+
83
+ success = exit_status.to_i.zero?
84
+ if job['message']
85
+ RubyProgress::Utils.display_completion(
86
+ job['message'],
87
+ success: success,
88
+ show_checkmark: job['checkmark'] || false,
89
+ output_stream: :stdout
90
+ )
91
+ end
92
+
93
+ { 'exit_status' => exit_status, 'output' => captured, 'log_path' => log_path }
94
+ rescue StandardError
95
+ # ignore per-job errors
96
+ end
97
+ end
98
+
99
+ progress.run_daemon_mode(
100
+ success_message: options[:success],
101
+ show_checkmark: options[:checkmark],
102
+ control_message_file: RubyProgress::Daemon.control_message_file(pid_file)
103
+ )
104
+ ensure
105
+ job_thread&.kill
106
+ FileUtils.rm_f(pid_file)
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module WormCLI
6
+ # Option parsing helpers for the Worm subcommand.
7
+ #
8
+ # Keeps the CLI option definitions for `prg worm` extracted from
9
+ # the main dispatcher to keep the CLI module small and focused.
10
+ module Options
11
+ def self.parse_cli_options
12
+ options = {
13
+ output_position: :above,
14
+ output_lines: 3
15
+ }
16
+ # rubocop:disable Metrics/BlockLength
17
+ begin
18
+ OptionParser.new do |opts|
19
+ opts.banner = 'Usage: prg worm [options]'
20
+ opts.separator ''
21
+ opts.separator 'Animation Options:'
22
+
23
+ opts.on('-s', '--speed SPEED', 'Animation speed (1-10, fast/medium/slow, or f/m/s)') do |speed|
24
+ options[:speed] = speed
25
+ end
26
+
27
+ opts.on('-m', '--message MESSAGE', 'Message to display before animation') do |message|
28
+ options[:message] = message
29
+ end
30
+
31
+ opts.on('-l', '--length LENGTH', Integer, 'Number of dots to display') do |length|
32
+ options[:length] = length
33
+ end
34
+
35
+ opts.on('--style STYLE', 'Animation style (circles/blocks/geometric, c/b/g, or custom=abc)') do |style|
36
+ options[:style] = style
37
+ end
38
+
39
+ opts.on('-d', '--direction DIRECTION', 'Animation direction (forward/bidirectional or f/b)') do |direction|
40
+ options[:direction] = direction =~ /^f/i ? :forward_only : :bidirectional
41
+ end
42
+
43
+ opts.on('--ends CHARS', 'Start/end characters (even number of chars, split in half)') do |chars|
44
+ options[:ends] = chars
45
+ end
46
+
47
+ opts.separator ''
48
+ opts.separator 'Command Execution:'
49
+
50
+ opts.on('-c', '--command COMMAND', 'Command to run (optional - runs indefinitely without)') do |command|
51
+ options[:command] = command
52
+ end
53
+
54
+ opts.on('--output-position POSITION', 'Position to render captured output: above or below (default: above)') do |pos|
55
+ options[:output_position] = pos.to_sym
56
+ end
57
+
58
+ opts.on('--output-lines N', Integer, 'Number of output lines to reserve for captured output (default: 3)') do |n|
59
+ options[:output_lines] = n
60
+ end
61
+
62
+ opts.on('--success MESSAGE', 'Success message to display') do |text|
63
+ options[:success] = text
64
+ end
65
+
66
+ opts.on('--error MESSAGE', 'Error message to display') do |text|
67
+ options[:error] = text
68
+ end
69
+
70
+ opts.on('--checkmark', 'Show checkmarks (✅ success, 🛑 failure)') do
71
+ options[:checkmark] = true
72
+ end
73
+
74
+ opts.on('--stdout', 'Output captured command result to STDOUT') do
75
+ options[:stdout] = true
76
+ end
77
+
78
+ opts.separator ''
79
+ opts.separator 'Daemon Mode:'
80
+
81
+ opts.on('--daemon', 'Run in background daemon mode') do
82
+ options[:daemon] = true
83
+ end
84
+
85
+ opts.on('--daemon-as NAME', 'Run in daemon mode with custom name (creates /tmp/ruby-progress/NAME.pid)') do |name|
86
+ options[:daemon] = true
87
+ options[:daemon_name] = name
88
+ end
89
+
90
+ # Accept --daemon-name as an alias for --daemon-as for compatibility
91
+ opts.on('--daemon-name NAME', 'Alias for --daemon-as (compat)') do |name|
92
+ options[:daemon] = true
93
+ options[:daemon_name] = name
94
+ end
95
+
96
+ opts.on('--pid-file FILE', 'Write process ID to file (default: /tmp/ruby-progress/progress.pid)') do |file|
97
+ options[:pid_file] = file
98
+ end
99
+
100
+ opts.on('--stop', 'Stop daemon (uses default PID file unless --pid-file specified)') do
101
+ options[:stop] = true
102
+ end
103
+ opts.on('--stop-id NAME', 'Stop daemon by name (automatically implies --stop)') do |name|
104
+ options[:stop] = true
105
+ options[:stop_name] = name
106
+ end
107
+ opts.on('--status', 'Show daemon status (uses default PID file unless --pid-file specified)') do
108
+ options[:status] = true
109
+ end
110
+ opts.on('--status-id NAME', 'Show daemon status by name') do |name|
111
+ options[:status] = true
112
+ options[:status_name] = name
113
+ end
114
+ opts.on('--stop-success MESSAGE', 'Stop daemon with success message (automatically implies --stop)') do |msg|
115
+ options[:stop] = true
116
+ options[:stop_success] = msg
117
+ end
118
+ opts.on('--stop-error MESSAGE', 'Stop daemon with error message (automatically implies --stop)') do |msg|
119
+ options[:stop] = true
120
+ options[:stop_error] = msg
121
+ end
122
+ opts.on('--stop-checkmark', 'When stopping, include a success checkmark') { options[:stop_checkmark] = true }
123
+
124
+ opts.on('--stop-all', 'Stop all prg worm processes') do
125
+ success = PrgCLI.stop_subcommand_processes('worm')
126
+ exit(success ? 0 : 1)
127
+ end
128
+
129
+ opts.on('--stop-pid FILE', 'Stop daemon by reading PID from file (deprecated: use --stop [--pid-file])') do |file|
130
+ RubyProgress::Daemon.stop_daemon_by_pid_file(file)
131
+ exit
132
+ end
133
+
134
+ opts.separator ''
135
+ opts.separator 'Daemon notes:'
136
+ opts.separator ' - Do not append &; prg detaches itself and returns immediately.'
137
+ opts.separator ' - Use --daemon-as NAME for named daemons, or --stop-id/--status-id for named control.'
138
+
139
+ opts.separator ''
140
+ opts.separator 'General:'
141
+
142
+ opts.on('--show-styles', 'Show available worm styles with visual previews') do
143
+ PrgCLI.show_worm_styles
144
+ exit
145
+ end
146
+
147
+ opts.on('--stop-all', 'Stop all prg worm processes') do
148
+ success = PrgCLI.stop_subcommand_processes('worm')
149
+ exit(success ? 0 : 1)
150
+ end
151
+
152
+ opts.on('-v', '--version', 'Show version') do
153
+ puts "Worm version #{RubyProgress::VERSION}"
154
+ exit
155
+ end
156
+
157
+ opts.on('-h', '--help', 'Show this help') do
158
+ puts opts
159
+ exit
160
+ end
161
+ end.parse!
162
+ rescue OptionParser::InvalidOption => e
163
+ puts "Invalid option: #{e.args.first}"
164
+ puts ''
165
+ puts 'Usage: prg worm [options]'
166
+ puts "Run 'prg worm --help' for more information."
167
+ exit 1
168
+ end
169
+ # rubocop:enable Metrics/BlockLength
170
+ options
171
+ end
172
+ end
173
+ end