consolle 0.3.9 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module Consolle
7
+ # Manages command history for sessions
8
+ class History
9
+ attr_reader :registry
10
+
11
+ def initialize(project_path = Dir.pwd)
12
+ @registry = SessionRegistry.new(project_path)
13
+ end
14
+
15
+ # Log a command execution to session history
16
+ def log_command(session_id:, target:, code:, result:)
17
+ log_path = registry.history_log_path(session_id)
18
+
19
+ entry = {
20
+ 'timestamp' => Time.now.iso8601,
21
+ 'session_id' => session_id,
22
+ 'target' => target,
23
+ 'request_id' => result['request_id'],
24
+ 'code' => code,
25
+ 'success' => result['success'],
26
+ 'result' => result['result'],
27
+ 'error' => result['error'],
28
+ 'message' => result['message'],
29
+ 'execution_time' => result['execution_time']
30
+ }
31
+
32
+ FileUtils.mkdir_p(File.dirname(log_path))
33
+ File.open(log_path, 'a') do |f|
34
+ f.puts JSON.generate(entry)
35
+ end
36
+
37
+ # Update command count in registry
38
+ registry.record_command(session_id)
39
+
40
+ entry
41
+ rescue StandardError
42
+ nil
43
+ end
44
+
45
+ # Query history for a session
46
+ def query(session_id: nil, target: nil, limit: nil, today: false, date: nil,
47
+ success_only: false, failed_only: false, grep: nil, all_sessions: false)
48
+ entries = []
49
+
50
+ # Get sessions to query
51
+ sessions = if session_id
52
+ [registry.find_session(session_id: session_id)]
53
+ elsif target
54
+ if all_sessions
55
+ # All sessions with this target (including stopped)
56
+ registry.list_sessions(include_stopped: true).select { |s| s['target'] == target }
57
+ else
58
+ # Most recent session with this target
59
+ [registry.find_session(target: target)]
60
+ end
61
+ else
62
+ # Current project sessions
63
+ if all_sessions
64
+ registry.list_sessions(include_stopped: true)
65
+ else
66
+ registry.list_sessions(include_stopped: false)
67
+ end
68
+ end
69
+
70
+ sessions.compact.each do |session|
71
+ session_entries = load_history(session['id'])
72
+ session_entries.each { |e| e['_session'] = session }
73
+ entries.concat(session_entries)
74
+ end
75
+
76
+ # Apply filters
77
+ entries = filter_by_date(entries, today: today, date: date)
78
+ entries = filter_by_status(entries, success_only: success_only, failed_only: failed_only)
79
+ entries = filter_by_grep(entries, grep) if grep
80
+
81
+ # Sort by timestamp descending
82
+ entries = entries.sort_by { |e| e['timestamp'] }.reverse
83
+
84
+ # Apply limit
85
+ entries = entries.first(limit) if limit
86
+
87
+ entries
88
+ end
89
+
90
+ # Format history entry for display (compact)
91
+ def format_entry(entry, show_session: true)
92
+ timestamp = Time.parse(entry['timestamp']).strftime('%Y-%m-%d %H:%M:%S')
93
+ session_prefix = show_session ? "[#{entry['_session']&.dig('short_id') || entry['session_id']&.[](0, 4)}] " : ''
94
+ code_preview = entry['code'].to_s.gsub("\n", ' ').strip
95
+ code_preview = "#{code_preview[0, 60]}..." if code_preview.length > 63
96
+
97
+ lines = []
98
+ lines << "#{session_prefix}#{timestamp} | #{code_preview}"
99
+
100
+ if entry['success']
101
+ result_preview = entry['result'].to_s.gsub("\n", ' ').strip
102
+ result_preview = "#{result_preview[0, 70]}..." if result_preview.length > 73
103
+ exec_time = entry['execution_time'] ? " (#{entry['execution_time'].round(3)}s)" : ''
104
+ lines << "#{result_preview}#{exec_time}"
105
+ else
106
+ error_msg = entry['error'] || entry['message'] || 'Error'
107
+ lines << "ERROR: #{error_msg}"
108
+ end
109
+
110
+ lines.join("\n")
111
+ end
112
+
113
+ # Format history entry for verbose display
114
+ def format_entry_verbose(entry)
115
+ lines = []
116
+ lines << '━' * 60
117
+ timestamp = Time.parse(entry['timestamp']).strftime('%Y-%m-%d %H:%M:%S')
118
+ session_info = entry['_session']
119
+ session_str = session_info ? "#{session_info['short_id']} (#{session_info['target']})" : entry['session_id']
120
+
121
+ lines << "[#{timestamp}] Session: #{session_str}"
122
+ lines << '━' * 60
123
+ lines << 'Code:'
124
+ entry['code'].to_s.lines.each { |line| lines << " #{line.chomp}" }
125
+ lines << ''
126
+
127
+ if entry['success']
128
+ lines << 'Result:'
129
+ entry['result'].to_s.lines.each { |line| lines << " #{line.chomp}" }
130
+ else
131
+ lines << "Error: #{entry['error']}"
132
+ lines << entry['message'] if entry['message']
133
+ end
134
+
135
+ lines << ''
136
+ lines << "Execution time: #{entry['execution_time']&.round(3)}s" if entry['execution_time']
137
+ lines << '━' * 60
138
+
139
+ lines.join("\n")
140
+ end
141
+
142
+ # Format history as JSON
143
+ def format_json(entries)
144
+ JSON.pretty_generate(entries.map { |e| e.except('_session') })
145
+ end
146
+
147
+ # Get total command count for a session
148
+ def command_count(session_id)
149
+ load_history(session_id).size
150
+ end
151
+
152
+ private
153
+
154
+ def load_history(session_id)
155
+ log_path = registry.history_log_path(session_id)
156
+ return [] unless File.exist?(log_path)
157
+
158
+ entries = []
159
+ File.readlines(log_path).each do |line|
160
+ entry = JSON.parse(line.strip)
161
+ entries << entry
162
+ rescue JSON::ParserError
163
+ next
164
+ end
165
+
166
+ entries
167
+ end
168
+
169
+ def filter_by_date(entries, today: false, date: nil)
170
+ return entries unless today || date
171
+
172
+ target_date = if today
173
+ Date.today
174
+ elsif date.is_a?(String)
175
+ Date.parse(date)
176
+ else
177
+ date
178
+ end
179
+
180
+ entries.select do |e|
181
+ entry_date = Time.parse(e['timestamp']).to_date
182
+ entry_date == target_date
183
+ end
184
+ end
185
+
186
+ def filter_by_status(entries, success_only: false, failed_only: false)
187
+ return entries unless success_only || failed_only
188
+
189
+ if success_only
190
+ entries.select { |e| e['success'] }
191
+ elsif failed_only
192
+ entries.reject { |e| e['success'] }
193
+ else
194
+ entries
195
+ end
196
+ end
197
+
198
+ def filter_by_grep(entries, pattern)
199
+ regex = Regexp.new(pattern, Regexp::IGNORECASE)
200
+ entries.select do |e|
201
+ e['code']&.match?(regex) || e['result']&.match?(regex)
202
+ end
203
+ rescue RegexpError
204
+ # If invalid regex, treat as literal string
205
+ entries.select do |e|
206
+ e['code']&.include?(pattern) || e['result']&.include?(pattern)
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'securerandom'
6
+
7
+ module Consolle
8
+ # Manages session registry with unique session IDs
9
+ # Provides persistent storage for session metadata even after sessions stop
10
+ class SessionRegistry
11
+ SCHEMA_VERSION = 2
12
+ SESSION_ID_LENGTH = 8
13
+ SHORT_ID_LENGTH = 4
14
+
15
+ attr_reader :project_path
16
+
17
+ def initialize(project_path = Dir.pwd)
18
+ @project_path = project_path
19
+ end
20
+
21
+ # Generate a new unique session ID (8 hex characters)
22
+ def generate_session_id
23
+ SecureRandom.hex(SESSION_ID_LENGTH / 2)
24
+ end
25
+
26
+ # Get short ID (first 4 characters)
27
+ def short_id(session_id)
28
+ session_id[0, SHORT_ID_LENGTH]
29
+ end
30
+
31
+ # Create a new session entry
32
+ def create_session(target:, socket_path:, pid:, rails_env:, mode:)
33
+ session_id = generate_session_id
34
+
35
+ session_data = {
36
+ 'id' => session_id,
37
+ 'short_id' => short_id(session_id),
38
+ 'target' => target,
39
+ 'project' => project_path,
40
+ 'project_hash' => project_hash,
41
+ 'rails_env' => rails_env,
42
+ 'mode' => mode,
43
+ 'status' => 'running',
44
+ 'pid' => pid,
45
+ 'socket_path' => socket_path,
46
+ 'created_at' => Time.now.iso8601,
47
+ 'started_at' => Time.now.iso8601,
48
+ 'stopped_at' => nil,
49
+ 'last_activity_at' => Time.now.iso8601,
50
+ 'command_count' => 0
51
+ }
52
+
53
+ with_registry_lock do
54
+ registry = load_registry
55
+ registry['sessions'][session_id] = session_data
56
+ save_registry(registry)
57
+ end
58
+
59
+ # Create session directory
60
+ ensure_session_directory(session_id)
61
+
62
+ # Save session metadata
63
+ save_session_metadata(session_id, session_data)
64
+
65
+ session_data
66
+ end
67
+
68
+ # Update session status to stopped
69
+ def stop_session(session_id: nil, target: nil, reason: 'user_requested')
70
+ session = find_session(session_id: session_id, target: target)
71
+ return nil unless session
72
+
73
+ with_registry_lock do
74
+ registry = load_registry
75
+ if registry['sessions'][session['id']]
76
+ registry['sessions'][session['id']]['status'] = 'stopped'
77
+ registry['sessions'][session['id']]['stopped_at'] = Time.now.iso8601
78
+ registry['sessions'][session['id']]['stop_reason'] = reason
79
+ save_registry(registry)
80
+ end
81
+ end
82
+
83
+ # Update metadata file
84
+ metadata = load_session_metadata(session['id'])
85
+ if metadata
86
+ metadata['status'] = 'stopped'
87
+ metadata['stopped_at'] = Time.now.iso8601
88
+ metadata['stop_reason'] = reason
89
+ save_session_metadata(session['id'], metadata)
90
+ end
91
+
92
+ session
93
+ end
94
+
95
+ # Record command execution
96
+ def record_command(session_id)
97
+ with_registry_lock do
98
+ registry = load_registry
99
+ if registry['sessions'][session_id]
100
+ registry['sessions'][session_id]['command_count'] ||= 0
101
+ registry['sessions'][session_id]['command_count'] += 1
102
+ registry['sessions'][session_id]['last_activity_at'] = Time.now.iso8601
103
+ save_registry(registry)
104
+ end
105
+ end
106
+ end
107
+
108
+ # Find session by ID (full or short) or target name
109
+ def find_session(session_id: nil, target: nil, status: nil)
110
+ registry = load_registry
111
+
112
+ if session_id
113
+ # Try exact match first
114
+ session = registry['sessions'][session_id]
115
+ return session if session && (status.nil? || session['status'] == status)
116
+
117
+ # Try short ID match
118
+ matches = registry['sessions'].values.select do |s|
119
+ s['short_id'] == session_id && s['project'] == project_path
120
+ end
121
+ matches = matches.select { |s| s['status'] == status } if status
122
+ return matches.first if matches.size == 1
123
+
124
+ # Ambiguous short ID
125
+ return nil if matches.size > 1
126
+ end
127
+
128
+ if target
129
+ # Find by target name in current project
130
+ matches = registry['sessions'].values.select do |s|
131
+ s['target'] == target && s['project'] == project_path
132
+ end
133
+ matches = matches.select { |s| s['status'] == status } if status
134
+
135
+ # Return most recent running session, or most recently created
136
+ matches.sort_by { |s| s['created_at'] }.last
137
+ end
138
+ end
139
+
140
+ # Find running session by target
141
+ def find_running_session(target:)
142
+ find_session(target: target, status: 'running')
143
+ end
144
+
145
+ # List sessions for current project
146
+ def list_sessions(include_stopped: false, all_projects: false)
147
+ registry = load_registry
148
+
149
+ sessions = registry['sessions'].values
150
+
151
+ # Filter by project unless all_projects
152
+ sessions = sessions.select { |s| s['project'] == project_path } unless all_projects
153
+
154
+ # Filter by status
155
+ sessions = sessions.select { |s| s['status'] == 'running' } unless include_stopped
156
+
157
+ # Sort by created_at descending
158
+ sessions.sort_by { |s| s['created_at'] }.reverse
159
+ end
160
+
161
+ # List only stopped sessions
162
+ def list_stopped_sessions(all_projects: false)
163
+ registry = load_registry
164
+
165
+ sessions = registry['sessions'].values.select { |s| s['status'] == 'stopped' }
166
+
167
+ # Filter by project unless all_projects
168
+ sessions = sessions.select { |s| s['project'] == project_path } unless all_projects
169
+
170
+ # Sort by stopped_at descending
171
+ sessions.sort_by { |s| s['stopped_at'] || s['created_at'] }.reverse
172
+ end
173
+
174
+ # Remove session and its history
175
+ def remove_session(session_id: nil, target: nil)
176
+ session = find_session(session_id: session_id, target: target)
177
+ return nil unless session
178
+
179
+ # Check if running
180
+ if session['status'] == 'running'
181
+ return { error: 'running', session: session }
182
+ end
183
+
184
+ with_registry_lock do
185
+ registry = load_registry
186
+ registry['sessions'].delete(session['id'])
187
+ save_registry(registry)
188
+ end
189
+
190
+ # Remove session directory
191
+ session_dir = session_directory(session['id'])
192
+ FileUtils.rm_rf(session_dir) if Dir.exist?(session_dir)
193
+
194
+ session
195
+ end
196
+
197
+ # Remove all stopped sessions (prune)
198
+ def prune_sessions(all_projects: false)
199
+ stopped = list_stopped_sessions(all_projects: all_projects)
200
+ removed = []
201
+
202
+ stopped.each do |session|
203
+ result = remove_session(session_id: session['id'])
204
+ # Check if result is session data (success) vs error hash
205
+ removed << result if result && !result.key?(:error)
206
+ end
207
+
208
+ removed
209
+ end
210
+
211
+ # Get session directory path
212
+ def session_directory(session_id)
213
+ File.join(sessions_base_dir, session_id)
214
+ end
215
+
216
+ # Get history log path for session
217
+ def history_log_path(session_id)
218
+ File.join(session_directory(session_id), 'history.log')
219
+ end
220
+
221
+ # Migration from old sessions.json format
222
+ def migrate_from_legacy
223
+ legacy_sessions_file = File.join(project_path, 'tmp', 'cone', 'sessions.json')
224
+ return unless File.exist?(legacy_sessions_file)
225
+
226
+ legacy_data = JSON.parse(File.read(legacy_sessions_file))
227
+ return if legacy_data.empty?
228
+
229
+ legacy_data.each do |target, info|
230
+ next if target == '_schema'
231
+
232
+ # Create new session entry for running legacy sessions
233
+ if info['process_pid'] && process_alive?(info['process_pid'])
234
+ create_session(
235
+ target: target,
236
+ socket_path: info['socket_path'],
237
+ pid: info['process_pid'],
238
+ rails_env: 'development',
239
+ mode: 'pty'
240
+ )
241
+ end
242
+ end
243
+ end
244
+
245
+ private
246
+
247
+ def registry_path
248
+ File.expand_path('~/.cone/registry.json')
249
+ end
250
+
251
+ def sessions_base_dir
252
+ File.join(File.expand_path('~/.cone/sessions'), project_hash)
253
+ end
254
+
255
+ def project_hash
256
+ project_path.gsub('/', '-')
257
+ end
258
+
259
+ def ensure_session_directory(session_id)
260
+ dir = session_directory(session_id)
261
+ FileUtils.mkdir_p(dir)
262
+ dir
263
+ end
264
+
265
+ def load_registry
266
+ FileUtils.mkdir_p(File.dirname(registry_path))
267
+
268
+ if File.exist?(registry_path)
269
+ data = JSON.parse(File.read(registry_path))
270
+ # Ensure schema version
271
+ data['_schema'] ||= SCHEMA_VERSION
272
+ data['sessions'] ||= {}
273
+ data
274
+ else
275
+ { '_schema' => SCHEMA_VERSION, 'sessions' => {} }
276
+ end
277
+ rescue JSON::ParserError
278
+ { '_schema' => SCHEMA_VERSION, 'sessions' => {} }
279
+ end
280
+
281
+ def save_registry(registry)
282
+ registry['_schema'] = SCHEMA_VERSION
283
+ temp_path = "#{registry_path}.tmp.#{Process.pid}"
284
+ File.write(temp_path, JSON.pretty_generate(registry))
285
+ File.rename(temp_path, registry_path)
286
+ rescue StandardError => e
287
+ File.unlink(temp_path) if File.exist?(temp_path)
288
+ raise e
289
+ end
290
+
291
+ def with_registry_lock
292
+ FileUtils.mkdir_p(File.dirname(registry_path))
293
+ lock_file_path = "#{registry_path}.lock"
294
+
295
+ File.open(lock_file_path, File::RDWR | File::CREAT, 0o644) do |f|
296
+ f.flock(File::LOCK_EX)
297
+ yield
298
+ ensure
299
+ f.flock(File::LOCK_UN)
300
+ end
301
+ end
302
+
303
+ def load_session_metadata(session_id)
304
+ metadata_path = File.join(session_directory(session_id), 'metadata.json')
305
+ return nil unless File.exist?(metadata_path)
306
+
307
+ JSON.parse(File.read(metadata_path))
308
+ rescue JSON::ParserError
309
+ nil
310
+ end
311
+
312
+ def save_session_metadata(session_id, metadata)
313
+ ensure_session_directory(session_id)
314
+ metadata_path = File.join(session_directory(session_id), 'metadata.json')
315
+ File.write(metadata_path, JSON.pretty_generate(metadata))
316
+ end
317
+
318
+ def process_alive?(pid)
319
+ return false unless pid
320
+
321
+ Process.kill(0, pid)
322
+ true
323
+ rescue Errno::ESRCH, Errno::EPERM
324
+ false
325
+ end
326
+ end
327
+ end
data/rule.ko.md CHANGED
@@ -8,6 +8,15 @@ Rails console과 마찬가지로 세션 내에서 실행한 결과는 유지되
8
8
 
