relaunch 0.0.1 → 0.16.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,391 @@
1
+ require 'timeout'
2
+ require 'io/wait'
3
+
4
+ module Rerun
5
+ class Runner
6
+
7
+ # The watcher instance that wait for changes
8
+ attr_reader :watcher
9
+
10
+ def self.keep_running(cmd, options)
11
+ runner = new(cmd, options)
12
+ runner.start
13
+ runner.join
14
+ # apparently runner doesn't keep running anymore (as of Listen 2) so we have to sleep forever :-(
15
+ sleep 10000 while true # :-(
16
+ end
17
+
18
+ include System
19
+ include ::Timeout
20
+
21
+ def initialize(run_command, options = {})
22
+ @run_command, @options = run_command, options
23
+ @run_command = "ruby #{@run_command}" if @run_command.split(' ').first =~ /\.rb$/
24
+ @options[:directory] ||= options.delete(:dir) || '.'
25
+ @options[:ignore] ||= []
26
+ end
27
+
28
+ def start_keypress_thread
29
+ return if @options[:background] || !interactive_terminal?
30
+
31
+ @keypress_thread = Thread.new do
32
+ while true
33
+ if c = key_pressed
34
+ case c.downcase
35
+ when 'c'
36
+ say "Clearing screen"
37
+ clear_screen
38
+ when 'r'
39
+ say "Restarting"
40
+ restart
41
+ when 'f'
42
+ say "Stopping and starting"
43
+ restart(false)
44
+ when 'p'
45
+ toggle_pause
46
+ when 'x', 'q'
47
+ die
48
+ break # the break will stop this thread, in case the 'die' doesn't
49
+ else
50
+ puts "\n#{c.inspect} pressed inside rerun"
51
+ puts [["c", "clear screen"],
52
+ ["r", "restart"],
53
+ ["f", "forced restart (stop and start)"],
54
+ ["p", "toggle pause"],
55
+ ["x or q", "stop and exit"]
56
+ ].map {|key, description| " #{key} -- #{description}"}.join("\n")
57
+ puts
58
+ end
59
+ end
60
+ sleep 1 # todo: use select instead of polling somehow?
61
+ end
62
+ end
63
+ @keypress_thread.run
64
+ end
65
+
66
+ def stop_keypress_thread
67
+ @keypress_thread.kill if @keypress_thread
68
+ @keypress_thread = nil
69
+ end
70
+
71
+ def restart(with_signal = true)
72
+ @restarting = true
73
+ if @options[:restart] && with_signal
74
+ restart_with_signal(@options[:signal])
75
+ else
76
+ stop
77
+ start
78
+ end
79
+ @restarting = false
80
+ end
81
+
82
+ def toggle_pause
83
+ unless @pausing
84
+ say "Pausing. Press 'p' again to resume."
85
+ @watcher.pause
86
+ @pausing = true
87
+ else
88
+ say "Resuming."
89
+ @watcher.unpause
90
+ @pausing = false
91
+ end
92
+ end
93
+
94
+ def unpause
95
+ @watcher.unpause
96
+ end
97
+
98
+ def dir
99
+ @options[:directory]
100
+ end
101
+
102
+ def pattern
103
+ @options[:pattern]
104
+ end
105
+
106
+ def clear?
107
+ @options[:clear]
108
+ end
109
+
110
+ def quiet?
111
+ @options[:quiet]
112
+ end
113
+
114
+ def verbose?
115
+ @options[:verbose]
116
+ end
117
+
118
+ def exit?
119
+ @options[:exit]
120
+ end
121
+
122
+ def app_name
123
+ @options[:name]
124
+ end
125
+
126
+ def restart_with_signal(restart_signal)
127
+ if @pid && (@pid != 0)
128
+ notify "restarting", "We will be with you shortly."
129
+ send_signal(restart_signal)
130
+ end
131
+ end
132
+
133
+ def force_polling
134
+ @options[:force_polling]
135
+ end
136
+
137
+ def start
138
+ if @already_running
139
+ taglines = [
140
+ "Here we go again!",
141
+ "Keep on trucking.",
142
+ "Once more unto the breach, dear friends, once more!",
143
+ "The road goes ever on and on, down from the door where it began.",
144
+ ]
145
+ notify "restarted", taglines[rand(taglines.size)]
146
+ else
147
+ taglines = [
148
+ "To infinity... and beyond!",
149
+ "Charge!",
150
+ ]
151
+ notify "launched", taglines[rand(taglines.size)]
152
+ @already_running = true
153
+ end
154
+
155
+ clear_screen if clear?
156
+ start_keypress_thread unless @keypress_thread
157
+
158
+ begin
159
+ @pid = run @run_command
160
+ say "Rerun (#{$PID}) running #{app_name} (#{@pid})"
161
+ rescue => e
162
+ puts "#{e.class}: #{e.message}"
163
+ raise ExitException
164
+ end
165
+
166
+ status_thread = @status_thread = Process.detach(@pid)
167
+
168
+ Signal.trap("INT") do # INT = control-C -- allows user to stop the top-level rerun process
169
+ die
170
+ end
171
+
172
+ Signal.trap("TERM") do # TERM is the polite way of terminating a process
173
+ die
174
+ end
175
+
176
+ begin
177
+ sleep 2
178
+ rescue Interrupt => e
179
+ # in case someone hits control-C immediately ("oops!")
180
+ die
181
+ end
182
+
183
+ if exit?
184
+ status = status_thread.value
185
+ if status.success?
186
+ notify "succeeded", ""
187
+ else
188
+ notify "failed", "Exit status #{status.exitstatus}"
189
+ end
190
+ else
191
+ if !running?
192
+ notify "Launch Failed", "See console for error output"
193
+ @already_running = false
194
+ end
195
+ end
196
+
197
+ unless @watcher
198
+ watcher = Watcher.new(@options) do |changes|
199
+ message = change_message(changes)
200
+ say "Change detected: #{message}"
201
+ restart unless @restarting
202
+ end
203
+ watcher.start
204
+ @watcher = watcher
205
+ ignore = @options[:ignore]
206
+ say "Watching #{dir.join(', ')} for #{pattern}" +
207
+ (ignore.empty? ? "" : " (ignoring #{ignore.join(',')})") +
208
+ (watcher.adapter.nil? ? "" : " with #{watcher.adapter_name} adapter")
209
+ end
210
+ end
211
+
212
+ def run command
213
+ Kernel.spawn command
214
+ end
215
+
216
+ def change_message(changes)
217
+ message = [:modified, :added, :removed].map do |change|
218
+ count = changes[change] ? changes[change].size : 0
219
+ if count > 0
220
+ "#{count} #{change}"
221
+ end
222
+ end.compact.join(", ")
223
+
224
+ changed_files = changes.values.flatten
225
+ if changed_files.count > 0
226
+ message += ": "
227
+ message += changes.values.flatten[0..3].map {|path| path.split('/').last}.join(', ')
228
+ if changed_files.count > 3
229
+ message += ", ..."
230
+ end
231
+ end
232
+ message
233
+ end
234
+
235
+ def die
236
+ #stop_keypress_thread # don't do this since we're probably *in* the keypress thread
237
+ stop # stop the child process if it exists
238
+ raise ExitException # todo: status code param
239
+ end
240
+
241
+ def join
242
+ @watcher.join
243
+ end
244
+
245
+ def running?
246
+ send_signal(0)
247
+ end
248
+
249
+ # Send the signal to process @pid and wait for it to die.
250
+ # @returns true if the process dies
251
+ # @returns false if either sending the signal fails or the process fails to die
252
+ def signal_and_wait(signal)
253
+ status_thread = @status_thread
254
+
255
+ signal_sent = if windows?
256
+ force_kill = (signal == 'KILL')
257
+ system("taskkill /T #{'/F' if force_kill} /PID #{@pid}")
258
+ else
259
+ send_signal(signal)
260
+ end
261
+
262
+ if signal_sent
263
+ # the signal was successfully sent, so wait for the process to die
264
+ begin
265
+ timeout(@options[:wait]) do
266
+ status_thread.join
267
+ end
268
+ process_status = status_thread.value
269
+ say "Process ended: #{process_status}" if verbose?
270
+ true
271
+ rescue Timeout::Error
272
+ false
273
+ end
274
+ else
275
+ false
276
+ end
277
+ end
278
+
279
+ # Send the signal to process @pid.
280
+ # @returns true if the signal is sent
281
+ # @returns false if sending the signal fails
282
+ # If sending the signal fails, the exception will be swallowed
283
+ # (and logged if verbose is true) and this method will return false.
284
+ #
285
+ def send_signal(signal)
286
+ say "Sending signal #{signal} to #{@pid}" unless signal == 0 if verbose?
287
+ Process.kill(signal, @pid)
288
+ true
289
+ rescue => e
290
+ say "Signal #{signal} failed: #{e.class}: #{e.message}" if verbose?
291
+ false
292
+ end
293
+
294
+ # todo: test escalation
295
+ def stop
296
+ if @pid && (@pid != 0)
297
+ notify "stopping", "All good things must come to an end." unless @restarting
298
+ @options[:signal].split(',').each do |signal|
299
+ success = signal_and_wait(signal)
300
+ return true if success
301
+ end
302
+ end
303
+ rescue
304
+ false
305
+ end
306
+
307
+ def git_head_changed?
308
+ old_git_head = @git_head
309
+ read_git_head
310
+ @git_head and old_git_head and @git_head != old_git_head
311
+ end
312
+
313
+ def read_git_head
314
+ git_head_file = File.join(dir, '.git', 'HEAD')
315
+ @git_head = File.exist?(git_head_file) && File.read(git_head_file)
316
+ end
317
+
318
+ def notify(title, body, background = true)
319
+ Notification.new(title, body, @options).send(background) if @options[:notify]
320
+ puts
321
+ say "#{app_name} #{title}"
322
+ end
323
+
324
+ def say msg
325
+ puts "#{Time.now.strftime("%T")} [rerun] #{msg}" unless quiet?
326
+ end
327
+
328
+ def stty(args)
329
+ system "stty #{args}"
330
+ end
331
+
332
+ # non-blocking stdin reader.
333
+ # returns a 1-char string if a key was pressed; otherwise nil
334
+ #
335
+ def key_pressed
336
+ return one_char if windows?
337
+ begin
338
+ # this "raw input" nonsense is because unix likes waiting for linefeeds before sending stdin
339
+
340
+ # 'raw' means turn raw input on
341
+
342
+ # restore proper output newline handling -- see "man stty" and /usr/include/sys/termios.h
343
+ # looks like "raw" flips off the OPOST bit 0x00000001 /* enable following output processing */
344
+ # which disables #define ONLCR 0x00000002 /* map NL to CR-NL (ala CRMOD) */
345
+ # so this sets it back on again since all we care about is raw input, not raw output
346
+ stty "raw opost"
347
+ one_char
348
+ ensure
349
+ stty "-raw" # turn raw input off
350
+ end
351
+
352
+ # note: according to 'man tty' the proper way restore the settings is
353
+ # tty_state=`stty -g`
354
+ # ensure
355
+ # system 'stty "#{tty_state}'
356
+ # end
357
+ # but this way seems fine and less confusing
358
+ end
359
+
360
+ def clear_screen
361
+ # see http://ascii-table.com/ansi-escape-sequences-vt-100.php
362
+ $stdout.print "\033[H\033[2J"
363
+ end
364
+
365
+ private
366
+
367
+ def interactive_terminal?
368
+ return false unless $stdin.tty?
369
+ return true if windows?
370
+
371
+ # A terminal can be inherited by a background group (e.g. under Overman).
372
+ # Querying ps avoids platform-specific terminal ioctl constants.
373
+ foreground_group = IO.popen(['ps', '-o', 'tpgid=', '-p', Process.pid.to_s],
374
+ err: File::NULL, &:read).to_i
375
+ foreground_group == Process.getpgrp
376
+ rescue IOError, SystemCallError
377
+ false
378
+ end
379
+
380
+ def one_char
381
+ c = nil
382
+ msg = $stdin.respond_to?(:wait_readable) ? :wait_readable : :ready?
383
+
384
+ if $stdin.send(msg)
385
+ c = $stdin.getc
386
+ end
387
+ c.chr if c
388
+ end
389
+
390
+ end
391
+ end
@@ -0,0 +1,22 @@
1
+ module Rerun
2
+ module System
3
+
4
+ def mac?
5
+ RUBY_PLATFORM =~ /darwin/i
6
+ end
7
+
8
+ def windows?
9
+ RUBY_PLATFORM =~ /(mswin|mingw32)/i
10
+ end
11
+
12
+ def linux?
13
+ RUBY_PLATFORM =~ /linux/i
14
+ end
15
+
16
+ def rails?
17
+ rails_sig_file = File.expand_path(".")+"/config/boot.rb"
18
+ File.exist? rails_sig_file
19
+ end
20
+
21
+ end
22
+ end
@@ -0,0 +1,134 @@
1
+ require 'listen'
2
+
3
+ Thread.abort_on_exception = true
4
+
5
+ # This class will watch a directory and alert you of
6
+ # new files, modified files, deleted files.
7
+ #
8
+ # Now uses the Listen gem, but spawns its own thread on top.
9
+ # We should probably be accessing the Listen thread directly.
10
+ #
11
+ # Author: Alex Chaffee
12
+ #
13
+ module Rerun
14
+ class Watcher
15
+ InvalidDirectoryError = Class.new(RuntimeError)
16
+
17
+ #def self.default_ignore
18
+ # Listen::Silencer.new(Listen::Listener.new).send :_default_ignore_patterns
19
+ #end
20
+
21
+ attr_reader :directory, :pattern, :priority, :ignore_dotfiles
22
+
23
+ # Create a file system watcher. Start it by calling #start.
24
+ #
25
+ # @param options[:directory] the directory to watch (default ".")
26
+ # @param options[:pattern] the glob pattern to search under the watched directory (default "**/*")
27
+ # @param options[:priority] the priority of the watcher thread (default 0)
28
+ #
29
+ def initialize(options = {}, &client_callback)
30
+ @client_callback = client_callback
31
+
32
+ options = {
33
+ :directory => ".",
34
+ :pattern => "**/*",
35
+ :priority => 0,
36
+ :ignore_dotfiles => true,
37
+ }.merge(options)
38
+
39
+ @pattern = options[:pattern]
40
+ @directories = options[:directory]
41
+ @directories = sanitize_dirs(@directories)
42
+ @priority = options[:priority]
43
+ @force_polling = options[:force_polling]
44
+ @ignore = [options[:ignore]].flatten.compact
45
+ @ignore_dotfiles = options[:ignore_dotfiles]
46
+ @thread = nil
47
+ end
48
+
49
+ def sanitize_dirs(dirs)
50
+ dirs = [*dirs]
51
+ dirs.map do |d|
52
+ d.chomp!("/")
53
+ unless FileTest.exist?(d) && FileTest.readable?(d) && FileTest.directory?(d)
54
+ raise InvalidDirectoryError, "Directory '#{d}' either doesnt exist or isn't readable"
55
+ end
56
+ File.expand_path(d)
57
+ end
58
+ end
59
+
60
+ def start
61
+ if @thread then
62
+ raise RuntimeError, "already started"
63
+ end
64
+
65
+ @thread = Thread.new do
66
+ @listener = Listen.to(*@directories, only: watching, ignore: ignoring, wait_for_delay: 1, force_polling: @force_polling) do |modified, added, removed|
67
+ count = modified.size + added.size + removed.size
68
+ if count > 0
69
+ @client_callback.call(:modified => modified, :added => added, :removed => removed)
70
+ end
71
+ end
72
+ @listener.start
73
+ end
74
+
75
+ @thread.priority = @priority
76
+
77
+ sleep 0.1 until @listener
78
+
79
+ at_exit { stop } # try really hard to clean up after ourselves
80
+ end
81
+
82
+ def watching
83
+ Rerun::Glob.new(@pattern).to_regexp
84
+ end
85
+
86
+ def ignoring
87
+ patterns = []
88
+ if ignore_dotfiles
89
+ patterns << /^\.[^.]/ # at beginning of string, a real dot followed by any other character
90
+ end
91
+ patterns + @ignore.map { |x| Rerun::Glob.new(x).to_regexp }
92
+ end
93
+
94
+ # kill the file watcher thread
95
+ def stop
96
+ @thread.wakeup rescue ThreadError
97
+ begin
98
+ @listener.stop
99
+ rescue Exception => e
100
+ puts "#{e.class}: #{e.message} stopping listener"
101
+ end
102
+ @thread.kill rescue ThreadError
103
+ end
104
+
105
+ # wait for the file watcher to finish
106
+ def join
107
+ @thread.join if @thread
108
+ rescue Interrupt
109
+ # don't care
110
+ end
111
+
112
+ def pause
113
+ @listener.pause if @listener
114
+ end
115
+
116
+ def unpause
117
+ @listener.start if @listener
118
+ end
119
+
120
+ def running?
121
+ @listener && @listener.processing?
122
+ end
123
+
124
+ def adapter
125
+ @listener &&
126
+ (backend = @listener.instance_variable_get(:@backend)) &&
127
+ backend.instance_variable_get(:@adapter)
128
+ end
129
+
130
+ def adapter_name
131
+ adapter && adapter.class.name.split('::').last
132
+ end
133
+ end
134
+ end
data/lib/rerun.rb ADDED
@@ -0,0 +1,17 @@
1
+ here = File.expand_path(File.dirname(__FILE__))
2
+ $: << here unless $:.include?(here)
3
+
4
+ require "listen" # pull in the Listen gem
5
+ require "rerun/options"
6
+ require "rerun/system"
7
+ require "rerun/notification"
8
+ require "rerun/runner"
9
+ require "rerun/watcher"
10
+ require "rerun/glob"
11
+
12
+ module Rerun
13
+ # Raised when the runner wants to exit cleanly.
14
+ # This allows tests to catch the exit instead of terminating the process.
15
+ class ExitException < StandardError; end
16
+ end
17
+
data/relaunch.gemspec ADDED
@@ -0,0 +1,44 @@
1
+ $spec = Gem::Specification.new do |s|
2
+ s.specification_version = 2 if s.respond_to? :specification_version=
3
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
4
+
5
+ s.name = 'relaunch'
6
+ s.version = '0.16.0'
7
+ s.required_ruby_version = '>= 3.3'
8
+
9
+ s.description = "Restarts your app when a file changes. A no-frills, command-line alternative to Guard, Shotgun, Autotest, etc."
10
+ s.summary = "Launches an app, and restarts it whenever the filesystem changes. A no-frills, command-line alternative to Guard, Shotgun, Autotest, etc."
11
+
12
+ s.authors = ["Alex Chaffee", "Patrik Ragnarsson"]
13
+ s.email = ["alexch@gmail.com", "patrik@starkast.net"]
14
+
15
+ s.files = %w[
16
+ README.md
17
+ Changelog.md
18
+ Fork.md
19
+ LICENSE
20
+ Rakefile
21
+ relaunch.gemspec
22
+ bin/rerun
23
+ bin/rerun.bat
24
+ bin/relaunch
25
+ bin/relaunch.bat
26
+ icons/rails_grn_sml.png
27
+ icons/rails_red_sml.png] +
28
+ Dir['lib/**/*.rb']
29
+ s.executables = ['rerun', 'relaunch']
30
+ s.test_files = s.files.select {|path| path =~ /^spec\/.*_spec.rb/}
31
+
32
+ s.extra_rdoc_files = %w[README.md]
33
+
34
+ s.add_runtime_dependency 'listen', '~> 3.0'
35
+
36
+ s.homepage = "https://github.com/spinels/relaunch"
37
+ s.metadata["homepage_uri"] = s.homepage
38
+ s.metadata["changelog_uri"] = "#{s.homepage}/blob/main/Changelog.md"
39
+ s.metadata["source_code_uri"] = "#{s.homepage}/tree/main"
40
+ s.metadata["rubygems_mfa_required"] = "true"
41
+ s.require_paths = %w[lib]
42
+
43
+ s.license = 'MIT'
44
+ end
metadata CHANGED
@@ -1,24 +1,69 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: relaunch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
- - Placeholder
7
+ - Alex Chaffee
8
+ - Patrik Ragnarsson
8
9
  bindir: bin
