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.
- checksums.yaml +7 -0
- data/.github/workflows/test.yml +52 -0
- data/.gitignore +6 -0
- data/.mise.toml +9 -0
- data/.rspec +4 -0
- data/.version +1 -0
- data/CHANGELOG.md +29 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +39 -0
- data/LICENSE +9 -0
- data/README.md +190 -0
- data/bin/cone +6 -0
- data/bin/consolle +6 -0
- data/consolle.gemspec +40 -0
- data/lib/consolle/adapters/rails_console.rb +295 -0
- data/lib/consolle/cli.rb +718 -0
- data/lib/consolle/server/console_socket_server.rb +201 -0
- data/lib/consolle/server/console_supervisor.rb +556 -0
- data/lib/consolle/server/request_broker.rb +247 -0
- data/lib/consolle/version.rb +5 -0
- data/lib/consolle.rb +13 -0
- data/mise/release.sh +120 -0
- data/rule.ko.md +117 -0
- data/rule.md +117 -0
- metadata +115 -0
|
@@ -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
|