9
9
  사용 전에는 `status`로 상태를 확인하고, 작업 종료 후에는 `stop`해야 합니다.
10
10
 
11
+ ## 설치 참고사항
12
+
13
+ Gemfile이 있는 프로젝트에서는 `bundle exec`를 사용하는 것이 좋습니다:
14
+
15
+ ```bash
16
+ $ bundle exec cone start
17
+ $ bundle exec cone exec 'User.count'
18
+ ```
19
+
11
20
  ## Cone의 용도
12
21
 
13
22
  Cone은 디버깅, 데이터 탐색, 그리고 개발 보조 도구로 사용됩니다.
@@ -27,9 +36,21 @@ $ cone start # 서버 시작 (RAILS_ENV가 없으면 development)
27
36
  $ RAILS_ENV=test cone start # test 환경에서 console 시작
28
37
  ```
29
38
 
39
+ 시작 시 고유한 Session ID를 포함한 세션 정보가 표시됩니다:
40
+
41
+ ```bash
42
+ $ cone start
43
+ ✓ Rails console started
44
+ Session ID: a1b2c3d4 (a1b2)
45
+ Target: cone
46
+ Environment: development
47
+ PID: 12345
48
+ Socket: /path/to/cone.socket
49
+ ```
50
+
30
51
  중지와 재시작 명령어도 제공합니다.
31
52
 
32
- Cone은 한 번에 하나의 세션만 제공하며, 실행 환경을 변경하려면 반드시 중지 후 재시작해야 합니다.
53
+ Cone은 타겟당 한 번에 하나의 세션만 제공하며, 실행 환경을 변경하려면 반드시 중지 후 재시작해야 합니다.
33
54
 
34
55
  ```bash