9
10
  cert_chain: []
10
11
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
12
- executables: []
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: listen
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
27
+ description: Restarts your app when a file changes. A no-frills, command-line alternative
28
+ to Guard, Shotgun, Autotest, etc.
29
+ email:
30
+ - alexch@gmail.com
31
+ - patrik@starkast.net
32
+ executables:
33
+ - relaunch
34
+ - rerun
13
35
  extensions: []
14
- extra_rdoc_files: []
36
+ extra_rdoc_files:
37
+ - README.md
15
38
  files:
39
+ - Changelog.md
40
+ - Fork.md
41
+ - LICENSE
42
+ - README.md
43
+ - Rakefile
44
+ - bin/relaunch
45
+ - bin/relaunch.bat
46
+ - bin/rerun
47
+ - bin/rerun.bat
48
+ - icons/rails_grn_sml.png
49
+ - icons/rails_red_sml.png
16
50
  - lib/relaunch.rb
17
- homepage: https://github.com/spinels/elsewhere
51
+ - lib/rerun.rb
52
+ - lib/rerun/glob.rb
53
+ - lib/rerun/notification.rb
54
+ - lib/rerun/options.rb
55
+ - lib/rerun/runner.rb
56
+ - lib/rerun/system.rb
57
+ - lib/rerun/watcher.rb
58
+ - relaunch.gemspec
59
+ homepage: https://github.com/spinels/relaunch
18
60
  licenses:
