rbwatch 1.0.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.
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-box"
4
+ require "tty-cursor"
5
+ require "tty-screen"
6
+ require "tty-spinner"
7
+ require "tty-table"
8
+
9
+ module RBWatch
10
+ class Dashboard
11
+ FOOTER_MESSAGE = "Watching... Press CTRL+C to stop."
12
+ SCROLLBACK_CLEAR = "\e[3J"
13
+ STARTUP_TITLE = " RBWatch "
14
+ RESTART_TITLE = " 🔄 Changes detected "
15
+
16
+ def initialize(output:, theme:, clear: false)
17
+ @output = output
18
+ @theme = theme
19
+ @clear = clear
20
+ end
21
+
22
+ def clear_screen
23
+ return "" unless tty?
24
+
25
+ output.print SCROLLBACK_CLEAR
26
+ output.print TTY::Cursor.clear_screen
27
+ output.print TTY::Cursor.move_to(0, 0)
28
+ ""
29
+ end
30
+
31
+ def hide_cursor
32
+ return unless tty?
33
+
34
+ output.print TTY::Cursor.hide
35
+ end
36
+
37
+ def show_cursor
38
+ return unless tty?
39
+
40
+ output.print TTY::Cursor.show
41
+ end
42
+
43
+ def render_banner(version:, command:, watch_paths:, config_file: nil, theme_name: nil)
44
+ lines = [
45
+ theme.accent("RBWatch #{version}"),
46
+ theme.info("Delightful process restarts for Ruby projects"),
47
+ theme.muted("Command: #{command}"),
48
+ theme.muted("Watching: #{Array(watch_paths).join(', ')}")
49
+ ]
50
+ lines << theme.muted("Config: #{config_file}") if config_file
51
+ lines << theme.muted("Theme: #{theme_name}") if theme_name
52
+ frame = TTY::Box.frame(
53
+ lines.join("\n"),
54
+ width: box_width(lines),
55
+ align: :center,
56
+ padding: [1, 2],
57
+ title: { top_left: STARTUP_TITLE },
58
+ **theme.box_options(:banner)
59
+ )
60
+ output.puts frame
61
+ frame
62
+ end
63
+
64
+ def render_status(status:, files:, extensions:, delay:, pid:, command:, config_file: nil, theme_name: nil, stats: nil)
65
+ rows = [
66
+ [theme.label("Status"), theme.status(status)],
67
+ [theme.label("Files"), theme.files(files.to_s)],
68
+ [theme.label("Extensions"), theme.command(Array(extensions).join(", "))],
69
+ [theme.label("Delay"), theme.delay("#{delay}ms")],
70
+ [theme.label("PID"), theme.pid(pid.to_s)],
71
+ [theme.label("Command"), theme.command(command)]
72
+ ]
73
+ rows.insert(4, [theme.label("Theme"), theme.command(theme_name.to_s)]) if theme_name
74
+ rows.insert(5, [theme.label("Config"), theme.command(config_file.to_s)]) if config_file
75
+ if stats
76
+ rows.insert(4, [theme.label("Restarts"), theme.command(stats.fetch(:restarts).to_s)])
77
+ rows.insert(5, [theme.label("Uptime"), theme.command(stats.fetch(:uptime).to_s)])
78
+ end
79
+ table = TTY::Table.new(header: ["Field", "Value"], rows: rows)
80
+ body = table.render(:basic).lines.drop(1).map { |line| line.rstrip }
81
+ frame = render_table_frame(body, border: theme.border(:status))
82
+ output.puts frame
83
+ frame
84
+ end
85
+
86
+ def render_restart(change_set)
87
+ lines = [
88
+ theme.restart("Changes detected"),
89
+ theme.warning("Stopping previous process..."),
90
+ theme.info("Starting new process..."),
91
+ theme.success("Process started successfully")
92
+ ]
93
+ if change_set&.changed?
94
+ summary = change_set.paths.first(5).join(", ")
95
+ summary = "#{summary}, ..." if change_set.paths.length > 5
96
+ lines << theme.muted("Changed files: #{summary}")
97
+ end
98
+ frame = TTY::Box.frame(
99
+ lines.join("\n"),
100
+ width: box_width(lines),
101
+ align: :center,
102
+ padding: [1, 2],
103
+ title: { top_left: RESTART_TITLE },
104
+ **theme.box_options(:restart)
105
+ )
106
+ output.puts frame
107
+ frame
108
+ end
109
+
110
+ def render_footer
111
+ line = theme.muted(FOOTER_MESSAGE)
112
+ output.puts line
113
+ line
114
+ end
115
+
116
+ def log_info(message)
117
+ log(:info, message)
118
+ end
119
+
120
+ def log_success(message)
121
+ log(:success, message)
122
+ end
123
+
124
+ def log_warning(message)
125
+ log(:warning, message)
126
+ end
127
+
128
+ def log_error(message)
129
+ log(:error, message)
130
+ end
131
+
132
+ def log_restart(message)
133
+ log(:restart, message)
134
+ end
135
+
136
+ def spinner(message)
137
+ if tty?
138
+ TTY::Spinner.new("[:spinner] #{message}", format: :spin, output: output)
139
+ else
140
+ NullSpinner.new(self)
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ def box_width(content, minimum: 48)
147
+ lines = Array(content).flat_map { |line| line.to_s.lines }
148
+ visible_width = lines.map { |line| line.uncolorize.rstrip.length }.max || minimum
149
+ width = [visible_width + 4, screen_width - 2].min
150
+ [width, minimum].max
151
+ end
152
+
153
+ def render_table_frame(lines, border:)
154
+ visible_width = lines.map { |line| line.uncolorize.rstrip.length }.max || 0
155
+ chars = box_chars(border)
156
+ horizontal = chars[:line] * (visible_width + 2)
157
+
158
+ framed = []
159
+ framed << theme.muted("#{chars[:top_left]}#{horizontal}#{chars[:top_right]}")
160
+
161
+ lines.each do |line|
162
+ padding = visible_width - line.uncolorize.rstrip.length
163
+ framed << [
164
+ theme.muted(chars[:pipe]),
165
+ " ",
166
+ line,
167
+ " " * padding,
168
+ " ",
169
+ theme.muted(chars[:pipe])
170
+ ].join
171
+ end
172
+
173
+ framed << theme.muted("#{chars[:bottom_left]}#{horizontal}#{chars[:bottom_right]}")
174
+ framed.join("\n")
175
+ end
176
+
177
+ def box_chars(border)
178
+ {
179
+ top_left: TTY::Box.corner_top_left_char(border),
180
+ top_right: TTY::Box.corner_top_right_char(border),
181
+ bottom_left: TTY::Box.corner_bottom_left_char(border),
182
+ bottom_right: TTY::Box.corner_bottom_right_char(border),
183
+ line: TTY::Box.line_char(border),
184
+ pipe: TTY::Box.pipe_char(border)
185
+ }
186
+ end
187
+
188
+ attr_reader :output, :theme, :clear
189
+
190
+ def tty?
191
+ output.respond_to?(:tty?) && output.tty?
192
+ end
193
+
194
+ def screen_width
195
+ TTY::Screen.width
196
+ rescue StandardError
197
+ 80
198
+ end
199
+
200
+ def log(level, message)
201
+ line = "#{theme.timestamp} #{theme.prefix(level)} #{colorized_message(level, message)}"
202
+ output.puts line
203
+ line
204
+ end
205
+
206
+ def colorized_message(level, message)
207
+ case level
208
+ when :success then theme.success(message)
209
+ when :warning then theme.warning(message)
210
+ when :error then theme.error(message)
211
+ when :restart then theme.restart(message)
212
+ else theme.info(message)
213
+ end
214
+ end
215
+
216
+ class NullSpinner
217
+ def initialize(dashboard)
218
+ @dashboard = dashboard
219
+ end
220
+
221
+ def auto_spin
222
+ true
223
+ end
224
+
225
+ def success(message = nil)
226
+ @dashboard.log_success(message) if message
227
+ true
228
+ end
229
+
230
+ def error(message = nil)
231
+ @dashboard.log_error(message) if message
232
+ true
233
+ end
234
+
235
+ def stop
236
+ true
237
+ end
238
+
239
+ def kill
240
+ true
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module RBWatch
6
+ class FileScanner
7
+ ChangeSet = Struct.new(:added_paths, :removed_paths, :modified_paths, keyword_init: true) do
8
+ def initialize(**kwargs)
9
+ super
10
+ self.added_paths ||= []
11
+ self.removed_paths ||= []
12
+ self.modified_paths ||= []
13
+ end
14
+
15
+ def changed?
16
+ added_paths.any? || removed_paths.any? || modified_paths.any?
17
+ end
18
+
19
+ def total
20
+ added_paths.length + removed_paths.length + modified_paths.length
21
+ end
22
+
23
+ def paths
24
+ (added_paths + removed_paths + modified_paths).uniq
25
+ end
26
+ end
27
+
28
+ attr_reader :configuration
29
+
30
+ def initialize(configuration)
31
+ @configuration = configuration
32
+ end
33
+
34
+ def watchable_files
35
+ files = configuration.watch_paths.flat_map do |root|
36
+ Dir.glob(File.join(root, "**", "*"))
37
+ end
38
+
39
+ files.uniq.select { |path| watchable_file?(path) }.map { |path| normalize_path(path) }.sort
40
+ end
41
+
42
+ def scan
43
+ watchable_files.each_with_object({}) do |path, snapshot|
44
+ snapshot[path] = mtime_for(path)
45
+ rescue Errno::ENOENT
46
+ next
47
+ end
48
+ end
49
+
50
+ def diff(previous_snapshot, current_snapshot = scan)
51
+ previous_snapshot ||= {}
52
+ current_snapshot ||= {}
53
+
54
+ previous_paths = previous_snapshot.keys
55
+ current_paths = current_snapshot.keys
56
+
57
+ added = current_paths - previous_paths
58
+ removed = previous_paths - current_paths
59
+ modified = (previous_paths & current_paths).select do |path|
60
+ previous_snapshot[path] != current_snapshot[path]
61
+ end
62
+
63
+ ChangeSet.new(
64
+ added_paths: added.sort,
65
+ removed_paths: removed.sort,
66
+ modified_paths: modified.sort
67
+ )
68
+ end
69
+
70
+ def count
71
+ watchable_files.length
72
+ end
73
+
74
+ private
75
+
76
+ def watchable_file?(path)
77
+ normalized = normalize_path(path)
78
+ return false unless File.file?(path)
79
+
80
+ return false if ignored_path?(normalized)
81
+
82
+ extension = File.extname(normalized).delete_prefix(".").downcase
83
+ return false if extension.empty?
84
+
85
+ configuration.extensions.include?(extension)
86
+ end
87
+
88
+ def ignored_path?(path)
89
+ segments = Pathname.new(path).each_filename.to_a.map(&:downcase)
90
+ segments.any? { |segment| configuration.ignore.include?(segment) }
91
+ end
92
+
93
+ def mtime_for(path)
94
+ File.mtime(path).to_f
95
+ end
96
+
97
+ def normalize_path(path)
98
+ Pathname.new(path).cleanpath.to_s
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBWatch
4
+ class ProcessManager
5
+ DEFAULT_GRACE_PERIOD = 2.0
6
+ DEFAULT_KILL_TIMEOUT = 5.0
7
+
8
+ attr_reader :pid, :last_status, :command
9
+
10
+ def initialize(
11
+ command:,
12
+ stdout: $stdout,
13
+ stderr: $stderr,
14
+ grace_period: DEFAULT_GRACE_PERIOD,
15
+ kill_timeout: DEFAULT_KILL_TIMEOUT,
16
+ chdir: Dir.pwd
17
+ )
18
+ @command = Array(command).map(&:to_s)
19
+ @child_stdout = stdout
20
+ @child_stderr = stderr
21
+ @grace_period = grace_period.to_f
22
+ @kill_timeout = kill_timeout.to_f
23
+ @chdir = chdir
24
+ @pid = nil
25
+ @last_status = nil
26
+ end
27
+
28
+ def start
29
+ raise ArgumentError, "command cannot be empty" if command.empty?
30
+
31
+ stop if running?
32
+ @last_status = nil
33
+ @pid = spawn_process
34
+ end
35
+
36
+ def stop
37
+ current_pid = @pid
38
+ return @last_status unless current_pid
39
+
40
+ status = terminate_process(current_pid)
41
+ @pid = nil
42
+ @last_status = status if status
43
+ status
44
+ end
45
+
46
+ def restart
47
+ stop
48
+ start
49
+ end
50
+
51
+ def running?
52
+ poll_status
53
+ !@pid.nil?
54
+ end
55
+
56
+ def poll_status
57
+ current_pid = @pid
58
+ return nil unless current_pid
59
+
60
+ waited_pid, status = Process.waitpid2(current_pid, Process::WNOHANG)
61
+ return nil unless waited_pid
62
+
63
+ @pid = nil
64
+ @last_status = status
65
+ status
66
+ rescue Errno::ECHILD
67
+ @pid = nil
68
+ nil
69
+ end
70
+
71
+ private
72
+
73
+ def spawn_process
74
+ Process.spawn(
75
+ *command,
76
+ chdir: @chdir,
77
+ out: @child_stdout,
78
+ err: @child_stderr,
79
+ pgroup: true
80
+ )
81
+ end
82
+
83
+ def terminate_process(current_pid)
84
+ terminate_pid(current_pid)
85
+ status = wait_for_exit(current_pid, @grace_period)
86
+ return status if status
87
+
88
+ terminate_group(current_pid)
89
+ status = wait_for_exit(current_pid, [@kill_timeout - @grace_period, 0].max)
90
+ return status if status
91
+
92
+ kill_pid(current_pid)
93
+ kill_group(current_pid)
94
+ wait_for_exit(current_pid, 0.5)
95
+ end
96
+
97
+ def wait_for_exit(current_pid, timeout)
98
+ deadline = monotonic_now + timeout
99
+
100
+ loop do
101
+ waited_pid, status = Process.waitpid2(current_pid, Process::WNOHANG)
102
+ return status if waited_pid
103
+
104
+ break if monotonic_now >= deadline
105
+
106
+ sleep 0.05
107
+ end
108
+
109
+ nil
110
+ rescue Errno::ECHILD
111
+ @last_status
112
+ end
113
+
114
+ def terminate_pid(current_pid)
115
+ Process.kill("TERM", current_pid)
116
+ rescue Errno::ESRCH
117
+ nil
118
+ end
119
+
120
+ def terminate_group(current_pid)
121
+ Process.kill("TERM", -current_pid)
122
+ rescue Errno::ESRCH, Errno::EPERM
123
+ nil
124
+ end
125
+
126
+ def kill_pid(current_pid)
127
+ Process.kill("KILL", current_pid)
128
+ rescue Errno::ESRCH
129
+ nil
130
+ end
131
+
132
+ def kill_group(current_pid)
133
+ Process.kill("KILL", -current_pid)
134
+ rescue Errno::ESRCH, Errno::EPERM
135
+ nil
136
+ end
137
+
138
+ def monotonic_now
139
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBWatch
4
+ class Runner
5
+ CRASH_LOOP_THRESHOLD = 1.0
6
+
7
+ def initialize(configuration:, dashboard: nil, scanner: nil, process_manager: nil, watcher: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
8
+ @configuration = configuration
9
+ @theme = configuration.theme
10
+ @dashboard = dashboard || Dashboard.new(output: configuration.stderr, theme: @theme, clear: configuration.clear?)
11
+ @scanner = scanner || FileScanner.new(configuration)
12
+ @process_manager = process_manager || ProcessManager.new(
13
+ command: configuration.command,
14
+ stdout: configuration.stdout,
15
+ stderr: configuration.stderr
16
+ )
17
+ @watcher = watcher || Watcher.new(
18
+ scanner: @scanner,
19
+ delay: configuration.delay,
20
+ poll_interval: poll_interval
21
+ )
22
+ @clock = clock
23
+ @started_at = current_time
24
+ @process_started_at = nil
25
+ @restart_count = 0
26
+ @shutdown_requested = false
27
+ @restart_suspended = false
28
+ @previous_signal_handlers = {}
29
+ end
30
+
31
+ def run
32
+ ensure_command!
33
+ install_signal_handlers
34
+
35
+ dashboard.clear_screen if configuration.clear?
36
+ dashboard.hide_cursor
37
+
38
+ dashboard.render_banner(
39
+ version: VERSION,
40
+ command: configuration.command_string,
41
+ watch_paths: configuration.watch_paths,
42
+ config_file: configuration.config_file,
43
+ theme_name: configuration.theme_name
44
+ ) if configuration.banner?
45
+
46
+ dashboard.log_info("Starting process...")
47
+ dashboard.log_info("Watching #{scanner.count} files")
48
+ dashboard.log_info("Loaded config file: #{configuration.config_file}") if configuration.config_file
49
+ dashboard.log_info("Extensions: #{configuration.extensions.join(', ')}") if configuration.verbose?
50
+ dashboard.log_info("Ignoring: #{configuration.ignore.join(', ')}") if configuration.verbose?
51
+ dashboard.log_info("Theme: #{configuration.theme_name}") if configuration.verbose?
52
+
53
+ spinner = dashboard.spinner("Starting application...")
54
+ spinner.auto_spin
55
+ process_manager.start
56
+ mark_process_started
57
+ spinner.success("Application started")
58
+
59
+ dashboard.log_success("Process started (PID #{process_manager.pid})")
60
+ render_status_card
61
+ dashboard.render_footer
62
+
63
+ watcher.reset(scanner.scan)
64
+ watch_loop
65
+
66
+ 0
67
+ rescue Theme::UnknownThemeError => e
68
+ dashboard.log_error(e.message)
69
+ 1
70
+ rescue StandardError => e
71
+ dashboard.log_error("#{e.class}: #{e.message}")
72
+ dashboard.log_error(e.backtrace.first) if configuration.verbose? && e.backtrace
73
+ 1
74
+ ensure
75
+ dashboard.log_warning("Received #{@shutdown_signal}, shutting down...") if @shutdown_signal
76
+ watcher.stop
77
+ process_manager.stop
78
+ dashboard.show_cursor
79
+ restore_signal_handlers
80
+ end
81
+
82
+ private
83
+
84
+ attr_reader :configuration, :dashboard, :scanner, :process_manager, :watcher
85
+
86
+ def ensure_command!
87
+ raise ArgumentError, "rbwatch requires a command to run" if configuration.command.empty?
88
+ end
89
+
90
+ def poll_interval
91
+ [configuration.delay_seconds / 3.0, 0.1].min
92
+ end
93
+
94
+ def install_signal_handlers
95
+ %w[INT TERM].each do |signal|
96
+ @previous_signal_handlers[signal] = Signal.trap(signal) { request_shutdown(signal) }
97
+ end
98
+ end
99
+
100
+ def restore_signal_handlers
101
+ @previous_signal_handlers.each do |signal, handler|
102
+ Signal.trap(signal, handler)
103
+ end
104
+ end
105
+
106
+ def request_shutdown(signal)
107
+ @shutdown_signal = signal
108
+ @shutdown_requested = true
109
+ end
110
+
111
+ def shutdown_requested?
112
+ @shutdown_requested
113
+ end
114
+
115
+ def watch_loop
116
+ until shutdown_requested?
117
+ if (status = process_manager.poll_status)
118
+ handle_process_exit(status)
119
+ next
120
+ end
121
+
122
+ if (event = watcher.step)
123
+ handle_change_event(event)
124
+ next
125
+ end
126
+
127
+ sleep watcher.poll_interval
128
+ end
129
+ end
130
+
131
+ def handle_process_exit(status)
132
+ dashboard.clear_screen if configuration.clear?
133
+ description = exit_description(status)
134
+ dashboard.log_warning("Process exited #{description}")
135
+
136
+ if quick_exit? && configuration.restart_on_exit?
137
+ @restart_suspended = true
138
+ dashboard.log_warning("Process exited too quickly; waiting for file changes before restarting.")
139
+ return
140
+ end
141
+
142
+ @restart_suspended = false
143
+
144
+ unless configuration.restart_on_exit?
145
+ @shutdown_requested = true
146
+ return
147
+ end
148
+
149
+ dashboard.log_restart("Restarting application after exit...")
150
+ restart_process
151
+ end
152
+
153
+ def handle_change_event(event)
154
+ dashboard.clear_screen if configuration.clear?
155
+ dashboard.log_restart("Changes detected")
156
+ dashboard.render_restart(event.change_set)
157
+ restart_process
158
+ end
159
+
160
+ def restart_process
161
+ spinner = dashboard.spinner("Starting application...")
162
+ spinner.auto_spin
163
+ process_manager.restart
164
+ mark_process_started
165
+ @restart_count += 1
166
+ @restart_suspended = false
167
+ spinner.success("Application started")
168
+
169
+ dashboard.log_success("Process started (PID #{process_manager.pid})")
170
+ render_status_card
171
+ dashboard.render_footer
172
+ watcher.reset(scanner.scan)
173
+ end
174
+
175
+ def render_status_card
176
+ dashboard.render_status(
177
+ status: "Watching",
178
+ files: scanner.count,
179
+ extensions: configuration.extensions,
180
+ delay: configuration.delay,
181
+ pid: process_manager.pid,
182
+ command: configuration.command_string,
183
+ config_file: configuration.config_file,
184
+ theme_name: configuration.theme_name,
185
+ stats: stats_snapshot
186
+ )
187
+ end
188
+
189
+ def exit_description(status)
190
+ if status.respond_to?(:signaled?) && status.signaled?
191
+ "by signal #{status.termsig}"
192
+ elsif status.respond_to?(:exitstatus) && !status.exitstatus.nil?
193
+ "with status #{status.exitstatus}"
194
+ else
195
+ "unexpectedly"
196
+ end
197
+ end
198
+
199
+ def stats_snapshot
200
+ return nil unless configuration.stats?
201
+
202
+ {
203
+ restarts: @restart_count,
204
+ uptime: format_duration(elapsed_seconds)
205
+ }
206
+ end
207
+
208
+ def elapsed_seconds
209
+ current_time - @started_at
210
+ end
211
+
212
+ def format_duration(total_seconds)
213
+ total = total_seconds.to_i
214
+ hours = total / 3600
215
+ minutes = (total % 3600) / 60
216
+ seconds = total % 60
217
+
218
+ format("%02d:%02d:%02d", hours, minutes, seconds)
219
+ end
220
+
221
+ def quick_exit?
222
+ return false unless @process_started_at
223
+
224
+ process_uptime < CRASH_LOOP_THRESHOLD
225
+ end
226
+
227
+ def process_uptime
228
+ current_time - @process_started_at
229
+ end
230
+
231
+ def mark_process_started
232
+ @process_started_at = current_time
233
+ end
234
+
235
+ def current_time
236
+ @clock.call
237
+ end
238
+ end
239
+ end