consolle 0.3.6 → 0.3.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.lock +1 -1
- data/lib/consolle/adapters/rails_console.rb +12 -8
- data/lib/consolle/cli.rb +30 -13
- data/lib/consolle/config.rb +104 -0
- data/lib/consolle/errors.rb +66 -1
- data/lib/consolle/server/base_supervisor.rb +92 -0
- data/lib/consolle/server/console_socket_server.rb +13 -3
- data/lib/consolle/server/console_supervisor.rb +30 -10
- data/lib/consolle/server/embedded_supervisor.rb +258 -0
- data/lib/consolle/server/supervisor_factory.rb +86 -0
- data/lib/consolle.rb +1 -0
- data/rule.ko.md +77 -3
- data/rule.md +77 -3
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0975195db50059158fa8643203c06099ea77c53a6ee199705731244806d7b2d8'
|
|
4
|
+
data.tar.gz: 8d1fc462e0dc7acd21b9f53989f892ffdf03920b1bc327c82fc70516fda8e201
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a281509268f01cdbb60e1a538e9c037c82cce14f26aebd1faf30befeee6aeba6996eaa94f37fa70f13949f0a1d5d749db7604fae39253154d61ec14e6db637ff
|
|
7
|
+
data.tar.gz: 58c2126e05ca127992001c57a597a095b3c4aa193c36b934de5490d3eddb95bc85770c7bd7e1905c3fdd2b4e95bc68d77f9518d35a22cd8f3619f71c4de15638
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
1
|
+
0.3.8
|
data/Gemfile.lock
CHANGED
|
@@ -13,7 +13,7 @@ module Consolle
|
|
|
13
13
|
attr_reader :socket_path, :process_pid, :pid_path, :log_path
|
|
14
14
|
|
|
15
15
|
def initialize(socket_path: nil, pid_path: nil, log_path: nil, rails_root: nil, rails_env: nil, verbose: false,
|
|
16
|
-
command: nil, wait_timeout: nil)
|
|
16
|
+
command: nil, wait_timeout: nil, mode: nil)
|
|
17
17
|
@socket_path = socket_path || default_socket_path
|
|
18
18
|
@pid_path = pid_path || default_pid_path
|
|
19
19
|
@log_path = log_path || default_log_path
|
|
@@ -22,6 +22,7 @@ module Consolle
|
|
|
22
22
|
@verbose = verbose
|
|
23
23
|
@command = command || 'bin/rails console'
|
|
24
24
|
@wait_timeout = wait_timeout || Consolle::DEFAULT_WAIT_TIMEOUT
|
|
25
|
+
@mode = mode # nil means use config file setting
|
|
25
26
|
@server_pid = nil
|
|
26
27
|
end
|
|
27
28
|
|
|
@@ -149,7 +150,8 @@ module Consolle
|
|
|
149
150
|
@pid_path,
|
|
150
151
|
@log_path,
|
|
151
152
|
@command,
|
|
152
|
-
@wait_timeout.to_s
|
|
153
|
+
@wait_timeout.to_s,
|
|
154
|
+
@mode.to_s # empty string if nil (use config file)
|
|
153
155
|
]
|
|
154
156
|
end
|
|
155
157
|
|
|
@@ -159,8 +161,9 @@ module Consolle
|
|
|
159
161
|
require 'consolle/server/console_socket_server'
|
|
160
162
|
require 'logger'
|
|
161
163
|
#{' '}
|
|
162
|
-
socket_path, rails_root, rails_env, log_level, pid_path, log_path, command, wait_timeout_str = ARGV
|
|
164
|
+
socket_path, rails_root, rails_env, log_level, pid_path, log_path, command, wait_timeout_str, mode_str = ARGV
|
|
163
165
|
wait_timeout = wait_timeout_str ? wait_timeout_str.to_i : nil
|
|
166
|
+
mode = mode_str && !mode_str.empty? ? mode_str.to_sym : nil # nil = use config file
|
|
164
167
|
#{' '}
|
|
165
168
|
# Write initial log
|
|
166
169
|
log_file = log_path || socket_path.sub(/\\.socket$/, '.log')
|
|
@@ -191,10 +194,11 @@ module Consolle
|
|
|
191
194
|
rails_env: rails_env,
|
|
192
195
|
logger: logger,
|
|
193
196
|
command: command,
|
|
194
|
-
wait_timeout: wait_timeout
|
|
197
|
+
wait_timeout: wait_timeout,
|
|
198
|
+
mode: mode
|
|
195
199
|
)
|
|
196
200
|
#{' '}
|
|
197
|
-
puts "[Server] Starting server with log level: \#{log_level}..."
|
|
201
|
+
puts "[Server] Starting server with log level: \#{log_level}, mode: \#{mode || 'config'}..."
|
|
198
202
|
server.start
|
|
199
203
|
#{' '}
|
|
200
204
|
puts "[Server] Server started, entering sleep..."
|
|
@@ -204,17 +208,17 @@ module Consolle
|
|
|
204
208
|
rescue => e
|
|
205
209
|
puts "[Server] Error: \#{e.class}: \#{e.message}"
|
|
206
210
|
puts e.backtrace.join("\\n")
|
|
207
|
-
|
|
211
|
+
|
|
208
212
|
# Clean up socket file if it exists
|
|
209
213
|
if defined?(socket_path) && socket_path && File.exist?(socket_path)
|
|
210
214
|
File.unlink(socket_path) rescue nil
|
|
211
215
|
end
|
|
212
|
-
|
|
216
|
+
|
|
213
217
|
# Clean up PID file if it exists
|
|
214
218
|
if defined?(pid_file) && pid_file && File.exist?(pid_file)
|
|
215
219
|
File.unlink(pid_file) rescue nil
|
|
216
220
|
end
|
|
217
|
-
|
|
221
|
+
|
|
218
222
|
exit 1
|
|
219
223
|
end
|
|
220
224
|
RUBY
|
data/lib/consolle/cli.rb
CHANGED
|
@@ -124,15 +124,25 @@ module Consolle
|
|
|
124
124
|
RAILS_ENV=production cone start --target api
|
|
125
125
|
RAILS_ENV=development cone start --target worker
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
Supervisor modes (--mode):
|
|
128
|
+
pty - Traditional PTY-based mode, supports custom commands (default)
|
|
129
|
+
embed-irb - Pure IRB embedding, Ruby 3.3+ only
|
|
130
|
+
embed-rails - Rails console embedding, Ruby 3.3+ only
|
|
131
|
+
|
|
132
|
+
Custom console commands are supported for PTY mode:
|
|
128
133
|
cone start --command "kamal app exec -i 'bin/rails console'"
|
|
129
134
|
cone start --command "docker exec -it myapp bin/rails console"
|
|
130
|
-
|
|
135
|
+
|
|
131
136
|
For SSH-based commands that require authentication (e.g., 1Password SSH agent):
|
|
132
137
|
cone start --command "kamal console" --wait-timeout 60
|
|
138
|
+
|
|
139
|
+
Use embedded modes for faster local execution (200x faster):
|
|
140
|
+
cone start --mode embed-rails
|
|
141
|
+
cone start --mode embed-irb
|
|
133
142
|
LONGDESC
|
|
134
143
|
# Rails environment is now controlled via RAILS_ENV, not a CLI option
|
|
135
|
-
method_option :
|
|
144
|
+
method_option :mode, type: :string, aliases: '-m', desc: 'Supervisor mode: pty, embed-irb, embed-rails'
|
|
145
|
+
method_option :command, type: :string, aliases: '-c', desc: 'Custom console command (PTY mode only)', default: 'bin/rails console'
|
|
136
146
|
method_option :wait_timeout, type: :numeric, aliases: '-w', desc: 'Timeout for console startup (seconds)', default: Consolle::DEFAULT_WAIT_TIMEOUT
|
|
137
147
|
def start
|
|
138
148
|
ensure_rails_project!
|
|
@@ -160,7 +170,13 @@ module Consolle
|
|
|
160
170
|
clear_session_info
|
|
161
171
|
end
|
|
162
172
|
|
|
163
|
-
adapter = create_rails_adapter(
|
|
173
|
+
adapter = create_rails_adapter(
|
|
174
|
+
current_rails_env,
|
|
175
|
+
options[:target],
|
|
176
|
+
options[:command],
|
|
177
|
+
options[:wait_timeout],
|
|
178
|
+
options[:mode]
|
|
179
|
+
)
|
|
164
180
|
|
|
165
181
|
puts 'Starting Rails console...'
|
|
166
182
|
|
|
@@ -418,7 +434,7 @@ module Consolle
|
|
|
418
434
|
|
|
419
435
|
stop
|
|
420
436
|
sleep 1
|
|
421
|
-
invoke(:start)
|
|
437
|
+
invoke(:start, [], {})
|
|
422
438
|
else
|
|
423
439
|
puts 'Restarting Rails console subprocess...'
|
|
424
440
|
|
|
@@ -557,8 +573,8 @@ module Consolle
|
|
|
557
573
|
if result['success']
|
|
558
574
|
# Always print result, even if empty (multiline code often returns empty string)
|
|
559
575
|
puts result['result'] unless result['result'].nil?
|
|
560
|
-
#
|
|
561
|
-
puts "Execution time: #{result['execution_time'].round(3)}s" if result['execution_time']
|
|
576
|
+
# Show execution time only in verbose mode
|
|
577
|
+
puts "Execution time: #{result['execution_time'].round(3)}s" if options[:verbose] && result['execution_time']
|
|
562
578
|
else
|
|
563
579
|
# Display error information
|
|
564
580
|
if result['error_code']
|
|
@@ -574,10 +590,10 @@ module Consolle
|
|
|
574
590
|
|
|
575
591
|
puts result['message']
|
|
576
592
|
puts result['backtrace']&.join("\n") if options[:verbose] && result['backtrace']
|
|
577
|
-
|
|
578
|
-
# Show execution time for errors too
|
|
579
|
-
puts "Execution time: #{result['execution_time'].round(3)}s" if result['execution_time']
|
|
580
|
-
|
|
593
|
+
|
|
594
|
+
# Show execution time for errors too (verbose only)
|
|
595
|
+
puts "Execution time: #{result['execution_time'].round(3)}s" if options[:verbose] && result['execution_time']
|
|
596
|
+
|
|
581
597
|
exit 1
|
|
582
598
|
end
|
|
583
599
|
end
|
|
@@ -702,7 +718,7 @@ module Consolle
|
|
|
702
718
|
File.join(Dir.pwd, 'tmp', 'cone', 'sessions.json')
|
|
703
719
|
end
|
|
704
720
|
|
|
705
|
-
def create_rails_adapter(rails_env = 'development', target = nil, command = nil, wait_timeout = nil)
|
|
721
|
+
def create_rails_adapter(rails_env = 'development', target = nil, command = nil, wait_timeout = nil, mode = nil)
|
|
706
722
|
target ||= options[:target]
|
|
707
723
|
|
|
708
724
|
Consolle::Adapters::RailsConsole.new(
|
|
@@ -713,7 +729,8 @@ module Consolle
|
|
|
713
729
|
rails_env: rails_env,
|
|
714
730
|
verbose: options[:verbose],
|
|
715
731
|
command: command,
|
|
716
|
-
wait_timeout: wait_timeout
|
|
732
|
+
wait_timeout: wait_timeout,
|
|
733
|
+
mode: mode
|
|
717
734
|
)
|
|
718
735
|
end
|
|
719
736
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Consolle
|
|
6
|
+
# Configuration loader for .consolle.yml
|
|
7
|
+
class Config
|
|
8
|
+
CONFIG_FILENAME = '.consolle.yml'
|
|
9
|
+
|
|
10
|
+
# Default prompt pattern that matches various console prompts
|
|
11
|
+
# - Custom sentinel: \u001E\u001F<CONSOLLE>\u001F\u001E
|
|
12
|
+
# - Rails app prompts: app(env)> or app(env):001>
|
|
13
|
+
# - IRB prompts: irb(main):001:0> or irb(main):001>
|
|
14
|
+
# - Generic prompts: >> or >
|
|
15
|
+
DEFAULT_PROMPT_PATTERN = /^[^\w]*(\u001E\u001F<CONSOLLE>\u001F\u001E|\w+[-_]?\w*\([^)]*\)(:\d+)?>|irb\([^)]+\):\d+:?\d*[>*]|>>|>)\s*$/
|
|
16
|
+
|
|
17
|
+
# Valid supervisor modes
|
|
18
|
+
# - pty: PTY-based, supports custom command (local/remote)
|
|
19
|
+
# - embed-irb: Pure IRB embedding (Ruby 3.3+, local only)
|
|
20
|
+
# - embed-rails: Rails console embedding (Ruby 3.3+, local only)
|
|
21
|
+
VALID_MODES = %w[pty embed-irb embed-rails].freeze
|
|
22
|
+
DEFAULT_MODE = :pty
|
|
23
|
+
DEFAULT_COMMAND = 'bin/rails console'
|
|
24
|
+
|
|
25
|
+
attr_reader :rails_root, :prompt_pattern, :raw_prompt_pattern, :mode, :command
|
|
26
|
+
|
|
27
|
+
def initialize(rails_root)
|
|
28
|
+
@rails_root = rails_root
|
|
29
|
+
@config = load_config
|
|
30
|
+
@raw_prompt_pattern = @config['prompt_pattern']
|
|
31
|
+
@prompt_pattern = parse_prompt_pattern(@raw_prompt_pattern)
|
|
32
|
+
@mode = parse_mode(@config['mode'])
|
|
33
|
+
@command = @config['command'] || DEFAULT_COMMAND
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.load(rails_root)
|
|
37
|
+
new(rails_root)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Check if a custom prompt pattern is configured
|
|
41
|
+
def custom_prompt_pattern?
|
|
42
|
+
!@raw_prompt_pattern.nil?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Get human-readable description of expected prompt patterns
|
|
46
|
+
def prompt_pattern_description
|
|
47
|
+
if custom_prompt_pattern?
|
|
48
|
+
"Custom pattern: #{@raw_prompt_pattern}"
|
|
49
|
+
else
|
|
50
|
+
<<~DESC.strip
|
|
51
|
+
Default patterns:
|
|
52
|
+
- app(env)> or app(env):001> (Rails console)
|
|
53
|
+
- irb(main):001:0> (IRB)
|
|
54
|
+
- >> or > (Generic)
|
|
55
|
+
DESC
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def config_path
|
|
62
|
+
File.join(@rails_root, CONFIG_FILENAME)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def load_config
|
|
66
|
+
return {} unless File.exist?(config_path)
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
YAML.safe_load(File.read(config_path)) || {}
|
|
70
|
+
rescue Psych::SyntaxError => e
|
|
71
|
+
warn "[Consolle] Warning: Failed to parse #{CONFIG_FILENAME}: #{e.message}"
|
|
72
|
+
{}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_prompt_pattern(pattern_string)
|
|
77
|
+
return DEFAULT_PROMPT_PATTERN if pattern_string.nil?
|
|
78
|
+
|
|
79
|
+
begin
|
|
80
|
+
Regexp.new(pattern_string)
|
|
81
|
+
rescue RegexpError => e
|
|
82
|
+
warn "[Consolle] Warning: Invalid prompt_pattern '#{pattern_string}': #{e.message}"
|
|
83
|
+
warn "[Consolle] Using default pattern instead."
|
|
84
|
+
DEFAULT_PROMPT_PATTERN
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_mode(mode_string)
|
|
89
|
+
return DEFAULT_MODE if mode_string.nil?
|
|
90
|
+
|
|
91
|
+
# Normalize: convert underscores/symbols, handle legacy 'embedded' -> 'embed-rails'
|
|
92
|
+
normalized = mode_string.to_s.downcase.tr('_', '-')
|
|
93
|
+
normalized = 'embed-rails' if normalized == 'embedded'
|
|
94
|
+
normalized = 'pty' if normalized == 'auto'
|
|
95
|
+
|
|
96
|
+
unless VALID_MODES.include?(normalized)
|
|
97
|
+
warn "[Consolle] Warning: Invalid mode '#{mode_string}'. Using '#{DEFAULT_MODE}'."
|
|
98
|
+
return DEFAULT_MODE
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
normalized.tr('-', '_').to_sym # :pty, :embed_irb, :embed_rails
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/consolle/errors.rb
CHANGED
|
@@ -39,6 +39,56 @@ module Consolle
|
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Prompt detection failure with diagnostic information
|
|
43
|
+
class PromptDetectionError < Error
|
|
44
|
+
attr_reader :received_output, :expected_patterns, :config_path
|
|
45
|
+
|
|
46
|
+
def initialize(timeout:, received_output:, expected_patterns:, config_path:)
|
|
47
|
+
@received_output = received_output
|
|
48
|
+
@expected_patterns = expected_patterns
|
|
49
|
+
@config_path = config_path
|
|
50
|
+
|
|
51
|
+
super(build_message(timeout))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def build_message(timeout)
|
|
57
|
+
# Extract last line that looks like a prompt (ends with > or similar)
|
|
58
|
+
lines = @received_output.to_s.lines.map(&:strip).reject(&:empty?)
|
|
59
|
+
potential_prompt = lines.reverse.find { |l| l.match?(/[>*]\s*$/) } || lines.last
|
|
60
|
+
|
|
61
|
+
<<~MSG.strip
|
|
62
|
+
Prompt not detected after #{timeout} seconds
|
|
63
|
+
|
|
64
|
+
Received output:
|
|
65
|
+
#{@received_output.to_s.lines.last(5).map { |l| l.strip }.join("\n ")}
|
|
66
|
+
|
|
67
|
+
Potential prompt found:
|
|
68
|
+
#{potential_prompt.inspect}
|
|
69
|
+
|
|
70
|
+
#{@expected_patterns}
|
|
71
|
+
|
|
72
|
+
To fix this, add to #{@config_path}:
|
|
73
|
+
prompt_pattern: '#{escape_for_yaml(potential_prompt)}'
|
|
74
|
+
|
|
75
|
+
Or set environment variable:
|
|
76
|
+
CONSOLLE_PROMPT_PATTERN='#{escape_for_yaml(potential_prompt)}' cone start
|
|
77
|
+
MSG
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def escape_for_yaml(str)
|
|
81
|
+
return '' if str.nil?
|
|
82
|
+
# Escape special regex characters and create a simple pattern
|
|
83
|
+
str.to_s
|
|
84
|
+
.gsub(/\e\[[\d;]*[a-zA-Z]/, '') # Remove ANSI codes
|
|
85
|
+
.gsub(/[\x00-\x1F]/, '') # Remove control characters
|
|
86
|
+
.strip
|
|
87
|
+
.gsub(/(\d+)/, '\d+') # Replace numbers with \d+
|
|
88
|
+
.gsub(/([().\[\]{}|*+?^$\\])/, '\\\\\1') # Escape regex special chars
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
42
92
|
# Syntax error in executed code
|
|
43
93
|
class SyntaxError < ExecutionError
|
|
44
94
|
def initialize(message)
|
|
@@ -60,6 +110,20 @@ module Consolle
|
|
|
60
110
|
end
|
|
61
111
|
end
|
|
62
112
|
|
|
113
|
+
# Configuration error
|
|
114
|
+
class ConfigurationError < Error
|
|
115
|
+
def initialize(message)
|
|
116
|
+
super("Configuration error: #{message}")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Unsupported Ruby version for embedded mode
|
|
121
|
+
class UnsupportedRubyVersion < Error
|
|
122
|
+
def initialize(message)
|
|
123
|
+
super(message)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
63
127
|
# Error classifier to map exceptions to error codes
|
|
64
128
|
class ErrorClassifier
|
|
65
129
|
ERROR_CODE_MAP = {
|
|
@@ -80,7 +144,8 @@ module Consolle
|
|
|
80
144
|
'::RuntimeError' => 'RUNTIME_ERROR',
|
|
81
145
|
'StandardError' => 'STANDARD_ERROR',
|
|
82
146
|
'Exception' => 'EXCEPTION',
|
|
83
|
-
'Consolle::Errors::ServerUnhealthy' => 'SERVER_UNHEALTHY'
|
|
147
|
+
'Consolle::Errors::ServerUnhealthy' => 'SERVER_UNHEALTHY',
|
|
148
|
+
'Consolle::Errors::PromptDetectionError' => 'PROMPT_DETECTION_ERROR'
|
|
84
149
|
}.freeze
|
|
85
150
|
|
|
86
151
|
def self.to_code(exception)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Consolle
|
|
4
|
+
module Server
|
|
5
|
+
# Base interface for console supervisors
|
|
6
|
+
# Both PTY-based and embedded IRB modes inherit from this class
|
|
7
|
+
class BaseSupervisor
|
|
8
|
+
attr_reader :rails_root, :rails_env, :logger, :config
|
|
9
|
+
|
|
10
|
+
def initialize(rails_root:, rails_env: 'development', logger: nil)
|
|
11
|
+
@rails_root = rails_root
|
|
12
|
+
@rails_env = rails_env
|
|
13
|
+
@logger = logger || Logger.new($stdout)
|
|
14
|
+
@config = Consolle::Config.load(rails_root)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Execute code and return result
|
|
18
|
+
# @param code [String] Ruby code to evaluate
|
|
19
|
+
# @param timeout [Integer] Timeout in seconds
|
|
20
|
+
# @return [Hash] Result hash with :success, :output, :execution_time, etc.
|
|
21
|
+
def eval(code, timeout: 60)
|
|
22
|
+
raise NotImplementedError, "#{self.class} must implement #eval"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Check if the console is running and ready
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def running?
|
|
28
|
+
raise NotImplementedError, "#{self.class} must implement #running?"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Stop the console
|
|
32
|
+
def stop
|
|
33
|
+
raise NotImplementedError, "#{self.class} must implement #stop"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Restart the console
|
|
37
|
+
def restart
|
|
38
|
+
raise NotImplementedError, "#{self.class} must implement #restart"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns the mode name for logging/debugging
|
|
42
|
+
# @return [Symbol] :pty or :embedded
|
|
43
|
+
def mode
|
|
44
|
+
raise NotImplementedError, "#{self.class} must implement #mode"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
protected
|
|
48
|
+
|
|
49
|
+
def build_success_response(output, execution_time:, result: nil)
|
|
50
|
+
response = {
|
|
51
|
+
success: true,
|
|
52
|
+
output: output,
|
|
53
|
+
execution_time: execution_time
|
|
54
|
+
}
|
|
55
|
+
response[:result] = result if result
|
|
56
|
+
response
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_error_response(exception, execution_time: nil)
|
|
60
|
+
if exception.is_a?(String)
|
|
61
|
+
error_code = Consolle::Errors::ErrorClassifier.classify_message(exception)
|
|
62
|
+
return {
|
|
63
|
+
success: false,
|
|
64
|
+
error_class: 'RuntimeError',
|
|
65
|
+
error_code: error_code,
|
|
66
|
+
output: exception,
|
|
67
|
+
execution_time: execution_time
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
success: false,
|
|
73
|
+
error_class: exception.class.name,
|
|
74
|
+
error_code: Consolle::Errors::ErrorClassifier.to_code(exception),
|
|
75
|
+
output: "#{exception.class}: #{exception.message}",
|
|
76
|
+
execution_time: execution_time
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_timeout_response(timeout_seconds)
|
|
81
|
+
error = Consolle::Errors::ExecutionTimeout.new(timeout_seconds)
|
|
82
|
+
{
|
|
83
|
+
success: false,
|
|
84
|
+
error_class: error.class.name,
|
|
85
|
+
error_code: Consolle::Errors::ErrorClassifier.to_code(error),
|
|
86
|
+
output: error.message,
|
|
87
|
+
execution_time: timeout_seconds
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -5,6 +5,8 @@ require 'json'
|
|
|
5
5
|
require 'logger'
|
|
6
6
|
require 'fileutils'
|
|
7
7
|
require_relative 'console_supervisor'
|
|
8
|
+
require_relative 'embedded_supervisor'
|
|
9
|
+
require_relative 'supervisor_factory'
|
|
8
10
|
require_relative 'request_broker'
|
|
9
11
|
|
|
10
12
|
module Consolle
|
|
@@ -12,14 +14,15 @@ module Consolle
|
|
|
12
14
|
class ConsoleSocketServer
|
|
13
15
|
attr_reader :socket_path, :logger
|
|
14
16
|
|
|
15
|
-
def initialize(socket_path:, rails_root:, rails_env: 'development', logger: nil, command: nil, wait_timeout: nil)
|
|
17
|
+
def initialize(socket_path:, rails_root:, rails_env: 'development', logger: nil, command: nil, wait_timeout: nil, mode: nil)
|
|
16
18
|
@socket_path = socket_path
|
|
17
19
|
@rails_root = rails_root
|
|
18
20
|
@rails_env = rails_env
|
|
19
21
|
@command = command || 'bin/rails console'
|
|
20
22
|
@wait_timeout = wait_timeout
|
|
23
|
+
@mode = mode # nil = use config file, otherwise :pty, :embed_irb, :embed_rails
|
|
21
24
|
@logger = logger || begin
|
|
22
|
-
log = Logger.new(
|
|
25
|
+
log = Logger.new($stdout)
|
|
23
26
|
log.level = Logger::DEBUG
|
|
24
27
|
log
|
|
25
28
|
end
|
|
@@ -97,13 +100,20 @@ module Consolle
|
|
|
97
100
|
end
|
|
98
101
|
|
|
99
102
|
def setup_supervisor
|
|
100
|
-
|
|
103
|
+
# Load config to get default mode if not explicitly specified
|
|
104
|
+
config = Consolle::Config.load(@rails_root)
|
|
105
|
+
effective_mode = @mode || config.mode
|
|
106
|
+
|
|
107
|
+
@supervisor = SupervisorFactory.create(
|
|
101
108
|
rails_root: @rails_root,
|
|
109
|
+
mode: effective_mode,
|
|
102
110
|
rails_env: @rails_env,
|
|
103
111
|
logger: @logger,
|
|
104
112
|
command: @command,
|
|
105
113
|
wait_timeout: @wait_timeout
|
|
106
114
|
)
|
|
115
|
+
|
|
116
|
+
@logger.info "[ConsoleSocketServer] Using #{@supervisor.mode} mode"
|
|
107
117
|
end
|
|
108
118
|
|
|
109
119
|
def setup_broker
|
|
@@ -5,6 +5,7 @@ require 'timeout'
|
|
|
5
5
|
require 'fcntl'
|
|
6
6
|
require 'logger'
|
|
7
7
|
require_relative '../constants'
|
|
8
|
+
require_relative '../config'
|
|
8
9
|
require_relative '../errors'
|
|
9
10
|
|
|
10
11
|
# Ruby 3.4.0+ extracts base64 as a default gem
|
|
@@ -17,15 +18,13 @@ $VERBOSE = original_verbose
|
|
|
17
18
|
module Consolle
|
|
18
19
|
module Server
|
|
19
20
|
class ConsoleSupervisor
|
|
20
|
-
attr_reader :pid, :reader, :writer, :rails_root, :rails_env, :logger
|
|
21
|
+
attr_reader :pid, :reader, :writer, :rails_root, :rails_env, :logger, :config
|
|
21
22
|
|
|
22
23
|
RESTART_DELAY = 1 # seconds
|
|
23
24
|
MAX_RESTARTS = 5 # within 5 minutes
|
|
24
25
|
RESTART_WINDOW = 300 # 5 minutes
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
# Allow optional non-word characters before the prompt (e.g., Unicode symbols like ▽)
|
|
28
|
-
PROMPT_PATTERN = /^[^\w]*(\u001E\u001F<CONSOLLE>\u001F\u001E|\w+[-_]?\w*\([^)]*\)>|irb\([^)]+\):\d+:?\d*[>*]|>>|>)\s*$/
|
|
26
|
+
# Legacy constant for backward compatibility - use config.prompt_pattern instead
|
|
27
|
+
PROMPT_PATTERN = Consolle::Config::DEFAULT_PROMPT_PATTERN
|
|
29
28
|
CTRL_C = "\x03"
|
|
30
29
|
|
|
31
30
|
def initialize(rails_root:, rails_env: 'development', logger: nil, command: nil, wait_timeout: nil)
|
|
@@ -34,6 +33,7 @@ module Consolle
|
|
|
34
33
|
@command = command || 'bin/rails console'
|
|
35
34
|
@logger = logger || Logger.new(STDOUT)
|
|
36
35
|
@wait_timeout = wait_timeout || Consolle::DEFAULT_WAIT_TIMEOUT
|
|
36
|
+
@config = Consolle::Config.load(rails_root)
|
|
37
37
|
@pid = nil
|
|
38
38
|
@reader = nil
|
|
39
39
|
@writer = nil
|
|
@@ -227,7 +227,7 @@ module Consolle
|
|
|
227
227
|
|
|
228
228
|
# Check if we got prompt back
|
|
229
229
|
clean = strip_ansi(output)
|
|
230
|
-
if clean.match?(
|
|
230
|
+
if clean.match?(prompt_pattern)
|
|
231
231
|
# Wait a bit for any trailing output
|
|
232
232
|
sleep 0.1
|
|
233
233
|
begin
|
|
@@ -300,6 +300,17 @@ module Consolle
|
|
|
300
300
|
end
|
|
301
301
|
end
|
|
302
302
|
|
|
303
|
+
# Returns the mode name for logging/debugging
|
|
304
|
+
# @return [Symbol] :pty
|
|
305
|
+
def mode
|
|
306
|
+
:pty
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Returns the prompt pattern to use (custom from config or default)
|
|
310
|
+
def prompt_pattern
|
|
311
|
+
@config&.prompt_pattern || Consolle::Config::DEFAULT_PROMPT_PATTERN
|
|
312
|
+
end
|
|
313
|
+
|
|
303
314
|
def stop
|
|
304
315
|
@running = false
|
|
305
316
|
|
|
@@ -531,7 +542,16 @@ module Consolle
|
|
|
531
542
|
logger.error "[ConsoleSupervisor] Timeout reached. Current: #{current_time}, Deadline: #{deadline}, Elapsed: #{current_time - start_time}s"
|
|
532
543
|
logger.error "[ConsoleSupervisor] Output so far: #{output.inspect}"
|
|
533
544
|
logger.error "[ConsoleSupervisor] Stripped: #{strip_ansi(output).inspect}"
|
|
534
|
-
|
|
545
|
+
|
|
546
|
+
# Raise PromptDetectionError with diagnostic information
|
|
547
|
+
config_path = File.join(@rails_root || '.', Consolle::Config::CONFIG_FILENAME)
|
|
548
|
+
expected_desc = @config&.prompt_pattern_description || "Default prompt patterns"
|
|
549
|
+
raise Consolle::Errors::PromptDetectionError.new(
|
|
550
|
+
timeout: timeout,
|
|
551
|
+
received_output: strip_ansi(output),
|
|
552
|
+
expected_patterns: expected_desc,
|
|
553
|
+
config_path: config_path
|
|
554
|
+
)
|
|
535
555
|
end
|
|
536
556
|
|
|
537
557
|
# If we found prompt and consume_all is true, continue reading for a bit more
|
|
@@ -560,7 +580,7 @@ module Consolle
|
|
|
560
580
|
clean = strip_ansi(output)
|
|
561
581
|
# Check each line for prompt pattern
|
|
562
582
|
clean.lines.each do |line|
|
|
563
|
-
if line.match?(
|
|
583
|
+
if line.match?(prompt_pattern)
|
|
564
584
|
logger.info '[ConsoleSupervisor] Found prompt!'
|
|
565
585
|
prompt_found = true
|
|
566
586
|
end
|
|
@@ -647,7 +667,7 @@ module Consolle
|
|
|
647
667
|
# Wait for prompt after configuration with reasonable timeout
|
|
648
668
|
begin
|
|
649
669
|
wait_for_prompt(timeout: 2, consume_all: false)
|
|
650
|
-
rescue Timeout::Error
|
|
670
|
+
rescue Timeout::Error, Consolle::Errors::PromptDetectionError
|
|
651
671
|
# This can fail with some console types, but that's okay
|
|
652
672
|
logger.debug '[ConsoleSupervisor] No prompt after IRB configuration, continuing'
|
|
653
673
|
end
|
|
@@ -686,7 +706,7 @@ module Consolle
|
|
|
686
706
|
end
|
|
687
707
|
|
|
688
708
|
# Skip prompts (but not return values that start with =>)
|
|
689
|
-
next if line.match?(
|
|
709
|
+
next if line.match?(prompt_pattern) && !line.start_with?('=>')
|
|
690
710
|
|
|
691
711
|
# Skip common IRB configuration output patterns
|
|
692
712
|
if line.match?(/^(IRB\.conf|DISABLE_PRY_RAILS|Switch to inspect mode|Loading .*\.rb|nil)$/) ||
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'timeout'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
require_relative 'base_supervisor'
|
|
6
|
+
require_relative '../errors'
|
|
7
|
+
|
|
8
|
+
module Consolle
|
|
9
|
+
module Server
|
|
10
|
+
# IRB embedding mode supervisor (Ruby 3.3+)
|
|
11
|
+
# Runs IRB directly in-process without PTY
|
|
12
|
+
# Supports two modes:
|
|
13
|
+
# - :embed_irb - Pure IRB without Rails
|
|
14
|
+
# - :embed_rails - Rails console with IRB
|
|
15
|
+
class EmbeddedSupervisor < BaseSupervisor
|
|
16
|
+
MINIMUM_RUBY_VERSION = Gem::Version.new('3.3.0')
|
|
17
|
+
VALID_MODES = %i[embed_irb embed_rails].freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def supported?
|
|
21
|
+
Gem::Version.new(RUBY_VERSION) >= MINIMUM_RUBY_VERSION
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def check_support!
|
|
25
|
+
return if supported?
|
|
26
|
+
|
|
27
|
+
raise Consolle::Errors::UnsupportedRubyVersion.new(
|
|
28
|
+
"Embedded mode requires Ruby #{MINIMUM_RUBY_VERSION}+, " \
|
|
29
|
+
"current version: #{RUBY_VERSION}"
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param rails_root [String] Project root directory
|
|
35
|
+
# @param rails_env [String] Rails environment (only for embed_rails mode)
|
|
36
|
+
# @param logger [Logger] Logger instance
|
|
37
|
+
# @param embed_mode [Symbol] :embed_irb or :embed_rails
|
|
38
|
+
def initialize(rails_root:, rails_env: 'development', logger: nil, embed_mode: :embed_rails)
|
|
39
|
+
super(rails_root: rails_root, rails_env: rails_env, logger: logger)
|
|
40
|
+
@embed_mode = validate_embed_mode(embed_mode)
|
|
41
|
+
@running = false
|
|
42
|
+
@workspace = nil
|
|
43
|
+
@mutex = Mutex.new
|
|
44
|
+
|
|
45
|
+
boot_environment
|
|
46
|
+
setup_irb
|
|
47
|
+
@running = true
|
|
48
|
+
|
|
49
|
+
mode_name = @embed_mode == :embed_rails ? 'Rails console' : 'IRB'
|
|
50
|
+
logger.info "[EmbeddedSupervisor] #{mode_name} embedded mode initialized (Ruby #{RUBY_VERSION})"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def eval(code, timeout: nil, pre_sigint: nil)
|
|
54
|
+
# pre_sigint is ignored in embedded mode (no PTY)
|
|
55
|
+
|
|
56
|
+
env_timeout = ENV['CONSOLLE_TIMEOUT']&.to_i
|
|
57
|
+
timeout = if env_timeout && env_timeout.positive?
|
|
58
|
+
env_timeout
|
|
59
|
+
else
|
|
60
|
+
timeout || 60
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
raise 'Console is not running' unless running?
|
|
65
|
+
|
|
66
|
+
start_time = Time.now
|
|
67
|
+
execute_code(code, timeout, start_time)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def running?
|
|
72
|
+
@running && @workspace
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def stop
|
|
76
|
+
@running = false
|
|
77
|
+
@workspace = nil
|
|
78
|
+
logger.info '[EmbeddedSupervisor] Stopped'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def restart
|
|
82
|
+
logger.info '[EmbeddedSupervisor] Restarting IRB workspace...'
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
setup_irb
|
|
85
|
+
end
|
|
86
|
+
logger.info '[EmbeddedSupervisor] IRB workspace restarted'
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def mode
|
|
90
|
+
@embed_mode
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns the current process PID (embedded mode runs in-process)
|
|
94
|
+
# @return [Integer]
|
|
95
|
+
def pid
|
|
96
|
+
Process.pid
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns the prompt pattern (for compatibility with PTY mode)
|
|
100
|
+
def prompt_pattern
|
|
101
|
+
@config&.prompt_pattern || Consolle::Config::DEFAULT_PROMPT_PATTERN
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def validate_embed_mode(mode)
|
|
107
|
+
mode_sym = mode.to_sym
|
|
108
|
+
return mode_sym if VALID_MODES.include?(mode_sym)
|
|
109
|
+
|
|
110
|
+
raise ArgumentError, "Invalid embed_mode: #{mode}. Must be one of: #{VALID_MODES.join(', ')}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def boot_environment
|
|
114
|
+
return boot_rails if @embed_mode == :embed_rails
|
|
115
|
+
|
|
116
|
+
# For embed_irb mode, no Rails loading needed
|
|
117
|
+
logger.info '[EmbeddedSupervisor] Pure IRB mode - skipping Rails environment'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def boot_rails
|
|
121
|
+
return if defined?(Rails) && Rails.application
|
|
122
|
+
|
|
123
|
+
ENV['RAILS_ENV'] = @rails_env
|
|
124
|
+
|
|
125
|
+
environment_file = File.join(@rails_root, 'config', 'environment.rb')
|
|
126
|
+
unless File.exist?(environment_file)
|
|
127
|
+
raise Consolle::Errors::ConfigurationError.new(
|
|
128
|
+
"Rails environment file not found: #{environment_file}"
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
logger.info "[EmbeddedSupervisor] Loading Rails environment from #{environment_file}"
|
|
133
|
+
require environment_file
|
|
134
|
+
logger.info "[EmbeddedSupervisor] Rails #{Rails.version} loaded (#{Rails.env})"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def setup_irb
|
|
138
|
+
require 'irb'
|
|
139
|
+
|
|
140
|
+
# Initialize IRB if not already done
|
|
141
|
+
IRB.setup(nil, argv: []) unless IRB.conf[:PROMPT]
|
|
142
|
+
|
|
143
|
+
# Configure IRB for automation
|
|
144
|
+
IRB.conf[:USE_COLORIZE] = false
|
|
145
|
+
IRB.conf[:USE_AUTOCOMPLETE] = false
|
|
146
|
+
IRB.conf[:USE_PAGER] = false
|
|
147
|
+
IRB.conf[:VERBOSE] = false
|
|
148
|
+
IRB.conf[:USE_MULTILINE] = false
|
|
149
|
+
|
|
150
|
+
# Create workspace with top-level binding for Rails Console-like behavior
|
|
151
|
+
@workspace = IRB::WorkSpace.new(TOPLEVEL_BINDING)
|
|
152
|
+
|
|
153
|
+
# Inject Rails console helpers if available
|
|
154
|
+
inject_rails_console_methods
|
|
155
|
+
|
|
156
|
+
logger.debug '[EmbeddedSupervisor] IRB workspace configured'
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def inject_rails_console_methods
|
|
160
|
+
# Only inject for embed_rails mode
|
|
161
|
+
return unless @embed_mode == :embed_rails
|
|
162
|
+
|
|
163
|
+
begin
|
|
164
|
+
# Rails 7.1 and earlier: use Rails::ConsoleMethods
|
|
165
|
+
if defined?(Rails::ConsoleMethods)
|
|
166
|
+
@workspace.binding.eval('extend Rails::ConsoleMethods')
|
|
167
|
+
logger.debug '[EmbeddedSupervisor] Rails::ConsoleMethods injected'
|
|
168
|
+
else
|
|
169
|
+
# Rails 7.2+: ConsoleMethods moved, define reload! directly
|
|
170
|
+
inject_reload_method
|
|
171
|
+
logger.debug '[EmbeddedSupervisor] reload! method injected directly'
|
|
172
|
+
end
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
logger.warn "[EmbeddedSupervisor] Failed to inject console methods: #{e.message}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def inject_reload_method
|
|
179
|
+
# Define reload! method that calls Rails reloader
|
|
180
|
+
@workspace.binding.eval(<<~RUBY)
|
|
181
|
+
def reload!(print = true)
|
|
182
|
+
puts "Reloading..." if print
|
|
183
|
+
Rails.application.reloader.reload!
|
|
184
|
+
true
|
|
185
|
+
end
|
|
186
|
+
RUBY
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def execute_code(code, timeout, start_time)
|
|
190
|
+
stdout_capture = StringIO.new
|
|
191
|
+
stderr_capture = StringIO.new
|
|
192
|
+
result = nil
|
|
193
|
+
error = nil
|
|
194
|
+
|
|
195
|
+
# Capture stdout/stderr
|
|
196
|
+
original_stdout = $stdout
|
|
197
|
+
original_stderr = $stderr
|
|
198
|
+
$stdout = stdout_capture
|
|
199
|
+
$stderr = stderr_capture
|
|
200
|
+
|
|
201
|
+
begin
|
|
202
|
+
Timeout.timeout(timeout) do
|
|
203
|
+
# Use workspace.binding.eval for full IRB context
|
|
204
|
+
result = @workspace.binding.eval(code, '(consolle)', 1)
|
|
205
|
+
end
|
|
206
|
+
rescue Timeout::Error
|
|
207
|
+
$stdout = original_stdout
|
|
208
|
+
$stderr = original_stderr
|
|
209
|
+
return build_timeout_response(timeout)
|
|
210
|
+
rescue SyntaxError => e
|
|
211
|
+
error = e
|
|
212
|
+
rescue StandardError => e
|
|
213
|
+
error = e
|
|
214
|
+
ensure
|
|
215
|
+
$stdout = original_stdout
|
|
216
|
+
$stderr = original_stderr
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
execution_time = Time.now - start_time
|
|
220
|
+
captured_output = stdout_capture.string + stderr_capture.string
|
|
221
|
+
|
|
222
|
+
if error
|
|
223
|
+
build_error_response_with_output(error, captured_output, execution_time)
|
|
224
|
+
else
|
|
225
|
+
build_success_response_with_result(result, captured_output, execution_time)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def build_error_response_with_output(error, captured_output, execution_time)
|
|
230
|
+
output = captured_output.empty? ? "#{error.class}: #{error.message}" : captured_output
|
|
231
|
+
|
|
232
|
+
{
|
|
233
|
+
success: false,
|
|
234
|
+
error_class: error.class.name,
|
|
235
|
+
error_code: Consolle::Errors::ErrorClassifier.to_code(error),
|
|
236
|
+
output: output,
|
|
237
|
+
execution_time: execution_time
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def build_success_response_with_result(result, captured_output, execution_time)
|
|
242
|
+
# Format output like Rails console
|
|
243
|
+
# If there's captured stdout, include it
|
|
244
|
+
# Always include the return value with => prefix
|
|
245
|
+
output_parts = []
|
|
246
|
+
output_parts << captured_output unless captured_output.empty?
|
|
247
|
+
output_parts << "=> #{result.inspect}"
|
|
248
|
+
|
|
249
|
+
{
|
|
250
|
+
success: true,
|
|
251
|
+
output: output_parts.join("\n"),
|
|
252
|
+
result: result,
|
|
253
|
+
execution_time: execution_time
|
|
254
|
+
}
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'console_supervisor'
|
|
4
|
+
require_relative 'embedded_supervisor'
|
|
5
|
+
|
|
6
|
+
module Consolle
|
|
7
|
+
module Server
|
|
8
|
+
# Factory for creating the appropriate supervisor based on configuration
|
|
9
|
+
#
|
|
10
|
+
# Modes:
|
|
11
|
+
# - :pty - PTY-based, supports custom command (local/remote)
|
|
12
|
+
# - :embed_irb - Pure IRB embedding (Ruby 3.3+, local only)
|
|
13
|
+
# - :embed_rails - Rails console embedding (Ruby 3.3+, local only)
|
|
14
|
+
class SupervisorFactory
|
|
15
|
+
MODES = %i[pty embed_irb embed_rails].freeze
|
|
16
|
+
EMBEDDED_MODES = %i[embed_irb embed_rails].freeze
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Create a supervisor instance
|
|
20
|
+
# @param rails_root [String] Path to project root
|
|
21
|
+
# @param mode [Symbol] :pty, :embed_irb, or :embed_rails
|
|
22
|
+
# @param options [Hash] Additional options passed to supervisor
|
|
23
|
+
# @return [BaseSupervisor] PTY or Embedded supervisor
|
|
24
|
+
def create(rails_root:, mode: :pty, **options)
|
|
25
|
+
mode = normalize_mode(mode)
|
|
26
|
+
validate_mode!(mode)
|
|
27
|
+
|
|
28
|
+
case mode
|
|
29
|
+
when :pty
|
|
30
|
+
create_pty_supervisor(rails_root, options)
|
|
31
|
+
when :embed_irb
|
|
32
|
+
create_embedded_supervisor(rails_root, :embed_irb, options)
|
|
33
|
+
when :embed_rails
|
|
34
|
+
create_embedded_supervisor(rails_root, :embed_rails, options)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check if embedded mode is available (Ruby 3.3+)
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
def embedded_available?
|
|
41
|
+
EmbeddedSupervisor.supported?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def normalize_mode(mode)
|
|
47
|
+
mode_sym = mode.to_s.tr('-', '_').to_sym
|
|
48
|
+
|
|
49
|
+
# Handle legacy mode names
|
|
50
|
+
case mode_sym
|
|
51
|
+
when :embedded then :embed_rails
|
|
52
|
+
when :auto then :pty
|
|
53
|
+
else mode_sym
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_mode!(mode)
|
|
58
|
+
return if MODES.include?(mode)
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "Invalid mode: #{mode}. Must be one of: #{MODES.join(', ')}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def create_pty_supervisor(rails_root, options)
|
|
64
|
+
ConsoleSupervisor.new(
|
|
65
|
+
rails_root: rails_root,
|
|
66
|
+
rails_env: options[:rails_env] || 'development',
|
|
67
|
+
logger: options[:logger],
|
|
68
|
+
command: options[:command],
|
|
69
|
+
wait_timeout: options[:wait_timeout]
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def create_embedded_supervisor(rails_root, embed_mode, options)
|
|
74
|
+
EmbeddedSupervisor.check_support!
|
|
75
|
+
|
|
76
|
+
EmbeddedSupervisor.new(
|
|
77
|
+
rails_root: rails_root,
|
|
78
|
+
rails_env: options[:rails_env] || 'development',
|
|
79
|
+
logger: options[:logger],
|
|
80
|
+
embed_mode: embed_mode
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/consolle.rb
CHANGED
data/rule.ko.md
CHANGED
|
@@ -20,10 +20,10 @@ Cone은 디버깅, 데이터 탐색, 그리고 개발 보조 도구로 사용됩
|
|
|
20
20
|
|
|
21
21
|
## Cone 서버 시작과 중지
|
|
22
22
|
|
|
23
|
-
`start` 명령어로 cone을 시작할 수
|
|
23
|
+
`start` 명령어로 cone을 시작할 수 있습니다. 실행 환경 지정은 `RAILS_ENV` 환경변수를 사용합니다.
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
$ cone start # 서버 시작
|
|
26
|
+
$ cone start # 서버 시작 (RAILS_ENV가 없으면 development)
|
|
27
27
|
$ RAILS_ENV=test cone start # test 환경에서 console 시작
|
|
28
28
|
```
|
|
29
29
|
|
|
@@ -37,6 +37,46 @@ $ cone stop # 서버 중지
|
|
|
37
37
|
|
|
38
38
|
작업을 마치면 반드시 종료해 주세요.
|
|
39
39
|
|
|
40
|
+
## 실행 모드
|
|
41
|
+
|
|
42
|
+
Cone은 세 가지 실행 모드를 지원합니다. `--mode` 옵션으로 지정할 수 있습니다.
|
|
43
|
+
|
|
44
|
+
| 모드 | 설명 | Ruby 요구사항 | 실행 속도 |
|
|
45
|
+
|------|------|--------------|----------|
|
|
46
|
+
| `pty` | PTY 기반, 커스텀 명령어 지원 (기본값) | 모든 버전 | ~0.6s |
|
|
47
|
+
| `embed-rails` | Rails 콘솔 임베딩 | Ruby 3.3+ | ~0.001s |
|
|
48
|
+
| `embed-irb` | 순수 IRB 임베딩 (Rails 미로드) | Ruby 3.3+ | ~0.001s |
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
$ cone start # PTY 모드 (기본값)
|
|
52
|
+
$ cone start --mode embed-rails # Rails 콘솔 임베딩 (200배 빠름)
|
|
53
|
+
$ cone start --mode embed-irb # 순수 IRB 임베딩 (Rails 없이)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 모드 선택 기준
|
|
57
|
+
|
|
58
|
+
- **`pty`**: 원격 환경(SSH, Docker, Kamal)이나 커스텀 명령어가 필요한 경우
|
|
59
|
+
- **`embed-rails`**: 로컬 Rails 개발에서 빠른 실행이 필요한 경우
|
|
60
|
+
- **`embed-irb`**: Rails 없이 순수 Ruby 코드만 실행하는 경우
|
|
61
|
+
|
|
62
|
+
### 커스텀 명령어 (PTY 모드 전용)
|
|
63
|
+
|
|
64
|
+
PTY 모드에서는 `--command` 옵션으로 커스텀 콘솔 명령어를 지정할 수 있습니다.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
$ cone start --command "docker exec -it app bin/rails console"
|
|
68
|
+
$ cone start --command "kamal console" --wait-timeout 60
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 설정 파일
|
|
72
|
+
|
|
73
|
+
프로젝트 루트에 `.consolle.yml` 파일로 기본 모드를 설정할 수 있습니다. CLI 옵션은 설정 파일보다 우선합니다.
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
mode: embed-rails
|
|
77
|
+
# command: "bin/rails console" # PTY 모드 전용
|
|
78
|
+
```
|
|
79
|
+
|
|
40
80
|
## Cone 서버 상태 확인
|
|
41
81
|
|
|
42
82
|
```bash
|
|
@@ -74,10 +114,13 @@ $ cone exec 'puts u'
|
|
|
74
114
|
$ cone exec -f example.rb
|
|
75
115
|
```
|
|
76
116
|
|
|
77
|
-
디버깅을 위한 `-v` 옵션(Verbose 출력)이 제공됩니다.
|
|
117
|
+
디버깅을 위한 `-v` 옵션(Verbose 출력)이 제공됩니다. 실행 시간 및 추가 정보를 표시합니다.
|
|
78
118
|
|
|
79
119
|
```bash
|
|
80
120
|
$ cone exec -v 'puts "hello, world"'
|
|
121
|
+
hello, world
|
|
122
|
+
=> nil
|
|
123
|
+
Execution time: 0.001s
|
|
81
124
|
```
|
|
82
125
|
|
|
83
126
|
## 코드 입력 모범 사례
|
|
@@ -115,3 +158,34 @@ $ cone exec -f complex_task.rb
|
|
|
115
158
|
```
|
|
116
159
|
|
|
117
160
|
모든 방법은 세션 상태를 유지하므로 변수와 객체가 실행 간에 지속됩니다.
|
|
161
|
+
|
|
162
|
+
## 실행 안전장치 & 타임아웃
|
|
163
|
+
|
|
164
|
+
- 기본 타임아웃: 60초
|
|
165
|
+
- 타임아웃 우선순위: `CONSOLLE_TIMEOUT`(설정되고 0보다 클 때) > CLI `--timeout` > 기본값(60초)
|
|
166
|
+
- 사전 Ctrl‑C(프롬프트 분리):
|
|
167
|
+
- 매 `exec` 전에 Ctrl‑C를 보내고 IRB 프롬프트를 최대 3초 대기해 깨끗한 상태를 보장합니다.
|
|
168
|
+
- 3초 내 프롬프트가 돌아오지 않으면 콘솔 하위 프로세스를 재시작하고 요청은 `SERVER_UNHEALTHY`로 실패합니다.
|
|
169
|
+
- 서버 전역 비활성화: `CONSOLLE_DISABLE_PRE_SIGINT=1 cone start`
|
|
170
|
+
- 호출 단위 제어: `--pre-sigint` / `--no-pre-sigint`
|
|
171
|
+
|
|
172
|
+
### 예시
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
# CLI로 타임아웃 지정(환경변수 미설정 시 유효)
|
|
176
|
+
cone exec 'heavy_task' --timeout 120
|
|
177
|
+
|
|
178
|
+
# 최우선 타임아웃(클라이언트·서버 모두 적용)
|
|
179
|
+
CONSOLLE_TIMEOUT=90 cone exec 'heavy_task'
|
|
180
|
+
|
|
181
|
+
# 타임아웃 이후 복구 확인
|
|
182
|
+
cone exec 'sleep 999' --timeout 2 # -> EXECUTION_TIMEOUT로 실패
|
|
183
|
+
cone exec "puts :after_timeout; :ok" # -> 정상 동작(프롬프트 복구)
|
|
184
|
+
|
|
185
|
+
# 호출 단위로 사전 Ctrl‑C 비활성화
|
|
186
|
+
cone exec --no-pre-sigint 'code'
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### 에러 코드
|
|
190
|
+
- `EXECUTION_TIMEOUT`: 실행한 코드가 타임아웃을 초과함
|
|
191
|
+
- `SERVER_UNHEALTHY`: 사전 프롬프트 확인(3초) 실패로 콘솔 재시작, 요청 실패
|
data/rule.md
CHANGED
|
@@ -20,10 +20,10 @@ Existing objects also reference old code, so you need to create new ones to use
|
|
|
20
20
|
|
|
21
21
|
## Starting and Stopping Cone Server
|
|
22
22
|
|
|
23
|
-
You can start cone with the `start` command
|
|
23
|
+
You can start cone with the `start` command. To select the Rails environment, set the `RAILS_ENV` environment variable.
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
$ cone start # Start server
|
|
26
|
+
$ cone start # Start server (uses RAILS_ENV or defaults to development)
|
|
27
27
|
$ RAILS_ENV=test cone start # Start console in test environment
|
|
28
28
|
```
|
|
29
29
|
|
|
@@ -37,6 +37,46 @@ $ cone stop # Stop server
|
|
|
37
37
|
|
|
38
38
|
Always terminate when you finish your work.
|
|
39
39
|
|
|
40
|
+
## Execution Modes
|
|
41
|
+
|
|
42
|
+
Cone supports three execution modes. You can specify the mode with the `--mode` option.
|
|
43
|
+
|
|
44
|
+
| Mode | Description | Ruby Requirement | Execution Speed |
|
|
45
|
+
|------|-------------|-----------------|-----------------|
|
|
46
|
+
| `pty` | PTY-based, supports custom commands (default) | All versions | ~0.6s |
|
|
47
|
+
| `embed-rails` | Rails console embedding | Ruby 3.3+ | ~0.001s |
|
|
48
|
+
| `embed-irb` | Pure IRB embedding (no Rails) | Ruby 3.3+ | ~0.001s |
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
$ cone start # PTY mode (default)
|
|
52
|
+
$ cone start --mode embed-rails # Rails console embedding (200x faster)
|
|
53
|
+
$ cone start --mode embed-irb # Pure IRB embedding (without Rails)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Mode Selection Guide
|
|
57
|
+
|
|
58
|
+
- **`pty`**: For remote environments (SSH, Docker, Kamal) or when custom commands are needed
|
|
59
|
+
- **`embed-rails`**: For local Rails development when fast execution is required
|
|
60
|
+
- **`embed-irb`**: When running pure Ruby code without Rails
|
|
61
|
+
|
|
62
|
+
### Custom Commands (PTY Mode Only)
|
|
63
|
+
|
|
64
|
+
In PTY mode, you can specify a custom console command with the `--command` option.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
$ cone start --command "docker exec -it app bin/rails console"
|
|
68
|
+
$ cone start --command "kamal console" --wait-timeout 60
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Configuration File
|
|
72
|
+
|
|
73
|
+
You can set the default mode in a `.consolle.yml` file at the project root. CLI options take precedence over the configuration file.
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
mode: embed-rails
|
|
77
|
+
# command: "bin/rails console" # PTY mode only
|
|
78
|
+
```
|
|
79
|
+
|
|
40
80
|
## Checking Cone Server Status
|
|
41
81
|
|
|
42
82
|
```bash
|
|
@@ -74,10 +114,13 @@ You can also execute Ruby files directly using the `-f` option. Unlike Rails Run
|
|
|
74
114
|
$ cone exec -f example.rb
|
|
75
115
|
```
|
|
76
116
|
|
|
77
|
-
A `-v` option (Verbose output) is provided for debugging.
|
|
117
|
+
A `-v` option (Verbose output) is provided for debugging. It shows execution time and additional details.
|
|
78
118
|
|
|
79
119
|
```bash
|
|
80
120
|
$ cone exec -v 'puts "hello, world"'
|
|
121
|
+
hello, world
|
|
122
|
+
=> nil
|
|
123
|
+
Execution time: 0.001s
|
|
81
124
|
```
|
|
82
125
|
|
|
83
126
|
## Best Practices for Code Input
|
|
@@ -115,3 +158,34 @@ $ cone exec -f complex_task.rb
|
|
|
115
158
|
```
|
|
116
159
|
|
|
117
160
|
All methods maintain the session state, so variables and objects persist between executions.
|
|
161
|
+
|
|
162
|
+
## Execution Safety & Timeouts
|
|
163
|
+
|
|
164
|
+
- Default timeout: 60s
|
|
165
|
+
- Timeout precedence: `CONSOLLE_TIMEOUT` (if set and > 0) > CLI `--timeout` > default (60s)
|
|
166
|
+
- Pre-exec Ctrl-C (prompt separation):
|
|
167
|
+
- Before each `exec`, cone sends Ctrl-C and waits up to 3 seconds for the IRB prompt to ensure a clean state.
|
|
168
|
+
- If the prompt does not return in 3 seconds, the console subprocess is restarted and the request fails with `SERVER_UNHEALTHY`.
|
|
169
|
+
- Disable globally for the server: `CONSOLLE_DISABLE_PRE_SIGINT=1 cone start`
|
|
170
|
+
- Per-call control: `--pre-sigint` / `--no-pre-sigint`
|
|
171
|
+
|
|
172
|
+
### Examples
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
# Set timeout via CLI (fallback when CONSOLLE_TIMEOUT is not set)
|
|
176
|
+
cone exec 'heavy_task' --timeout 120
|
|
177
|
+
|
|
178
|
+
# Highest priority timeout (applies on client and server)
|
|
179
|
+
CONSOLLE_TIMEOUT=90 cone exec 'heavy_task'
|
|
180
|
+
|
|
181
|
+
# Verify recovery after a timeout
|
|
182
|
+
cone exec 'sleep 999' --timeout 2 # -> fails with EXECUTION_TIMEOUT
|
|
183
|
+
cone exec "puts :after_timeout; :ok" # -> should succeed (prompt recovered)
|
|
184
|
+
|
|
185
|
+
# Disable pre-exec Ctrl-C for a single call
|
|
186
|
+
cone exec --no-pre-sigint 'code'
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Error Codes
|
|
190
|
+
- `EXECUTION_TIMEOUT`: The executed code exceeded its timeout.
|
|
191
|
+
- `SERVER_UNHEALTHY`: The pre-exec prompt did not return within 3 seconds; the console subprocess was restarted and the request failed.
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: consolle
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- nacyot
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2025-
|
|
10
|
+
date: 2025-12-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: logger
|
|
@@ -79,11 +79,15 @@ files:
|
|
|
79
79
|
- lib/consolle.rb
|
|
80
80
|
- lib/consolle/adapters/rails_console.rb
|
|
81
81
|
- lib/consolle/cli.rb
|
|
82
|
+
- lib/consolle/config.rb
|
|
82
83
|
- lib/consolle/constants.rb
|
|
83
84
|
- lib/consolle/errors.rb
|
|
85
|
+
- lib/consolle/server/base_supervisor.rb
|
|
84
86
|
- lib/consolle/server/console_socket_server.rb
|
|
85
87
|
- lib/consolle/server/console_supervisor.rb
|
|
88
|
+
- lib/consolle/server/embedded_supervisor.rb
|
|
86
89
|
- lib/consolle/server/request_broker.rb
|
|
90
|
+
- lib/consolle/server/supervisor_factory.rb
|
|
87
91
|
- lib/consolle/version.rb
|
|
88
92
|
- mise/release.sh
|
|
89
93
|
- rule.ko.md
|