19
61
  - MIT
20
- metadata: {}
21
- post_install_message: Coming soon.
62
+ metadata:
63
+ homepage_uri: https://github.com/spinels/relaunch
64
+ changelog_uri: https://github.com/spinels/relaunch/blob/main/Changelog.md
65
+ source_code_uri: https://github.com/spinels/relaunch/tree/main
66
+ rubygems_mfa_required: 'true'
22
67
  rdoc_options: []
23
68
  require_paths:
24
69
  - lib
@@ -26,14 +71,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
26
71
  requirements:
27
72
  - - ">="
28
73
  - !ruby/object:Gem::Version
29
- version: '3.0'
74
+ version: '3.3'
30
75
  required_rubygems_version: !ruby/object:Gem::Requirement
31
76
  requirements:
32
77
  - - ">="
33
78
  - !ruby/object:Gem::Version
34
79
  version: '0'
35
80
  requirements: []
36
- rubygems_version: 4.0.19
37
- specification_version: 4
38
- summary: Placeholder gem
81
+ rubygems_version: 4.0.16
82
+ specification_version: 2
83
+ summary: Launches an app, and restarts it whenever the filesystem changes. A no-frills,
84
+ command-line alternative to Guard, Shotgun, Autotest, etc.
39
85
  test_files: []