consolle 0.2.6

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,556 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pty"
4
+ require "timeout"
5
+ require "fcntl"
6
+ require "logger"
7
+
8
+ # Ruby 3.4.0+ extracts base64 as a default gem
9
+ # Suppress warning by silencing verbose mode temporarily
10
+ original_verbose = $VERBOSE
11
+ $VERBOSE = nil
12
+ require "base64"
13
+ $VERBOSE = original_verbose
14
+
15
+ module Consolle
16
+ module Server
17
+ class ConsoleSupervisor
18
+ attr_reader :pid, :reader, :writer, :rails_root, :rails_env, :logger
19
+
20
+ RESTART_DELAY = 1 # seconds
21
+ MAX_RESTARTS = 5 # within 5 minutes
22
+ RESTART_WINDOW = 300 # 5 minutes
23
+ # Match various Rails console prompts
24
+ # Match various console prompts: custom sentinel, Rails app prompts, IRB prompts, and generic prompts
25
+ # Allow optional non-word characters before the prompt (e.g., Unicode symbols like ▽)
26
+ PROMPT_PATTERN = /^[^\w]*(\u001E\u001F<CONSOLLE>\u001F\u001E|\w+[-_]?\w*\([^)]*\)>|irb\([^)]+\):[\d]+:?[\d]*[>*]|>>|>)\s*$/
27
+ CTRL_C = "\x03"
28
+
29
+ def initialize(rails_root:, rails_env: "development", logger: nil, command: nil)
30
+ @rails_root = rails_root
31
+ @rails_env = rails_env
32
+ @command = command || "bin/rails console"
33
+ @logger = logger || Logger.new(STDOUT)
34
+ @pid = nil
35
+ @reader = nil
36
+ @writer = nil
37
+ @running = false
38
+ @restart_timestamps = []
39
+ @watchdog_thread = nil
40
+ @mutex = Mutex.new
41
+ @process_mutex = Mutex.new # Separate mutex for process lifecycle management
42
+
43
+ spawn_console
44
+ start_watchdog
45
+ end
46
+
47
+ def eval(code, timeout: 30)
48
+ @mutex.synchronize do
49
+ raise "Console is not running" unless running?
50
+
51
+ # Check if this is a remote console
52
+ is_remote = @command.include?("ssh") || @command.include?("kamal") || @command.include?("docker")
53
+
54
+ if is_remote
55
+ # Send Ctrl-C to ensure clean state before execution
56
+ @writer.write(CTRL_C)
57
+ @writer.flush
58
+
59
+ # Clear any pending output
60
+ clear_buffer
61
+
62
+ # Wait for prompt after Ctrl-C
63
+ begin
64
+ wait_for_prompt(timeout: 1, consume_all: true)
65
+ rescue Timeout::Error
66
+ # Continue anyway, some consoles may not show prompt immediately
67
+ end
68
+ else
69
+ # For local consoles, just clear buffer
70
+ clear_buffer
71
+ end
72
+
73
+ # Encode code using Base64 to handle special characters and remote consoles
74
+ # Ensure UTF-8 encoding to handle strings that may be tagged as ASCII-8BIT
75
+ utf8_code = code.encoding == Encoding::UTF_8 ? code : code.dup.force_encoding('UTF-8')
76
+ encoded_code = Base64.strict_encode64(utf8_code)
77
+
78
+ # Use eval to execute the Base64-decoded code
79
+ eval_command = "eval(Base64.decode64('#{encoded_code}'), IRB.CurrentContext.workspace.binding)"
80
+ logger.debug "[ConsoleSupervisor] Sending eval command (Base64 encoded)"
81
+ @writer.puts eval_command
82
+ @writer.flush
83
+
84
+ # Collect output
85
+ output = +""
86
+ deadline = Time.now + timeout
87
+
88
+ begin
89
+ loop do
90
+ if Time.now > deadline
91
+ # Timeout - send Ctrl-C
92
+ @writer.write(CTRL_C)
93
+ @writer.flush
94
+ sleep 0.5
95
+ clear_buffer
96
+ return {
97
+ success: false,
98
+ output: "Execution timed out after #{timeout} seconds",
99
+ execution_time: timeout
100
+ }
101
+ end
102
+
103
+ begin
104
+ chunk = @reader.read_nonblock(4096)
105
+ output << chunk
106
+
107
+ # Check if we got prompt back
108
+ clean = strip_ansi(output)
109
+ if clean.match?(PROMPT_PATTERN)
110
+ # Wait a bit for any trailing output
111
+ sleep 0.1
112
+ begin
113
+ output << @reader.read_nonblock(4096)
114
+ rescue IO::WaitReadable, Errno::EIO
115
+ # No more data
116
+ end
117
+ break
118
+ end
119
+ rescue IO::WaitReadable
120
+ IO.select([@reader], nil, nil, 0.1)
121
+ rescue Errno::EIO
122
+ # PTY can throw EIO when no data available
123
+ IO.select([@reader], nil, nil, 0.1)
124
+ rescue EOFError
125
+ return {
126
+ success: false,
127
+ output: "Console terminated",
128
+ execution_time: nil
129
+ }
130
+ end
131
+ end
132
+
133
+ # Parse and return result
134
+ result = parse_output(output, eval_command)
135
+
136
+ # Log for debugging object output issues
137
+ logger.debug "[ConsoleSupervisor] Raw output: #{output.inspect}"
138
+ logger.debug "[ConsoleSupervisor] Parsed result: #{result.inspect}"
139
+
140
+ { success: true, output: result, execution_time: nil }
141
+ rescue StandardError => e
142
+ logger.error "[ConsoleSupervisor] Eval error: #{e.message}"
143
+ { success: false, output: "Error: #{e.message}", execution_time: nil }
144
+ end
145
+ end
146
+ end
147
+
148
+ def running?
149
+ return false unless @pid
150
+
151
+ begin
152
+ Process.kill(0, @pid)
153
+ true
154
+ rescue Errno::ESRCH
155
+ false
156
+ end
157
+ end
158
+
159
+ def stop
160
+ @running = false
161
+
162
+ # Stop watchdog first to prevent it from restarting the process
163
+ @watchdog_thread&.kill
164
+ @watchdog_thread&.join(1)
165
+
166
+ # Use process mutex for clean shutdown
167
+ @process_mutex.synchronize do
168
+ stop_console
169
+ end
170
+
171
+ logger.info "[ConsoleSupervisor] Stopped"
172
+ end
173
+
174
+ def restart
175
+ logger.info "[ConsoleSupervisor] Restarting Rails console subprocess..."
176
+
177
+ @process_mutex.synchronize do
178
+ stop_console
179
+ spawn_console
180
+ end
181
+
182
+ @pid
183
+ end
184
+
185
+ private
186
+
187
+ def spawn_console
188
+ env = {
189
+ "RAILS_ENV" => @rails_env,
190
+
191
+ # Skip IRB configuration file (prevent conflicts with existing settings)
192
+ "IRBRC" => "skip",
193
+
194
+ # Disable pry-rails (force IRB instead of Pry)
195
+ "DISABLE_PRY_RAILS" => "1",
196
+
197
+ # Completely disable pager
198
+ "PAGER" => "cat", # Set pager to cat (immediate output)
199
+ "NO_PAGER" => "1", # Pager disable flag
200
+ "LESS" => "", # Clear less pager options
201
+
202
+ # Terminal settings (minimal features only)
203
+ "TERM" => "dumb", # Set to dumb terminal (minimize color/pager features)
204
+
205
+ # Disable Rails/ActiveSupport log colors
206
+ "FORCE_COLOR" => "0", # Force disable colors
207
+ "NO_COLOR" => "1", # Completely disable color output
208
+
209
+ # Disable other interactive features
210
+ "COLUMNS" => "120", # Fixed column count (prevent auto-detection)
211
+ "LINES" => "24" # Fixed line count (prevent auto-detection)
212
+ }
213
+ logger.info "[ConsoleSupervisor] Spawning console with command: #{@command} (#{@rails_env})"
214
+
215
+ @reader, @writer, @pid = PTY.spawn(env, @command, chdir: @rails_root)
216
+
217
+ # Non-blocking mode
218
+ @reader.sync = @writer.sync = true
219
+ flags = @reader.fcntl(Fcntl::F_GETFL, 0)
220
+ @reader.fcntl(Fcntl::F_SETFL, flags | Fcntl::O_NONBLOCK)
221
+
222
+ @running = true
223
+
224
+ # Record restart timestamp
225
+ @restart_timestamps << Time.now
226
+ trim_restart_history
227
+
228
+ # Wait for initial prompt
229
+ wait_for_prompt(timeout: 15)
230
+
231
+ # Configure IRB settings for automation
232
+ configure_irb_for_automation
233
+
234
+ # For remote consoles (like kamal), we need more aggressive initialization
235
+ # Check if this looks like a remote console based on the command
236
+ is_remote = @command.include?("ssh") || @command.include?("kamal") || @command.include?("docker")
237
+
238
+ if is_remote
239
+ # Send Ctrl-C to ensure clean state
240
+ @writer.write(CTRL_C)
241
+ @writer.flush
242
+
243
+ # Wait for prompt after Ctrl-C
244
+ begin
245
+ wait_for_prompt(timeout: 2, consume_all: true)
246
+ rescue Timeout::Error
247
+ logger.warn "[ConsoleSupervisor] No prompt after Ctrl-C, continuing anyway"
248
+ end
249
+
250
+ # Send a unique marker command to ensure all initialization output is consumed
251
+ marker = "__consolle_init_#{Time.now.to_f}__"
252
+ @writer.puts "puts '#{marker}'"
253
+ @writer.flush
254
+
255
+ # Read until we see our marker
256
+ output = +""
257
+ deadline = Time.now + 3
258
+ marker_found = false
259
+
260
+ while Time.now < deadline && !marker_found
261
+ begin
262
+ chunk = @reader.read_nonblock(4096)
263
+ output << chunk
264
+ marker_found = output.include?(marker)
265
+ rescue IO::WaitReadable
266
+ IO.select([@reader], nil, nil, 0.1)
267
+ rescue Errno::EIO
268
+ IO.select([@reader], nil, nil, 0.1)
269
+ end
270
+ end
271
+
272
+ unless marker_found
273
+ logger.warn "[ConsoleSupervisor] Initialization marker not found, continuing anyway"
274
+ end
275
+
276
+ # Final cleanup for remote consoles
277
+ @writer.write(CTRL_C)
278
+ @writer.flush
279
+ clear_buffer
280
+ else
281
+ # For local consoles, minimal cleanup is sufficient
282
+ clear_buffer
283
+ end
284
+
285
+ logger.info "[ConsoleSupervisor] Rails console started (PID: #{@pid})"
286
+ rescue StandardError => e
287
+ logger.error "[ConsoleSupervisor] Failed to spawn console: #{e.message}"
288
+ raise
289
+ end
290
+
291
+ def start_watchdog
292
+ @watchdog_thread = Thread.new do
293
+ Thread.current[:consolle_watchdog] = true # Tag thread for test cleanup
294
+ while @running
295
+ begin
296
+ sleep 0.5
297
+
298
+ # Use process mutex to avoid race conditions with restart
299
+ @process_mutex.synchronize do
300
+ # Check if process is still alive
301
+ dead_pid = Process.waitpid(@pid, Process::WNOHANG) rescue nil
302
+
303
+ if dead_pid || !running?
304
+ if @running # Only restart if we're supposed to be running
305
+ logger.warn "[ConsoleSupervisor] Console process died (PID: #{@pid}), restarting..."
306
+
307
+ # Wait before restart
308
+ sleep RESTART_DELAY
309
+
310
+ # Respawn
311
+ spawn_console
312
+ end
313
+ end
314
+ end
315
+ rescue Errno::ECHILD
316
+ # Process already reaped
317
+ @process_mutex.synchronize do
318
+ if @running
319
+ logger.warn "[ConsoleSupervisor] Console process missing, restarting..."
320
+ sleep RESTART_DELAY
321
+ spawn_console
322
+ end
323
+ end
324
+ rescue StandardError => e
325
+ logger.error "[ConsoleSupervisor] Watchdog error: #{e.message}"
326
+ end
327
+ end
328
+
329
+ logger.info "[ConsoleSupervisor] Watchdog thread stopped"
330
+ end
331
+ end
332
+
333
+ def wait_for_prompt(timeout: 15, consume_all: false)
334
+ output = +""
335
+ deadline = Time.now + timeout
336
+ prompt_found = false
337
+ last_data_time = Time.now
338
+
339
+ loop do
340
+ if Time.now > deadline
341
+ logger.error "[ConsoleSupervisor] Output so far: #{output.inspect}"
342
+ logger.error "[ConsoleSupervisor] Stripped: #{strip_ansi(output).inspect}"
343
+ raise Timeout::Error, "No prompt after #{timeout} seconds"
344
+ end
345
+
346
+ # If we found prompt and consume_all is true, continue reading for a bit more
347
+ if prompt_found && consume_all
348
+ if Time.now - last_data_time > 0.5
349
+ logger.info "[ConsoleSupervisor] No more data for 0.5s after prompt, stopping"
350
+ return true
351
+ end
352
+ elsif prompt_found
353
+ return true
354
+ end
355
+
356
+ begin
357
+ chunk = @reader.read_nonblock(4096)
358
+ output << chunk
359
+ last_data_time = Time.now
360
+ logger.debug "[ConsoleSupervisor] Got chunk: #{chunk.inspect}"
361
+
362
+ clean = strip_ansi(output)
363
+ # Check each line for prompt pattern
364
+ clean.lines.each do |line|
365
+ if line.match?(PROMPT_PATTERN)
366
+ logger.info "[ConsoleSupervisor] Found prompt!"
367
+ prompt_found = true
368
+ end
369
+ end
370
+ rescue IO::WaitReadable
371
+ IO.select([@reader], nil, nil, 0.1)
372
+ rescue Errno::EIO
373
+ # PTY can throw EIO when no data available
374
+ IO.select([@reader], nil, nil, 0.1)
375
+ end
376
+ end
377
+ end
378
+
379
+ def clear_buffer
380
+ # Try to clear buffer multiple times for remote consoles
381
+ 3.times do |i|
382
+ begin
383
+ loop do
384
+ @reader.read_nonblock(4096)
385
+ end
386
+ rescue IO::WaitReadable, Errno::EIO
387
+ # Buffer cleared for this iteration
388
+ end
389
+ sleep 0.05 if i < 2 # Sleep between iterations except the last
390
+ end
391
+ end
392
+
393
+ def configure_irb_for_automation
394
+ # Create the invisible-wrapper sentinel prompt
395
+ sentinel_prompt = "\u001E\u001F<CONSOLLE>\u001F\u001E "
396
+
397
+ # Send IRB configuration commands to disable interactive features
398
+ irb_commands = [
399
+ # Configure custom prompt mode to eliminate continuation prompts
400
+ "IRB.conf[:PROMPT][:CONSOLLE] = { " \
401
+ "AUTO_INDENT: false, " \
402
+ "PROMPT_I: #{sentinel_prompt.inspect}, " \
403
+ "PROMPT_N: '', " \
404
+ "PROMPT_S: '', " \
405
+ "PROMPT_C: '', " \
406
+ "RETURN: \"=> %s\\n\" }",
407
+ "IRB.conf[:PROMPT_MODE] = :CONSOLLE",
408
+
409
+ # Disable interactive features
410
+ "IRB.conf[:USE_PAGER] = false", # Disable pager
411
+ "IRB.conf[:USE_COLORIZE] = false", # Disable color output
412
+ "IRB.conf[:USE_AUTOCOMPLETE] = false", # Disable autocompletion
413
+ "IRB.conf[:USE_MULTILINE] = false", # Disable multiline editor to process code at once
414
+ "ActiveSupport::LogSubscriber.colorize_logging = false if defined?(ActiveSupport::LogSubscriber)" # Disable Rails logging colors
415
+ ]
416
+
417
+ irb_commands.each do |cmd|
418
+ begin
419
+ @writer.puts cmd
420
+ @writer.flush
421
+
422
+ # Wait briefly for command to execute
423
+ sleep 0.1
424
+
425
+ # Clear any output from the configuration command (these commands typically don't produce visible output)
426
+ clear_buffer
427
+ rescue StandardError => e
428
+ logger.warn "[ConsoleSupervisor] Failed to configure IRB setting: #{cmd} - #{e.message}"
429
+ end
430
+ end
431
+
432
+ # Send multiple empty lines to ensure all settings are processed
433
+ # This is especially important for remote consoles like kamal console
434
+ 2.times do
435
+ @writer.puts
436
+ @writer.flush
437
+ sleep 0.05
438
+ end
439
+
440
+ # Clear buffer again after sending empty lines
441
+ clear_buffer
442
+
443
+ # Wait for prompt after configuration with reasonable timeout
444
+ begin
445
+ wait_for_prompt(timeout: 2, consume_all: false)
446
+ rescue Timeout::Error
447
+ # This can fail with some console types, but that's okay
448
+ logger.debug "[ConsoleSupervisor] No prompt after IRB configuration, continuing"
449
+ end
450
+
451
+ # Final buffer clear
452
+ clear_buffer
453
+
454
+ logger.debug "[ConsoleSupervisor] IRB configured for automation"
455
+ end
456
+
457
+ def strip_ansi(text)
458
+ # Remove all ANSI escape sequences
459
+ text
460
+ .gsub(/\e\[[\d;]*[a-zA-Z]/, "") # Standard ANSI codes
461
+ .gsub(/\e\[\?[\d]+[hl]/, "") # Private mode codes like [?2004h
462
+ .gsub(/\e[<>=]/, "") # Other escape sequences
463
+ .gsub(/[\x00-\x08\x0B-\x0C\x0E-\x1D\x7F]/, "") # Control chars except \t(09) \n(0A) \r(0D) \u001E(1E) \u001F(1F)
464
+ .gsub(/\r\n/, "\n") # Normalize line endings
465
+ end
466
+
467
+
468
+ def parse_output(output, code)
469
+ # Remove ANSI codes
470
+ clean = strip_ansi(output)
471
+
472
+ # Split into lines
473
+ lines = clean.lines
474
+ result_lines = []
475
+ skip_echo = true
476
+
477
+ lines.each_with_index do |line, idx|
478
+ # Skip the eval command echo (both file-based and Base64)
479
+ if skip_echo && (line.include?("eval(File.read") || line.include?("eval(Base64.decode64"))
480
+ skip_echo = false
481
+ next
482
+ end
483
+
484
+ # Skip prompts (but not return values that start with =>)
485
+ if line.match?(PROMPT_PATTERN) && !line.start_with?("=>")
486
+ next
487
+ end
488
+
489
+ # Skip common IRB configuration output patterns
490
+ if line.match?(/^(IRB\.conf|DISABLE_PRY_RAILS|Switch to inspect mode|Loading .*\.rb|nil)$/) ||
491
+ line.match?(/^__consolle_init_[\d.]+__$/) ||
492
+ line.match?(/^'consolle_init'$/) ||
493
+ line.strip == "false" && idx == 0 # Skip leading false from IRB config
494
+ next
495
+ end
496
+
497
+ # Collect all other lines (including return values and side effects)
498
+ result_lines << line
499
+ end
500
+
501
+ # Join all lines - this includes both side effects and return values
502
+ result = result_lines.join.strip
503
+
504
+ result
505
+ end
506
+
507
+ def trim_restart_history
508
+ # Keep only restarts within the window
509
+ cutoff = Time.now - RESTART_WINDOW
510
+ @restart_timestamps.keep_if { |t| t > cutoff }
511
+
512
+ # Check if too many restarts
513
+ if @restart_timestamps.size > MAX_RESTARTS
514
+ logger.error "[ConsoleSupervisor] Too many restarts (#{@restart_timestamps.size} in #{RESTART_WINDOW}s)"
515
+ # TODO: Send alert to ops team
516
+ end
517
+ end
518
+
519
+ def stop_console
520
+ return unless running?
521
+
522
+ begin
523
+ @writer.puts("exit")
524
+ @writer.flush
525
+ rescue StandardError
526
+ # PTY might be closed already
527
+ end
528
+
529
+ # Wait for process to exit gracefully
530
+ waited = Process.waitpid(@pid, Process::WNOHANG) rescue nil
531
+ unless waited
532
+ # Give it up to 3 seconds to exit gracefully
533
+ 30.times do
534
+ sleep 0.1
535
+ break unless running?
536
+ waited = Process.waitpid(@pid, Process::WNOHANG) rescue nil
537
+ break if waited
538
+ end
539
+ end
540
+
541
+ # Force kill if still running
542
+ if running? && !waited
543
+ Process.kill("TERM", @pid) rescue nil
544
+ sleep 0.5
545
+ Process.kill("KILL", @pid) rescue nil if running?
546
+ end
547
+ rescue Errno::ECHILD
548
+ # Process already gone
549
+ ensure
550
+ # Close PTY file descriptors
551
+ @reader&.close rescue nil
552
+ @writer&.close rescue nil
553
+ end
554
+ end
555
+ end
556
+ end