35
56
  $ cone stop # 서버 중지
@@ -37,6 +58,51 @@ $ cone stop # 서버 중지
37
58
 
38
59
  작업을 마치면 반드시 종료해 주세요.
39
60
 
61
+ ## 세션 관리
62
+
63
+ ### 세션 목록 보기
64
+
65
+ ```bash
66
+ $ cone ls # 활성 세션만 표시
67
+ $ cone ls -a # 종료된 세션 포함 전체 표시
68
+ ```
69
+
70
+ 출력 예시:
71
+ ```
72
+ ACTIVE SESSIONS:
73
+
74
+ ID TARGET ENV STATUS UPTIME COMMANDS
75
+ a1b2 cone development running 2h 15m 42
76
+ e5f6 api production running 1h 30m 15
77
+
78
+ Usage: cone exec -t TARGET CODE
79
+ cone exec --session ID CODE
80
+ ```
81
+
82
+ ### 세션 히스토리
83
+
84
+ 세션별 명령어 히스토리를 조회합니다:
85
+
86
+ ```bash
87
+ $ cone history # 현재 세션 히스토리
88
+ $ cone history -t api # 'api' 타겟의 히스토리
89
+ $ cone history --session a1b2 # 특정 세션 ID의 히스토리
90
+ $ cone history -n 10 # 최근 10개 명령어
91
+ $ cone history --today # 오늘 실행한 명령어만
92
+ $ cone history --failed # 실패한 명령어만
93
+ $ cone history --grep User # 패턴으로 필터링
94
+ $ cone history --json # JSON 형식으로 출력
95
+ ```
96
+
97
+ ### 세션 삭제
98
+
99
+ ```bash
100
+ $ cone rm a1b2 # 종료된 세션 ID로 삭제
101
+ $ cone rm -f a1b2 # 강제 삭제 (실행 중이면 중지 후 삭제)
102
+ $ cone prune # 종료된 모든 세션 삭제
103
+ $ cone prune --yes # 확인 없이 삭제
104
+ ```
105
+
40
106
  ## 실행 모드
41
107
 
42
108
  Cone은 세 가지 실행 모드를 지원합니다. `--mode` 옵션으로 지정할 수 있습니다.
@@ -82,10 +148,13 @@ mode: embed-rails
82
148
  ```bash
83
149
  $ cone status
84
150
  ✓ Rails console is running
85
- PID: 36384
86
- Environment: test
87
- Session: /Users/ben/syncthing/workspace/karrot-inhouse/ehr/tmp/cone/cone.socket
88
- Ready for input: Yes
151
+ Session ID: a1b2c3d4 (a1b2)
152
+ Target: cone
153
+ Environment: development
154
+ PID: 12345
155
+ Uptime: 2h 15m
156
+ Commands: 42
157
+ Socket: /path/to/cone.socket
89
158
  ```
90
159
 
91
160
  ## 코드 실행