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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9ff7a8e55dbe3107b8476b224e28e0da6a2d13caceded52652a23449c95878db
4
+ data.tar.gz: 5f956a57c909f30f0625dd80e4765988b11abb3377b6d15526daf51662356840
5
+ SHA512:
6
+ metadata.gz: 153366c2a7cda009abec391429663ceddd20745003198c063f354189189a5f7ca794a6bde5285994bbd095abb53853796f24dc7c1c86d44c234138faf8b946fe
7
+ data.tar.gz: 78d5d5eb3edf0d759b840b228795cb6134ab8f03bd9223ecd2c224a98d701fbb9a9dc0730cc7a24b65c92e1d1a4b8d99eeb7cc47a54e0f07794dc027fcb40640
data/README.md ADDED
@@ -0,0 +1,217 @@
1
+ # RBWatch
2
+
3
+ RBWatch is a Ruby process watcher inspired by nodemon.
4
+
5
+ It watches source files recursively and restarts the process automatically when changes are detected.
6
+
7
+ ## Goals
8
+
9
+ - Clean architecture
10
+ - Delightful terminal output
11
+ - Reliable process lifecycle management
12
+ - Works on Linux, macOS, and WSL
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ bundle install
18
+ ```
19
+
20
+ If you install RBWatch globally with RubyGems, the `rbwatch` and `rbw` executables are available directly on your shell `PATH`:
21
+
22
+ ```bash
23
+ gem install rbwatch
24
+ ```
25
+
26
+ If you want to use RBWatch from another project while developing locally, add it to that project's `Gemfile`:
27
+
28
+ ```ruby
29
+ gem "rbwatch", path: "../../../open-source/rbwatch"
30
+ ```
31
+
32
+ Then run:
33
+
34
+ ```bash
35
+ bundle install
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ### From this repository checkout
41
+
42
+ Run a Ruby app directly:
43
+
44
+ ```bash
45
+ rbwatch app.rb
46
+ ```
47
+
48
+ If you are working from this repository checkout:
49
+
50
+ ```bash
51
+ bundle exec exe/rbwatch app.rb
52
+ ```
53
+
54
+ Or use the alias:
55
+
56
+ ```bash
57
+ rbw app.rb
58
+ ```
59
+
60
+ If you are running from this repository checkout, use:
61
+
62
+ ```bash
63
+ bundle exec exe/rbw app.rb
64
+ ```
65
+
66
+ ### From another project
67
+
68
+ If RBWatch is added to another project's `Gemfile`, run it through Bundler:
69
+
70
+ ```bash
71
+ bundle exec rbwatch app.rb
72
+ bundle exec rbw app.rb
73
+ ```
74
+
75
+ Bundler makes the executables available to the bundle, but it does not place them on your shell `PATH`.
76
+
77
+ If you want a direct command without `bundle exec`, generate binstubs in that project:
78
+
79
+ ```bash
80
+ bundle binstubs rbwatch
81
+ bin/rbw app.rb
82
+ ```
83
+
84
+ If the first argument is a Ruby file, RBWatch expands it to `ruby app.rb` automatically.
85
+
86
+ You can still run explicit commands:
87
+
88
+ ```bash
89
+ bundle exec exe/rbwatch ruby app.rb
90
+ ```
91
+
92
+ Or watch a Bundler-driven command:
93
+
94
+ ```bash
95
+ bundle exec exe/rbwatch bundle exec ruby app.rb
96
+ ```
97
+
98
+ Useful flags:
99
+
100
+ ```bash
101
+ bundle exec exe/rbwatch --delay 500 ruby app.rb
102
+ bundle exec exe/rbwatch --ext rb,erb,yml ruby app.rb
103
+ bundle exec exe/rbwatch --ignore tmp,log,node_modules ruby app.rb
104
+ bundle exec exe/rbwatch --clear ruby app.rb
105
+ bundle exec exe/rbwatch --no-clear ruby app.rb
106
+ bundle exec exe/rbwatch --verbose ruby app.rb
107
+ bundle exec exe/rbwatch --no-banner ruby app.rb
108
+ bundle exec exe/rbwatch --no-color ruby app.rb
109
+ bundle exec exe/rbwatch --theme neon ruby app.rb
110
+ bundle exec exe/rbwatch --watch app,config ruby app.rb
111
+ bundle exec exe/rbwatch --config rbwatch.yml ruby app.rb
112
+ ```
113
+
114
+ ## Configuration File
115
+
116
+ RBWatch can load a YAML configuration file with `--config` or automatically discover `rbwatch.yml`, `rbwatch.yaml`, `.rbwatch.yml`, or `.rbwatch.yaml` in the current directory.
117
+
118
+ Example `rbwatch.yml`:
119
+
120
+ ```yaml
121
+ delay: 300
122
+ extensions:
123
+ - rb
124
+ - erb
125
+ - yml
126
+ ignore:
127
+ - tmp
128
+ - log
129
+ watch_paths:
130
+ - app
131
+ - config
132
+ command: ruby app.rb
133
+ clear: true
134
+ verbose: true
135
+ banner: true
136
+ color: true
137
+ theme: neon
138
+ ```
139
+
140
+ CLI options always win over values from the config file.
141
+
142
+ ## Defaults
143
+
144
+ RBWatch watches these extensions by default:
145
+
146
+ - `rb`
147
+ - `erb`
148
+ - `haml`
149
+ - `slim`
150
+ - `rake`
151
+ - `yml`
152
+ - `yaml`
153
+ - `json`
154
+
155
+ Ignored paths by default:
156
+
157
+ - `log`
158
+ - `tmp`
159
+ - `.git`
160
+ - `node_modules`
161
+
162
+ Default debounce delay: `300ms`
163
+
164
+ By default, RBWatch clears the visible terminal and scrollback before each restart when it is attached to a TTY. Use `--no-clear` or `clear: false` if you want to keep previous output on screen.
165
+
166
+ The startup dashboard shows the command, watched roots, theme, and config file when available.
167
+
168
+ If you pass `--stats`, the status card also shows restart count and uptime.
169
+
170
+ ## Architecture
171
+
172
+ ```text
173
+ rbwatch/
174
+ ├── bin/
175
+ │ ├── rbwatch
176
+ │ └── rbw
177
+ ├── exe/
178
+ │ ├── rbwatch
179
+ │ └── rbw
180
+ ├── lib/
181
+ │ └── rbwatch/
182
+ │ ├── cli.rb
183
+ │ ├── configuration.rb
184
+ │ ├── dashboard.rb
185
+ │ ├── file_scanner.rb
186
+ │ ├── process_manager.rb
187
+ │ ├── runner.rb
188
+ │ ├── theme.rb
189
+ │ ├── watcher.rb
190
+ │ ├── version.rb
191
+ │ └── rbwatch.rb
192
+ └── spec/
193
+ ```
194
+
195
+ ## Notes
196
+
197
+ - Process spawning uses `spawn`.
198
+ - Restarting uses `Process.kill("TERM", pid)` first and escalates if the process refuses to exit.
199
+ - CTRL+C and `TERM` are handled gracefully.
200
+ - The UI uses TTY gems for boxed layouts, cursors, spinners, tables, and screen sizing.
201
+ - Fast startup failures do not restart in a tight loop; RBWatch waits for file changes before trying again.
202
+
203
+ ## Future-ready hooks
204
+
205
+ The configuration layer already leaves room for:
206
+
207
+ - `--exec`
208
+ - `--config rbwatch.yml`
209
+ - `--watch app,config`
210
+ - `--sound`
211
+ - `--desktop-notify`
212
+ - `--history`
213
+ - `--theme neon`
214
+ - `--theme minimal`
215
+ - `--theme retro`
216
+ - `--theme ruby`
217
+ - `--theme matrix`
data/bin/rbw ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/rbwatch"
5
+
6
+ exit RBWatch::CLI.start(ARGV)
7
+
data/bin/rbwatch ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/rbwatch"
5
+
6
+ exit RBWatch::CLI.start(ARGV)
7
+
data/exe/rbw ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ load File.expand_path("../bin/rbw", __dir__)
5
+
data/exe/rbwatch ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ load File.expand_path("../bin/rbwatch", __dir__)
5
+
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module RBWatch
6
+ class CLI
7
+ def self.start(argv = ARGV, stdout: $stdout, stderr: $stderr, stdin: $stdin)
8
+ new(argv, stdout: stdout, stderr: stderr, stdin: stdin).run
9
+ end
10
+
11
+ def initialize(argv, stdout:, stderr:, stdin:)
12
+ @argv = argv.dup
13
+ @stdout = stdout
14
+ @stderr = stderr
15
+ @stdin = stdin
16
+ end
17
+
18
+ def run
19
+ configuration = parse
20
+ if configuration == :help
21
+ @stdout.puts parser if @show_help
22
+ return 0
23
+ end
24
+
25
+ Runner.new(configuration: configuration).run
26
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
27
+ @stderr.puts e.message
28
+ @stderr.puts
29
+ @stderr.puts parser
30
+ 1
31
+ rescue Theme::UnknownThemeError => e
32
+ @stderr.puts e.message
33
+ 1
34
+ rescue ArgumentError => e
35
+ @stderr.puts e.message
36
+ @stderr.puts
37
+ @stderr.puts parser
38
+ 1
39
+ end
40
+
41
+ def parse
42
+ options = default_options
43
+ parser = build_parser(options)
44
+ parser.parse!(@argv)
45
+
46
+ if options.delete(:help_requested)
47
+ @show_help = true
48
+ return :help
49
+ end
50
+
51
+ configuration = Configuration.from_sources(
52
+ cli_options: options,
53
+ command: @argv,
54
+ stdin: @stdin,
55
+ stdout: @stdout,
56
+ stderr: @stderr
57
+ )
58
+
59
+ if configuration.command.empty?
60
+ @stderr.puts parser
61
+ @show_help = false
62
+ return :help
63
+ end
64
+
65
+ configuration
66
+ ensure
67
+ @parser = parser
68
+ end
69
+
70
+ def parser
71
+ @parser || build_parser(default_options)
72
+ end
73
+
74
+ private
75
+
76
+ def default_options
77
+ {
78
+ delay: nil,
79
+ extensions: nil,
80
+ ignore: nil,
81
+ watch_paths: nil,
82
+ clear: nil,
83
+ verbose: nil,
84
+ banner: nil,
85
+ color: nil,
86
+ theme_name: nil,
87
+ restart_on_exit: nil,
88
+ sound: nil,
89
+ desktop_notify: nil,
90
+ stats: nil,
91
+ history: nil,
92
+ config_file: nil,
93
+ exec_mode: nil,
94
+ help_requested: false
95
+ }
96
+ end
97
+
98
+ def build_parser(options)
99
+ OptionParser.new do |opts|
100
+ opts.banner = "Usage: rbwatch [options] command [args...]"
101
+ opts.separator ""
102
+ opts.separator "Examples:"
103
+ opts.separator " rbwatch app.rb"
104
+ opts.separator " rbwatch ruby app.rb"
105
+ opts.separator " rbw app.rb"
106
+ opts.separator " rbwatch bundle exec ruby app.rb"
107
+ opts.separator " rbwatch --config rbwatch.yml ruby app.rb"
108
+ opts.separator ""
109
+ opts.separator "Options:"
110
+
111
+ opts.on("--delay MS", Integer, "Debounce delay in milliseconds (default 300)") { |value| options[:delay] = value }
112
+ opts.on("--ext EXTENSIONS", String, "Comma-separated extensions") { |value| options[:extensions] = split_csv(value) }
113
+ opts.on("--ignore PATHS", String, "Comma-separated ignored paths") { |value| options[:ignore] = split_csv(value) }
114
+ opts.on("--watch PATHS", String, "Comma-separated watch roots") { |value| options[:watch_paths] = split_csv(value) }
115
+ opts.on("--theme NAME", String, "Theme name: ruby, neon, minimal, matrix") { |value| options[:theme_name] = value }
116
+ opts.on("--clear", "Clear the terminal and scrollback before each restart") { options[:clear] = true }
117
+ opts.on("--no-clear", "Keep previous terminal output between restarts") { options[:clear] = false }
118
+ opts.on("--verbose", "Show additional logs") { options[:verbose] = true }
119
+ opts.on("--no-banner", "Hide the startup banner") { options[:banner] = false }
120
+ opts.on("--no-color", "Disable ANSI colors") { options[:color] = false }
121
+ opts.on("--sound", "Reserved for future sound notifications") { options[:sound] = true }
122
+ opts.on("--desktop-notify", "Reserved for desktop notifications") { options[:desktop_notify] = true }
123
+ opts.on("--stats", "Show restart count and uptime in the status card") { options[:stats] = true }
124
+ opts.on("--history", "Reserved for restart history") { options[:history] = true }
125
+ opts.on("--config PATH", String, "Load settings from a YAML configuration file") { |value| options[:config_file] = value }
126
+ opts.on("--exec", "Reserved for exec-mode launches") { options[:exec_mode] = true }
127
+ opts.on_tail("-h", "--help", "Show this help") { options[:help_requested] = true }
128
+ end
129
+ end
130
+
131
+ def split_csv(value)
132
+ value.to_s.split(",").map(&:strip).reject(&:empty?)
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "yaml"
5
+
6
+ module RBWatch
7
+ class Configuration
8
+ DEFAULT_DELAY = 300
9
+ DEFAULT_EXTENSIONS = %w[rb erb haml slim rake yml yaml json].freeze
10
+ DEFAULT_IGNORE = %w[log tmp .git node_modules].freeze
11
+ DEFAULT_WATCH_PATHS = ["."].freeze
12
+ CONFIG_FILENAMES = %w[rbwatch.yml rbwatch.yaml .rbwatch.yml .rbwatch.yaml].freeze
13
+ SHORTHAND_RUBY_EXTENSIONS = %w[.rb .ru].freeze
14
+
15
+ attr_reader :delay,
16
+ :extensions,
17
+ :ignore,
18
+ :watch_paths,
19
+ :command,
20
+ :clear,
21
+ :verbose,
22
+ :banner,
23
+ :color,
24
+ :theme_name,
25
+ :restart_on_exit,
26
+ :sound,
27
+ :desktop_notify,
28
+ :stats,
29
+ :history,
30
+ :config_file,
31
+ :exec_mode,
32
+ :stdin,
33
+ :stdout,
34
+ :stderr
35
+
36
+ class << self
37
+ def discover_config_file(cwd = Dir.pwd)
38
+ CONFIG_FILENAMES.map { |name| File.join(cwd, name) }.find { |path| File.file?(path) }
39
+ end
40
+
41
+ def load_file(path)
42
+ return {} if path.nil? || path.to_s.strip.empty?
43
+
44
+ raw = YAML.safe_load(File.read(path), permitted_classes: [Symbol], aliases: true)
45
+ raise ArgumentError, "Config file must contain a YAML mapping" unless raw.nil? || raw.is_a?(Hash)
46
+
47
+ normalize_config_hash(raw || {})
48
+ rescue Psych::SyntaxError => e
49
+ raise ArgumentError, "Unable to parse config file #{path}: #{e.message}"
50
+ end
51
+
52
+ def from_sources(cli_options:, command:, stdin:, stdout:, stderr:)
53
+ cli_options = symbolize_keys(cli_options)
54
+ explicit_config_file = cli_options.delete(:config_file)
55
+ config_path = explicit_config_file || discover_config_file
56
+ file_options = config_path ? load_file(config_path) : {}
57
+
58
+ merged = default_options.merge(file_options).merge(cli_options.compact)
59
+ resolved_command = command.empty? ? Array(merged[:command]) : command
60
+
61
+ new(
62
+ **merged.merge(command: resolved_command, config_file: config_path),
63
+ stdin: stdin,
64
+ stdout: stdout,
65
+ stderr: stderr
66
+ )
67
+ end
68
+
69
+ def default_options
70
+ {
71
+ delay: DEFAULT_DELAY,
72
+ extensions: DEFAULT_EXTENSIONS.dup,
73
+ ignore: DEFAULT_IGNORE.dup,
74
+ watch_paths: DEFAULT_WATCH_PATHS.dup,
75
+ command: [],
76
+ clear: nil,
77
+ verbose: false,
78
+ banner: true,
79
+ color: true,
80
+ theme_name: :ruby,
81
+ restart_on_exit: true,
82
+ sound: false,
83
+ desktop_notify: false,
84
+ stats: false,
85
+ history: false,
86
+ config_file: nil,
87
+ exec_mode: false
88
+ }
89
+ end
90
+
91
+ def normalize_config_hash(hash)
92
+ hash.each_with_object({}) do |(key, value), options|
93
+ case key.to_s
94
+ when "delay"
95
+ options[:delay] = value
96
+ when "extensions"
97
+ options[:extensions] = normalize_list(value)
98
+ when "ignore"
99
+ options[:ignore] = normalize_list(value)
100
+ when "watch_paths", "watch"
101
+ options[:watch_paths] = normalize_watch_paths(value)
102
+ when "command"
103
+ options[:command] = normalize_command(value)
104
+ when "clear", "verbose", "banner", "color", "restart_on_exit", "sound", "desktop_notify", "stats", "history", "exec_mode"
105
+ options[key.to_sym] = !!value
106
+ when "theme", "theme_name"
107
+ options[:theme_name] = Theme.normalize_name(value) || :ruby
108
+ end
109
+ end
110
+ end
111
+
112
+ def normalize_list(value)
113
+ Array(value).flat_map { |item| item.to_s.split(",") }
114
+ .map { |entry| entry.strip.downcase }
115
+ .reject(&:empty?)
116
+ .uniq
117
+ end
118
+
119
+ def normalize_watch_paths(value)
120
+ paths = Array(value).flat_map { |item| item.to_s.split(",") }
121
+ normalized = paths.map { |path| path.strip }.reject(&:empty?)
122
+ normalized = ["."] if normalized.empty?
123
+ normalized.uniq
124
+ end
125
+
126
+ def normalize_command(value)
127
+ command = case value
128
+ when nil
129
+ []
130
+ when String
131
+ Shellwords.split(value)
132
+ else
133
+ Array(value).flat_map do |entry|
134
+ entry.is_a?(String) ? Shellwords.split(entry) : entry
135
+ end.map(&:to_s)
136
+ end
137
+ expand_command_shorthand(command)
138
+ end
139
+
140
+ def expand_command_shorthand(command)
141
+ return command if command.length != 1
142
+
143
+ first = command.first.to_s
144
+ return command unless ruby_script_path?(first)
145
+
146
+ ["ruby", first]
147
+ end
148
+
149
+ def ruby_script_path?(path)
150
+ SHORTHAND_RUBY_EXTENSIONS.include?(File.extname(path).downcase)
151
+ end
152
+
153
+ def symbolize_keys(hash)
154
+ hash.each_with_object({}) { |(key, value), result| result[key.to_sym] = value }
155
+ end
156
+ end
157
+
158
+ def initialize(
159
+ delay: DEFAULT_DELAY,
160
+ extensions: DEFAULT_EXTENSIONS,
161
+ ignore: DEFAULT_IGNORE,
162
+ watch_paths: DEFAULT_WATCH_PATHS,
163
+ command: [],
164
+ clear: nil,
165
+ verbose: false,
166
+ banner: true,
167
+ color: true,
168
+ theme_name: "ruby",
169
+ restart_on_exit: true,
170
+ sound: false,
171
+ desktop_notify: false,
172
+ stats: false,
173
+ history: false,
174
+ config_file: nil,
175
+ exec_mode: false,
176
+ stdin: $stdin,
177
+ stdout: $stdout,
178
+ stderr: $stderr
179
+ )
180
+ @delay = Integer(delay)
181
+ @extensions = normalize_list(extensions)
182
+ @ignore = normalize_list(ignore)
183
+ @watch_paths = normalize_watch_paths(watch_paths)
184
+ @command = normalize_command(command)
185
+ @verbose = !!verbose
186
+ @banner = !!banner
187
+ @color = !!color
188
+ @theme_name = Theme.normalize_name(theme_name) || :ruby
189
+ @restart_on_exit = !!restart_on_exit
190
+ @sound = !!sound
191
+ @desktop_notify = !!desktop_notify
192
+ @stats = !!stats
193
+ @history = !!history
194
+ @config_file = config_file
195
+ @exec_mode = !!exec_mode
196
+ @stdin = stdin
197
+ @stdout = stdout
198
+ @stderr = stderr
199
+ @clear = clear.nil? ? clear_by_default?(stderr) : !!clear
200
+ @theme = nil
201
+ end
202
+
203
+ def color?
204
+ @color
205
+ end
206
+
207
+ def verbose?
208
+ @verbose
209
+ end
210
+
211
+ def clear?
212
+ @clear
213
+ end
214
+
215
+ def banner?
216
+ @banner
217
+ end
218
+
219
+ def restart_on_exit?
220
+ @restart_on_exit
221
+ end
222
+
223
+ def sound?
224
+ @sound
225
+ end
226
+
227
+ def desktop_notify?
228
+ @desktop_notify
229
+ end
230
+
231
+ def stats?
232
+ @stats
233
+ end
234
+
235
+ def history?
236
+ @history
237
+ end
238
+
239
+ def exec_mode?
240
+ @exec_mode
241
+ end
242
+
243
+ def theme
244
+ @theme ||= Theme.resolve(theme_name, color: color?, interactive: interactive_theme_prompt?, input: stdin, output: stderr)
245
+ end
246
+
247
+ def delay_seconds
248
+ delay / 1000.0
249
+ end
250
+
251
+ def command_string
252
+ Shellwords.join(command)
253
+ end
254
+
255
+ def interactive_theme_prompt?
256
+ stdin.respond_to?(:tty?) && stdout.respond_to?(:tty?) && stdin.tty? && stdout.tty?
257
+ end
258
+
259
+ def to_h
260
+ {
261
+ delay: delay,
262
+ extensions: extensions.dup,
263
+ ignore: ignore.dup,
264
+ watch_paths: watch_paths.dup,
265
+ command: command.dup,
266
+ clear: clear?,
267
+ verbose: verbose?,
268
+ banner: banner?,
269
+ color: color?,
270
+ theme_name: theme_name,
271
+ restart_on_exit: restart_on_exit?,
272
+ sound: sound?,
273
+ desktop_notify: desktop_notify?,
274
+ stats: stats?,
275
+ history: history?,
276
+ config_file: config_file,
277
+ exec_mode: exec_mode?
278
+ }
279
+ end
280
+
281
+ private
282
+
283
+ def normalize_list(values)
284
+ self.class.normalize_list(values)
285
+ end
286
+
287
+ def normalize_watch_paths(values)
288
+ self.class.normalize_watch_paths(values)
289
+ end
290
+
291
+ def normalize_command(value)
292
+ self.class.normalize_command(value)
293
+ end
294
+
295
+ def clear_by_default?(stderr)
296
+ stderr.respond_to?(:tty?) && stderr.tty?
297
+ end
298
+ end
299
+ end