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
data/lib/consolle/cli.rb
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "timeout"
|
|
8
|
+
require "securerandom"
|
|
9
|
+
require "date"
|
|
10
|
+
require_relative "adapters/rails_console"
|
|
11
|
+
|
|
12
|
+
module Consolle
|
|
13
|
+
class CLI < Thor
|
|
14
|
+
class_option :verbose, type: :boolean, aliases: "-v", desc: "Verbose output"
|
|
15
|
+
class_option :target, type: :string, aliases: "-t", desc: "Target session name", default: "cone"
|
|
16
|
+
|
|
17
|
+
def self.exit_on_failure?
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Override invoke_command to handle --help flag for subcommands
|
|
22
|
+
no_commands do
|
|
23
|
+
def invoke_command(command, *args)
|
|
24
|
+
# Check if --help or -h is in the original arguments
|
|
25
|
+
if ARGV.include?("--help") || ARGV.include?("-h")
|
|
26
|
+
# Show help for the command
|
|
27
|
+
self.class.command_help(shell, command.name)
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Call original invoke_command
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
default_task :default
|
|
38
|
+
|
|
39
|
+
desc "default", "Start console"
|
|
40
|
+
def default
|
|
41
|
+
start
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
desc "version", "Show consolle version"
|
|
45
|
+
def version
|
|
46
|
+
puts "Consolle version #{Consolle::VERSION}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
desc "rule FILE", "Write cone command guide to FILE"
|
|
50
|
+
def rule(file_path)
|
|
51
|
+
# Read the embedded rule content
|
|
52
|
+
rule_content = File.read(File.expand_path("../../../rule.md", __FILE__))
|
|
53
|
+
|
|
54
|
+
# Write to the specified file
|
|
55
|
+
File.write(file_path, rule_content)
|
|
56
|
+
puts "✓ Cone command guide written to #{file_path}"
|
|
57
|
+
rescue => e
|
|
58
|
+
puts "✗ Failed to write rule file: #{e.message}"
|
|
59
|
+
exit 1
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
desc "start", "Start Rails console in background"
|
|
63
|
+
method_option :rails_env, type: :string, aliases: "-e", desc: "Rails environment", default: "development"
|
|
64
|
+
method_option :command, type: :string, aliases: "-c", desc: "Custom console command", default: "bin/rails console"
|
|
65
|
+
def start
|
|
66
|
+
ensure_rails_project!
|
|
67
|
+
ensure_project_directories
|
|
68
|
+
validate_session_name!(options[:target])
|
|
69
|
+
|
|
70
|
+
# Check if already running using session info
|
|
71
|
+
session_info = load_session_info
|
|
72
|
+
if session_info && session_info[:process_pid]
|
|
73
|
+
# Check if process is still running
|
|
74
|
+
begin
|
|
75
|
+
Process.kill(0, session_info[:process_pid])
|
|
76
|
+
# Check if socket is ready
|
|
77
|
+
if File.exist?(session_info[:socket_path])
|
|
78
|
+
puts "Rails console is already running (PID: #{session_info[:process_pid]})"
|
|
79
|
+
puts "Socket: #{session_info[:socket_path]}"
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
rescue Errno::ESRCH
|
|
83
|
+
# Process not found, clean up session
|
|
84
|
+
clear_session_info
|
|
85
|
+
end
|
|
86
|
+
elsif session_info
|
|
87
|
+
# Session file exists but no valid PID, clean up
|
|
88
|
+
clear_session_info
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
adapter = create_rails_adapter(options[:rails_env], options[:target], options[:command])
|
|
92
|
+
|
|
93
|
+
puts "Starting Rails console..."
|
|
94
|
+
|
|
95
|
+
begin
|
|
96
|
+
adapter.start
|
|
97
|
+
puts "✓ Rails console started successfully"
|
|
98
|
+
puts " PID: #{adapter.process_pid}"
|
|
99
|
+
puts " Socket: #{adapter.socket_path}"
|
|
100
|
+
|
|
101
|
+
# Save session info
|
|
102
|
+
save_session_info(adapter)
|
|
103
|
+
|
|
104
|
+
# Log session start
|
|
105
|
+
log_session_event(adapter.process_pid, "session_start", {
|
|
106
|
+
rails_env: options[:rails_env],
|
|
107
|
+
socket_path: adapter.socket_path
|
|
108
|
+
})
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
puts "✗ Failed to start Rails console: #{e.message}"
|
|
111
|
+
exit 1
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
desc "status", "Show Rails console status"
|
|
116
|
+
def status
|
|
117
|
+
ensure_rails_project!
|
|
118
|
+
validate_session_name!(options[:target])
|
|
119
|
+
|
|
120
|
+
session_info = load_session_info
|
|
121
|
+
|
|
122
|
+
if session_info.nil?
|
|
123
|
+
puts "No active Rails console session found"
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Check if server is actually responsive
|
|
128
|
+
adapter = create_rails_adapter("development", options[:target])
|
|
129
|
+
server_status = adapter.get_status rescue nil
|
|
130
|
+
process_running = server_status && server_status["success"] && server_status["running"]
|
|
131
|
+
|
|
132
|
+
if process_running
|
|
133
|
+
rails_env = server_status["rails_env"] || "unknown"
|
|
134
|
+
console_pid = server_status["pid"] || "unknown"
|
|
135
|
+
|
|
136
|
+
puts "✓ Rails console is running"
|
|
137
|
+
puts " PID: #{console_pid}"
|
|
138
|
+
puts " Environment: #{rails_env}"
|
|
139
|
+
puts " Session: #{session_info[:socket_path]}"
|
|
140
|
+
puts " Ready for input: Yes"
|
|
141
|
+
else
|
|
142
|
+
puts "✗ Rails console is not running"
|
|
143
|
+
clear_session_info
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
desc "ls", "List active Rails console sessions"
|
|
148
|
+
def ls
|
|
149
|
+
ensure_rails_project!
|
|
150
|
+
|
|
151
|
+
sessions = load_sessions
|
|
152
|
+
|
|
153
|
+
if sessions.empty? || sessions.size == 1 && sessions.key?("_schema")
|
|
154
|
+
puts "No active sessions"
|
|
155
|
+
return
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
active_sessions = []
|
|
159
|
+
stale_sessions = []
|
|
160
|
+
|
|
161
|
+
sessions.each do |name, info|
|
|
162
|
+
next if name == "_schema" # Skip schema field
|
|
163
|
+
|
|
164
|
+
# Check if process is alive
|
|
165
|
+
if info["process_pid"] && process_alive?(info["process_pid"])
|
|
166
|
+
# Try to get server status
|
|
167
|
+
adapter = create_rails_adapter("development", name)
|
|
168
|
+
server_status = adapter.get_status rescue nil
|
|
169
|
+
|
|
170
|
+
if server_status && server_status["success"] && server_status["running"]
|
|
171
|
+
rails_env = server_status["rails_env"] || "development"
|
|
172
|
+
console_pid = server_status["pid"] || info["process_pid"]
|
|
173
|
+
active_sessions << "#{name} (#{rails_env}) - PID: #{console_pid}"
|
|
174
|
+
else
|
|
175
|
+
stale_sessions << name
|
|
176
|
+
end
|
|
177
|
+
else
|
|
178
|
+
stale_sessions << name
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Clean up stale sessions
|
|
183
|
+
if stale_sessions.any?
|
|
184
|
+
with_sessions_lock do
|
|
185
|
+
sessions = load_sessions
|
|
186
|
+
stale_sessions.each { |name| sessions.delete(name) }
|
|
187
|
+
save_sessions(sessions)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
if active_sessions.empty?
|
|
192
|
+
puts "No active sessions"
|
|
193
|
+
else
|
|
194
|
+
active_sessions.each { |session| puts session }
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
desc "stop", "Stop Rails console"
|
|
199
|
+
def stop
|
|
200
|
+
ensure_rails_project!
|
|
201
|
+
validate_session_name!(options[:target])
|
|
202
|
+
|
|
203
|
+
adapter = create_rails_adapter("development", options[:target])
|
|
204
|
+
|
|
205
|
+
if adapter.running?
|
|
206
|
+
puts "Stopping Rails console..."
|
|
207
|
+
|
|
208
|
+
if adapter.stop
|
|
209
|
+
puts "✓ Rails console stopped"
|
|
210
|
+
|
|
211
|
+
# Log session stop
|
|
212
|
+
session_info = load_session_info
|
|
213
|
+
if session_info && session_info[:process_pid]
|
|
214
|
+
log_session_event(session_info[:process_pid], "session_stop", {
|
|
215
|
+
reason: "user_requested"
|
|
216
|
+
})
|
|
217
|
+
end
|
|
218
|
+
else
|
|
219
|
+
puts "✗ Failed to stop Rails console"
|
|
220
|
+
end
|
|
221
|
+
else
|
|
222
|
+
puts "Rails console is not running"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
clear_session_info
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
desc "stop_all", "Stop all Rails console sessions"
|
|
229
|
+
map ["stop-all"] => :stop_all
|
|
230
|
+
def stop_all
|
|
231
|
+
ensure_rails_project!
|
|
232
|
+
|
|
233
|
+
sessions = load_sessions
|
|
234
|
+
active_sessions = []
|
|
235
|
+
|
|
236
|
+
# Filter active sessions (excluding schema)
|
|
237
|
+
sessions.each do |name, info|
|
|
238
|
+
next if name == "_schema"
|
|
239
|
+
if info["process_pid"] && process_alive?(info["process_pid"])
|
|
240
|
+
active_sessions << { name: name, info: info }
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
if active_sessions.empty?
|
|
245
|
+
puts "No active sessions to stop"
|
|
246
|
+
return
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
puts "Found #{active_sessions.size} active session(s)"
|
|
250
|
+
|
|
251
|
+
# Stop each active session
|
|
252
|
+
active_sessions.each do |session|
|
|
253
|
+
name = session[:name]
|
|
254
|
+
info = session[:info]
|
|
255
|
+
|
|
256
|
+
puts "\nStopping session '#{name}'..."
|
|
257
|
+
|
|
258
|
+
adapter = create_rails_adapter("development", name)
|
|
259
|
+
|
|
260
|
+
if adapter.stop
|
|
261
|
+
puts "✓ Session '#{name}' stopped"
|
|
262
|
+
|
|
263
|
+
# Log session stop
|
|
264
|
+
if info["process_pid"]
|
|
265
|
+
log_session_event(info["process_pid"], "session_stop", {
|
|
266
|
+
reason: "stop_all_requested"
|
|
267
|
+
})
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Clear session info
|
|
271
|
+
with_sessions_lock do
|
|
272
|
+
sessions = load_sessions
|
|
273
|
+
sessions.delete(name)
|
|
274
|
+
save_sessions(sessions)
|
|
275
|
+
end
|
|
276
|
+
else
|
|
277
|
+
puts "✗ Failed to stop session '#{name}'"
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
puts "\n✓ All sessions stopped"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
desc "restart", "Restart Rails console"
|
|
285
|
+
method_option :rails_env, type: :string, aliases: "-e", desc: "Rails environment", default: "development"
|
|
286
|
+
method_option :force, type: :boolean, aliases: "-f", desc: "Force restart the entire server"
|
|
287
|
+
def restart
|
|
288
|
+
ensure_rails_project!
|
|
289
|
+
validate_session_name!(options[:target])
|
|
290
|
+
|
|
291
|
+
adapter = create_rails_adapter(options[:rails_env], options[:target])
|
|
292
|
+
|
|
293
|
+
if adapter.running?
|
|
294
|
+
# Check if environment needs to be changed
|
|
295
|
+
current_status = adapter.get_status rescue nil
|
|
296
|
+
current_env = current_status&.dig("rails_env") || "development"
|
|
297
|
+
needs_full_restart = options[:force] || (current_env != options[:rails_env])
|
|
298
|
+
|
|
299
|
+
if needs_full_restart
|
|
300
|
+
if current_env != options[:rails_env]
|
|
301
|
+
puts "Environment change detected (#{current_env} -> #{options[:rails_env]})"
|
|
302
|
+
puts "Performing full server restart..."
|
|
303
|
+
else
|
|
304
|
+
puts "Force restarting Rails console server..."
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Save current rails_env for start command
|
|
308
|
+
old_env = @rails_env
|
|
309
|
+
@rails_env = options[:rails_env]
|
|
310
|
+
|
|
311
|
+
stop
|
|
312
|
+
sleep 1
|
|
313
|
+
invoke(:start, [], { rails_env: options[:rails_env] })
|
|
314
|
+
|
|
315
|
+
@rails_env = old_env
|
|
316
|
+
else
|
|
317
|
+
puts "Restarting Rails console subprocess..."
|
|
318
|
+
|
|
319
|
+
# Send restart request to the socket server
|
|
320
|
+
request = {
|
|
321
|
+
"action" => "restart",
|
|
322
|
+
"request_id" => SecureRandom.uuid
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
begin
|
|
326
|
+
# Use direct socket connection for restart request
|
|
327
|
+
socket = UNIXSocket.new(adapter.socket_path)
|
|
328
|
+
socket.write(JSON.generate(request))
|
|
329
|
+
socket.write("\n")
|
|
330
|
+
socket.flush
|
|
331
|
+
response_data = socket.gets
|
|
332
|
+
socket.close
|
|
333
|
+
|
|
334
|
+
response = JSON.parse(response_data)
|
|
335
|
+
|
|
336
|
+
if response["success"]
|
|
337
|
+
puts "✓ Rails console subprocess restarted"
|
|
338
|
+
puts " New PID: #{response["pid"]}" if response["pid"]
|
|
339
|
+
else
|
|
340
|
+
puts "✗ Failed to restart: #{response["message"]}"
|
|
341
|
+
puts "You can try 'cone restart --force' to restart the entire server"
|
|
342
|
+
end
|
|
343
|
+
rescue StandardError => e
|
|
344
|
+
puts "✗ Error restarting: #{e.message}"
|
|
345
|
+
puts "You can try 'cone restart --force' to restart the entire server"
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
else
|
|
349
|
+
puts "Rails console is not running. Starting it..."
|
|
350
|
+
invoke(:start, [], { rails_env: options[:rails_env] })
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
desc "exec CODE", "Execute Ruby code in Rails console"
|
|
355
|
+
method_option :timeout, type: :numeric, aliases: "-t", desc: "Timeout in seconds", default: 15
|
|
356
|
+
method_option :file, type: :string, aliases: "-f", desc: "Read Ruby code from FILE"
|
|
357
|
+
method_option :raw, type: :boolean, desc: "Do not apply escape fixes for Claude Code (keep \\! as is)"
|
|
358
|
+
def exec(*code_parts)
|
|
359
|
+
ensure_rails_project!
|
|
360
|
+
ensure_project_directories
|
|
361
|
+
validate_session_name!(options[:target])
|
|
362
|
+
|
|
363
|
+
# Handle code input from file or arguments first
|
|
364
|
+
code = if options[:file]
|
|
365
|
+
path = File.expand_path(options[:file])
|
|
366
|
+
unless File.file?(path)
|
|
367
|
+
puts "Error: File not found: #{path}"
|
|
368
|
+
exit 1
|
|
369
|
+
end
|
|
370
|
+
File.read(path, mode: "r:UTF-8")
|
|
371
|
+
else
|
|
372
|
+
code_parts.join(" ")
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
if code.strip.empty?
|
|
376
|
+
puts "Error: No code provided (pass CODE or use -f FILE)"
|
|
377
|
+
exit 1
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
session_info = load_session_info
|
|
381
|
+
server_running = false
|
|
382
|
+
|
|
383
|
+
# Check if server is running
|
|
384
|
+
if session_info
|
|
385
|
+
begin
|
|
386
|
+
# Try to connect to socket and get status
|
|
387
|
+
socket = UNIXSocket.new(session_info[:socket_path])
|
|
388
|
+
request = {
|
|
389
|
+
"action" => "status",
|
|
390
|
+
"request_id" => SecureRandom.uuid
|
|
391
|
+
}
|
|
392
|
+
socket.write(JSON.generate(request))
|
|
393
|
+
socket.write("\n")
|
|
394
|
+
socket.flush
|
|
395
|
+
response_data = socket.gets
|
|
396
|
+
socket.close
|
|
397
|
+
|
|
398
|
+
response = JSON.parse(response_data)
|
|
399
|
+
server_running = response["success"] && response["running"]
|
|
400
|
+
rescue StandardError
|
|
401
|
+
# Server not responsive
|
|
402
|
+
server_running = false
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Check if server is running
|
|
407
|
+
unless server_running
|
|
408
|
+
puts "✗ Rails console is not running"
|
|
409
|
+
puts "Please start it first with: cone start"
|
|
410
|
+
exit 1
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Apply Claude Code escape fix unless --raw option is specified
|
|
414
|
+
unless options[:raw]
|
|
415
|
+
code = code.gsub('\\!', '!')
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
puts "Executing: #{code}" if options[:verbose]
|
|
419
|
+
|
|
420
|
+
# Send code to socket
|
|
421
|
+
result = send_code_to_socket(session_info[:socket_path], code, timeout: options[:timeout])
|
|
422
|
+
|
|
423
|
+
# Log the request and response
|
|
424
|
+
log_session_activity(session_info[:process_pid], code, result)
|
|
425
|
+
|
|
426
|
+
if result["success"]
|
|
427
|
+
# Always print result, even if empty (multiline code often returns empty string)
|
|
428
|
+
puts result["result"] unless result["result"].nil?
|
|
429
|
+
puts "Execution time: #{result["execution_time"]}s" if options[:verbose] && result["execution_time"]
|
|
430
|
+
else
|
|
431
|
+
puts "Error: #{result["error"]}"
|
|
432
|
+
puts result["message"]
|
|
433
|
+
puts result["backtrace"]&.join("\n") if options[:verbose]
|
|
434
|
+
exit 1
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
private
|
|
439
|
+
|
|
440
|
+
def ensure_rails_project!
|
|
441
|
+
unless File.exist?("config/environment.rb") || File.exist?("config/application.rb")
|
|
442
|
+
puts "Error: This command must be run from a Rails project root directory"
|
|
443
|
+
exit 1
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def ensure_project_directories
|
|
448
|
+
# Create tmp/cone directory for socket
|
|
449
|
+
socket_dir = File.join(Dir.pwd, "tmp", "cone")
|
|
450
|
+
FileUtils.mkdir_p(socket_dir) unless Dir.exist?(socket_dir)
|
|
451
|
+
|
|
452
|
+
# Create session directory based on PWD
|
|
453
|
+
session_dir = project_session_dir
|
|
454
|
+
FileUtils.mkdir_p(session_dir) unless Dir.exist?(session_dir)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def project_session_dir
|
|
458
|
+
# Convert PWD to directory name (Claude Code style)
|
|
459
|
+
pwd_as_dirname = Dir.pwd.gsub("/", "-")
|
|
460
|
+
File.expand_path("~/.cone/sessions/#{pwd_as_dirname}")
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def project_socket_path(target = nil)
|
|
464
|
+
target ||= options[:target]
|
|
465
|
+
File.join(Dir.pwd, "tmp", "cone", "#{target}.socket")
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def project_pid_path(target = nil)
|
|
469
|
+
target ||= options[:target]
|
|
470
|
+
File.join(Dir.pwd, "tmp", "cone", "#{target}.pid")
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def project_log_path(target = nil)
|
|
474
|
+
target ||= options[:target]
|
|
475
|
+
File.join(Dir.pwd, "tmp", "cone", "#{target}.log")
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def send_code_to_socket(socket_path, code, timeout: 15)
|
|
479
|
+
request_id = SecureRandom.uuid
|
|
480
|
+
request = {
|
|
481
|
+
"action" => "eval",
|
|
482
|
+
"code" => code,
|
|
483
|
+
"timeout" => timeout,
|
|
484
|
+
"request_id" => request_id
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
Timeout.timeout(timeout + 5) do
|
|
488
|
+
socket = UNIXSocket.new(socket_path)
|
|
489
|
+
|
|
490
|
+
# Send request as single line JSON
|
|
491
|
+
socket.write(JSON.generate(request))
|
|
492
|
+
socket.write("\n")
|
|
493
|
+
socket.flush
|
|
494
|
+
|
|
495
|
+
# Read response
|
|
496
|
+
response_data = socket.gets
|
|
497
|
+
socket.close
|
|
498
|
+
|
|
499
|
+
JSON.parse(response_data)
|
|
500
|
+
end
|
|
501
|
+
rescue Timeout::Error
|
|
502
|
+
{ "success" => false, "error" => "Timeout", "message" => "Request timed out after #{timeout} seconds" }
|
|
503
|
+
rescue StandardError => e
|
|
504
|
+
{ "success" => false, "error" => e.class.name, "message" => e.message }
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def sessions_file_path
|
|
508
|
+
File.join(Dir.pwd, "tmp", "cone", "sessions.json")
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def create_rails_adapter(rails_env = "development", target = nil, command = nil)
|
|
512
|
+
target ||= options[:target]
|
|
513
|
+
|
|
514
|
+
Consolle::Adapters::RailsConsole.new(
|
|
515
|
+
socket_path: project_socket_path(target),
|
|
516
|
+
pid_path: project_pid_path(target),
|
|
517
|
+
log_path: project_log_path(target),
|
|
518
|
+
rails_root: Dir.pwd,
|
|
519
|
+
rails_env: rails_env,
|
|
520
|
+
verbose: options[:verbose],
|
|
521
|
+
command: command
|
|
522
|
+
)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def save_session_info(adapter)
|
|
526
|
+
target = options[:target]
|
|
527
|
+
|
|
528
|
+
with_sessions_lock do
|
|
529
|
+
sessions = load_sessions
|
|
530
|
+
|
|
531
|
+
sessions[target] = {
|
|
532
|
+
"socket_path" => adapter.socket_path,
|
|
533
|
+
"process_pid" => adapter.process_pid,
|
|
534
|
+
"pid_path" => project_pid_path(target),
|
|
535
|
+
"log_path" => project_log_path(target),
|
|
536
|
+
"started_at" => Time.now.to_f,
|
|
537
|
+
"rails_root" => Dir.pwd
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
save_sessions(sessions)
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def load_session_info
|
|
545
|
+
target = options[:target]
|
|
546
|
+
sessions = load_sessions
|
|
547
|
+
|
|
548
|
+
return nil if sessions.empty?
|
|
549
|
+
|
|
550
|
+
session = sessions[target]
|
|
551
|
+
return nil unless session
|
|
552
|
+
|
|
553
|
+
# Convert to symbolized keys for backward compatibility
|
|
554
|
+
{
|
|
555
|
+
socket_path: session["socket_path"],
|
|
556
|
+
process_pid: session["process_pid"],
|
|
557
|
+
started_at: session["started_at"],
|
|
558
|
+
rails_root: session["rails_root"]
|
|
559
|
+
}
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def clear_session_info
|
|
563
|
+
target = options[:target]
|
|
564
|
+
|
|
565
|
+
with_sessions_lock do
|
|
566
|
+
sessions = load_sessions
|
|
567
|
+
sessions.delete(target)
|
|
568
|
+
save_sessions(sessions)
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def log_session_activity(process_pid, code, result)
|
|
573
|
+
# Create log filename based on date and PID
|
|
574
|
+
log_file = File.join(project_session_dir, "session_#{Date.today.strftime('%Y%m%d')}_pid#{process_pid}.log")
|
|
575
|
+
|
|
576
|
+
# Create log entry
|
|
577
|
+
log_entry = {
|
|
578
|
+
timestamp: Time.now.iso8601,
|
|
579
|
+
request_id: result["request_id"],
|
|
580
|
+
code: code,
|
|
581
|
+
success: result["success"],
|
|
582
|
+
result: result["result"],
|
|
583
|
+
error: result["error"],
|
|
584
|
+
message: result["message"],
|
|
585
|
+
execution_time: result["execution_time"]
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
# Append to log file
|
|
589
|
+
File.open(log_file, "a") do |f|
|
|
590
|
+
f.puts JSON.generate(log_entry)
|
|
591
|
+
end
|
|
592
|
+
rescue StandardError => e
|
|
593
|
+
# Log errors should not crash the command
|
|
594
|
+
puts "Warning: Failed to log session activity: #{e.message}" if options[:verbose]
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def log_session_event(process_pid, event_type, details = {})
|
|
598
|
+
# Create log filename based on date and PID
|
|
599
|
+
log_file = File.join(project_session_dir, "session_#{Date.today.strftime('%Y%m%d')}_pid#{process_pid}.log")
|
|
600
|
+
|
|
601
|
+
# Create log entry
|
|
602
|
+
log_entry = {
|
|
603
|
+
timestamp: Time.now.iso8601,
|
|
604
|
+
event: event_type
|
|
605
|
+
}.merge(details)
|
|
606
|
+
|
|
607
|
+
# Append to log file
|
|
608
|
+
File.open(log_file, "a") do |f|
|
|
609
|
+
f.puts JSON.generate(log_entry)
|
|
610
|
+
end
|
|
611
|
+
rescue StandardError => e
|
|
612
|
+
# Log errors should not crash the command
|
|
613
|
+
puts "Warning: Failed to log session event: #{e.message}" if options[:verbose]
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def load_sessions
|
|
617
|
+
# Check for legacy session.json file first
|
|
618
|
+
legacy_file = File.join(Dir.pwd, "tmp", "cone", "session.json")
|
|
619
|
+
if File.exist?(legacy_file) && !File.exist?(sessions_file_path)
|
|
620
|
+
# Migrate from old format
|
|
621
|
+
migrate_legacy_session(legacy_file)
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
return {} unless File.exist?(sessions_file_path)
|
|
625
|
+
|
|
626
|
+
json = JSON.parse(File.read(sessions_file_path))
|
|
627
|
+
|
|
628
|
+
# Handle backward compatibility with old single-session format
|
|
629
|
+
if json.key?("socket_path")
|
|
630
|
+
# Legacy single session format - convert to new format
|
|
631
|
+
{ "cone" => json }
|
|
632
|
+
else
|
|
633
|
+
# New multi-session format
|
|
634
|
+
json
|
|
635
|
+
end
|
|
636
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
637
|
+
{}
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def migrate_legacy_session(legacy_file)
|
|
641
|
+
legacy_data = JSON.parse(File.read(legacy_file))
|
|
642
|
+
|
|
643
|
+
# Convert to new format
|
|
644
|
+
new_sessions = {
|
|
645
|
+
"_schema" => 1,
|
|
646
|
+
"cone" => legacy_data
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
# Write new format
|
|
650
|
+
File.write(sessions_file_path, JSON.pretty_generate(new_sessions))
|
|
651
|
+
|
|
652
|
+
# Remove old file
|
|
653
|
+
File.delete(legacy_file)
|
|
654
|
+
|
|
655
|
+
puts "Migrated session data to new multi-session format" if options[:verbose]
|
|
656
|
+
rescue StandardError => e
|
|
657
|
+
puts "Warning: Failed to migrate legacy session: #{e.message}" if options[:verbose]
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def save_sessions(sessions)
|
|
661
|
+
# Add schema version for future migrations
|
|
662
|
+
sessions_with_schema = { "_schema" => 1 }.merge(sessions)
|
|
663
|
+
|
|
664
|
+
# Write to temp file first for atomicity - use PID to avoid conflicts
|
|
665
|
+
temp_path = "#{sessions_file_path}.tmp.#{Process.pid}"
|
|
666
|
+
File.write(temp_path, JSON.pretty_generate(sessions_with_schema))
|
|
667
|
+
|
|
668
|
+
# Atomic rename - will overwrite existing file
|
|
669
|
+
File.rename(temp_path, sessions_file_path)
|
|
670
|
+
rescue StandardError => e
|
|
671
|
+
# Clean up temp file if rename fails
|
|
672
|
+
File.unlink(temp_path) if File.exist?(temp_path)
|
|
673
|
+
raise e
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def with_sessions_lock(&block)
|
|
677
|
+
# Ensure directory exists
|
|
678
|
+
FileUtils.mkdir_p(File.dirname(sessions_file_path))
|
|
679
|
+
|
|
680
|
+
# Create lock file separate from sessions file to avoid issues
|
|
681
|
+
lock_file_path = "#{sessions_file_path}.lock"
|
|
682
|
+
|
|
683
|
+
# Use file locking to prevent concurrent access
|
|
684
|
+
File.open(lock_file_path, File::RDWR | File::CREAT, 0644) do |f|
|
|
685
|
+
f.flock(File::LOCK_EX)
|
|
686
|
+
|
|
687
|
+
# Execute the block
|
|
688
|
+
yield
|
|
689
|
+
ensure
|
|
690
|
+
f.flock(File::LOCK_UN)
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def process_alive?(pid)
|
|
695
|
+
return false unless pid
|
|
696
|
+
|
|
697
|
+
Process.kill(0, pid)
|
|
698
|
+
true
|
|
699
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
700
|
+
false
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def validate_session_name!(name)
|
|
704
|
+
# Allow alphanumeric, hyphen, and underscore only
|
|
705
|
+
unless name.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
706
|
+
puts "Error: Invalid session name '#{name}'"
|
|
707
|
+
puts "Session names can only contain letters, numbers, hyphens (-), and underscores (_)"
|
|
708
|
+
exit 1
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# Check length (reasonable limit)
|
|
712
|
+
if name.length > 50
|
|
713
|
+
puts "Error: Session name is too long (maximum 50 characters)"
|
|
714
|
+
exit 1
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
end
|