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.
- checksums.yaml +7 -0
- data/README.md +217 -0
- data/bin/rbw +7 -0
- data/bin/rbwatch +7 -0
- data/exe/rbw +5 -0
- data/exe/rbwatch +5 -0
- data/lib/rbwatch/cli.rb +135 -0
- data/lib/rbwatch/configuration.rb +299 -0
- data/lib/rbwatch/dashboard.rb +244 -0
- data/lib/rbwatch/file_scanner.rb +101 -0
- data/lib/rbwatch/process_manager.rb +142 -0
- data/lib/rbwatch/runner.rb +239 -0
- data/lib/rbwatch/theme.rb +226 -0
- data/lib/rbwatch/version.rb +5 -0
- data/lib/rbwatch/watcher.rb +98 -0
- data/lib/rbwatch.rb +15 -0
- metadata +184 -0
|
@@ -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
|