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