tayo 0.2.0 → 0.2.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5fa91902d5a43f2bd8259bdd3949017b629c6f228cf68600f9b4fd238a13299
4
- data.tar.gz: 66e296458abdfca8cebf3e919be4f36f06fcbb1238cefd2e441d96cc193fcdda
3
+ metadata.gz: a128e0d938a02268ef73d7c92f956d9f316923eaf1ba7c8827f416a738e2365f
4
+ data.tar.gz: 6e8c5fa59202380639d5f44dc05ea1723b3df5018a15ae9ec56bb5acf2765f58
5
5
  SHA512:
6
- metadata.gz: 876880104575214b91231682c947c40a73ea233dd558644bb5d34d75e90e5b4a6046185e9e650c9a945d5d9e3d4fd77cc29a4fefbabb83d1c1f82683d2b5ce9b
7
- data.tar.gz: 2955f65d56f1395e823b159cc64fd2285dfe523dae26b21ea2aa896b350c499029d9fe84908fdbfad0ffbc50734e309386f1ef2613517f087c7499205d06dc36
6
+ metadata.gz: 4a08ad8b3b192257dbbae0cf19f007210f70e303d7b64727eb388d1381b45ac36d9b3ff9e2441490951c37eb7732da2cd0e4e14fa771fd0b3b58685f36f05490
7
+ data.tar.gz: 53919de826cf6970b5cd96c9bbeda335c6346df981cd9f46b7790c8f5fb310baf2362b704477ee4a5d5f84d487260ec794ece693fc67bcebbe9d9278acd22943
@@ -0,0 +1,53 @@
1
+ # tayo cf 명령어 리팩토링
2
+
3
+ **날짜**: 2025-11-29
4
+
5
+ ## 변경 사항
6
+
7
+ ### 문제점
8
+
9
+ 1. **흐름이 어색함**: 도메인 입력을 먼저 받고 나서 Cloudflare Zone 목록을 보여주는 순서가 비직관적
10
+ 2. **기존 레코드 확인 버그**: `check_existing_records`가 `domain_info`를 파라미터로 받지만 사용하지 않고, 항상 루트 도메인만 조회
11
+ 3. **권한 안내 오류**: DNS 편집 권한만 있으면 읽기도 가능한데, 불필요하게 읽기 권한도 안내
12
+
13
+ ### 해결
14
+
15
+ #### 새로운 흐름
16
+
17
+ ```
18
+ 기존: 변경 후:
19
+ 1. 도메인 입력 1. 토큰 입력
20
+ 2. 토큰 입력 2. Zone 선택
21
+ 3. Zone 선택 3. 기존 레코드 목록 표시
22
+ 4. 기존 레코드 확인 (버그) 4. 서비스 도메인 입력
23
+ 5. DNS 레코드 설정 5. 홈서버 연결 정보 입력
24
+ 6. deploy.yml 업데이트 6. DNS 레코드 생성/수정
25
+ 7. Git 커밋 7. deploy.yml 업데이트
26
+ 8. Git 커밋
27
+ ```
28
+
29
+ #### 주요 변경 내용
30
+
31
+ 1. **`show_existing_records` 메서드 추가**
32
+ - Zone 전체의 A/CNAME 레코드를 조회하여 표시
33
+ - `get_all_dns_records` 헬퍼 메서드로 name 필터 없이 조회
34
+
35
+ 2. **`get_domain_input` 수정**
36
+ - Zone 정보를 파라미터로 받아서 활용
37
+ - 서브도메인 사용 여부를 먼저 질문 후 입력받음
38
+
39
+ 3. **`get_server_info` 메서드 분리**
40
+ - 기존 `setup_dns_record`에서 서버 정보 입력 로직 분리
41
+ - "홈서버 연결 정보" 단계로 명명
42
+
43
+ 4. **`setup_dns_record` 단순화**
44
+ - 인스턴스 변수 대신 파라미터로 데이터 전달
45
+ - `determine_final_domain` 로직 제거 (사용자가 직접 선택)
46
+
47
+ 5. **권한 안내 수정**
48
+ - "영역 → DNS → 읽기" 제거
49
+ - "영역 → DNS → 편집"만 안내
50
+
51
+ ## 파일 변경
52
+
53
+ - `lib/tayo/commands/cf.rb`: 143 insertions, 113 deletions
data/lib/tayo/cli.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "commands/init"
6
6
  require_relative "commands/gh"
7
7
  require_relative "commands/cf"
8
8
  require_relative "commands/proxy"
9
+ require_relative "commands/sqlite"
9
10
 
10
11
  module Tayo
11
12
  class CLI < Thor
@@ -29,6 +30,11 @@ module Tayo
29
30
  Commands::Proxy.new.execute
30
31
  end
31
32
 
33
+ desc "sqlite", "SQLite + Solid Cable 최적화 설정을 적용합니다"
34
+ def sqlite
35
+ Commands::Sqlite.new.execute
36
+ end
37
+
32
38
  desc "version", "Tayo 버전을 표시합니다"
33
39
  def version
34
40
  puts "Tayo #{VERSION}"
@@ -12,61 +12,78 @@ module Tayo
12
12
  def execute
13
13
  puts "☁️ Cloudflare DNS 설정을 시작합니다...".colorize(:green)
14
14
 
15
- unless rails_project?
16
- puts "❌ Rails 프로젝트가 아닙니다. Rails 프로젝트 루트에서 실행해주세요.".colorize(:red)
17
- return
18
- end
19
-
20
- # 1. 도메인 입력받기
21
- domain_info = get_domain_input
22
-
23
- # 2. Cloudflare 토큰 생성 페이지 열기 및 권한 안내
15
+ # 1. Cloudflare 토큰 생성 페이지 열기 및 권한 안내
24
16
  open_token_creation_page
25
-
26
- # 3. 토큰 입력받기
17
+
18
+ # 2. 토큰 입력받기
27
19
  token = get_cloudflare_token
28
-
29
- # 4. Cloudflare API로 도메인 목록 조회 및 선택
20
+
21
+ # 3. Cloudflare API로 도메인 목록 조회 및 선택
30
22
  selected_zone = select_cloudflare_zone(token)
31
-
32
- # 5. 루트 도메인 레코드 확인
33
- existing_records = check_existing_records(token, selected_zone, domain_info)
34
-
35
- # 6. DNS 레코드 추가/수정
36
- setup_dns_record(token, selected_zone, domain_info, existing_records)
37
-
38
- # 7. config/deploy.yml 업데이트
39
- update_deploy_config(domain_info)
23
+
24
+ # 4. 기존 레코드 목록 표시
25
+ show_existing_records(token, selected_zone)
26
+
27
+ # 5. 서비스 도메인 입력받기
28
+ domain_info = get_domain_input(selected_zone)
29
+
30
+ # 6. 홈서버 연결 정보 입력받기
31
+ server_info = get_server_info
32
+
33
+ # 7. DNS 레코드 추가/수정
34
+ setup_dns_record(token, selected_zone, domain_info, server_info)
35
+
36
+ # 8. config/deploy.yml 업데이트
37
+ update_deploy_config(domain_info, server_info)
40
38
 
41
39
  puts "\n🎉 Cloudflare DNS 설정이 완료되었습니다!".colorize(:green)
42
-
40
+
43
41
  # 변경사항 커밋
44
42
  commit_cloudflare_changes(domain_info)
45
43
  end
46
44
 
47
45
  private
48
46
 
49
- def rails_project?
50
- File.exist?("Gemfile") && File.exist?("config/application.rb")
47
+ def get_domain_input(zone)
48
+ prompt = TTY::Prompt.new
49
+ zone_name = zone['name']
50
+
51
+ puts "📝 서비스 도메인을 설정합니다.".colorize(:yellow)
52
+ puts " 선택된 Zone: #{zone_name}".colorize(:gray)
53
+
54
+ use_subdomain = prompt.yes?("서브도메인을 사용하시겠습니까? (예: app.#{zone_name})")
55
+
56
+ if use_subdomain
57
+ subdomain = prompt.ask("서브도메인을 입력하세요 (예: app, api, www):") do |q|
58
+ q.validate(/\A[a-zA-Z0-9-]+\z/, "올바른 서브도메인을 입력해주세요 (영문, 숫자, 하이픈만 가능)")
59
+ end
60
+ full_domain = "#{subdomain}.#{zone_name}"
61
+ { type: :subdomain, domain: full_domain, zone: zone_name, subdomain: subdomain }
62
+ else
63
+ { type: :root, domain: zone_name, zone: zone_name }
64
+ end
51
65
  end
52
66
 
53
- def get_domain_input
67
+ def get_server_info
54
68
  prompt = TTY::Prompt.new
55
-
56
- puts "\n📝 배포할 도메인을 설정합니다.".colorize(:yellow)
57
-
58
- domain = prompt.ask("배포할 도메인을 입력하세요 (예: myapp.com, api.example.com):") do |q|
59
- q.validate(/\A[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z/, "올바른 도메인 형식을 입력해주세요 (예: myapp.com)")
60
- end
61
-
62
- # 도메인이 루트인지 서브도메인인지 판단
63
- parts = domain.split('.')
64
- if parts.length == 2
65
- { type: :root, domain: domain, zone: domain }
66
- else
67
- zone = parts[-2..-1].join('.')
68
- { type: :subdomain, domain: domain, zone: zone, subdomain: parts[0..-3].join('.') }
69
+
70
+ puts "\n🖥️ 홈서버 연결 정보를 입력합니다.".colorize(:yellow)
71
+
72
+ server_address = prompt.ask("홈서버 IP 또는 도메인을 입력하세요:") do |q|
73
+ q.validate(/\A.+\z/, "서버 정보를 입력해주세요")
69
74
  end
75
+
76
+ ssh_user = prompt.ask("SSH 사용자 계정을 입력하세요:", default: "root")
77
+
78
+ # IP인지 도메인인지 판단
79
+ is_ip = server_address.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
80
+ record_type = is_ip ? 'A' : 'CNAME'
81
+
82
+ {
83
+ address: server_address,
84
+ ssh_user: ssh_user,
85
+ record_type: record_type
86
+ }
70
87
  end
71
88
 
72
89
  def open_token_creation_page
@@ -79,12 +96,10 @@ module Tayo
79
96
  puts "\n다음 권한으로 토큰을 생성해주세요:".colorize(:yellow)
80
97
  puts ""
81
98
  puts "한국어 화면:".colorize(:gray)
82
- puts "• 영역 → DNS → 읽기".colorize(:white)
83
99
  puts "• 영역 → DNS → 편집".colorize(:white)
84
100
  puts " (영역 리소스는 '모든 영역' 선택)".colorize(:gray)
85
101
  puts ""
86
102
  puts "English:".colorize(:gray)
87
- puts "• Zone → DNS → Read".colorize(:white)
88
103
  puts "• Zone → DNS → Edit".colorize(:white)
89
104
  puts " (Zone Resources: Select 'All zones')".colorize(:gray)
90
105
  puts ""
@@ -171,18 +186,53 @@ module Tayo
171
186
  exit 1
172
187
  end
173
188
 
174
- def check_existing_records(token, zone, domain_info)
189
+ def show_existing_records(token, zone)
175
190
  puts "\n🔍 기존 DNS 레코드를 확인합니다...".colorize(:yellow)
176
-
191
+
177
192
  zone_id = zone['id']
178
193
  zone_name = zone['name']
179
-
180
- # 루트 도메인의 A/CNAME 레코드 확인
181
- records = get_dns_records(token, zone_id, zone_name, ['A', 'CNAME'])
182
-
183
- puts "기존 레코드: #{records.length}개 발견".colorize(:gray)
184
-
185
- return records
194
+
195
+ # Zone의 모든 A/CNAME 레코드 조회
196
+ records = get_all_dns_records(token, zone_id, ['A', 'CNAME'])
197
+
198
+ if records.empty?
199
+ puts " 등록된 A/CNAME 레코드가 없습니다.".colorize(:gray)
200
+ else
201
+ puts " #{zone_name}의 기존 레코드:".colorize(:cyan)
202
+ records.each do |record|
203
+ puts " • #{record['name']} → #{record['content']} (#{record['type']})".colorize(:white)
204
+ end
205
+ end
206
+
207
+ puts ""
208
+ end
209
+
210
+ def get_all_dns_records(token, zone_id, types)
211
+ records = []
212
+
213
+ types.each do |type|
214
+ uri = URI("https://api.cloudflare.com/client/v4/zones/#{zone_id}/dns_records")
215
+ uri.query = URI.encode_www_form({ type: type })
216
+
217
+ http = Net::HTTP.new(uri.host, uri.port)
218
+ http.use_ssl = true
219
+
220
+ request = Net::HTTP::Get.new(uri)
221
+ request['Authorization'] = "Bearer #{token}"
222
+ request['Content-Type'] = 'application/json'
223
+
224
+ response = http.request(request)
225
+
226
+ if response.code == '200'
227
+ data = JSON.parse(response.body)
228
+ records.concat(data['result'] || [])
229
+ end
230
+ end
231
+
232
+ records.sort_by { |r| r['name'] }
233
+ rescue => e
234
+ puts "⚠️ DNS 레코드 조회 중 오류: #{e.message}".colorize(:yellow)
235
+ []
186
236
  end
187
237
 
188
238
  def get_dns_records(token, zone_id, name, types)
@@ -216,73 +266,41 @@ module Tayo
216
266
  return []
217
267
  end
218
268
 
219
- def setup_dns_record(token, zone, domain_info, existing_records)
269
+ def setup_dns_record(token, zone, domain_info, server_info)
220
270
  puts "\n⚙️ DNS 레코드를 설정합니다...".colorize(:yellow)
221
-
222
- # 홈서버 IP/URL 입력받기
223
- prompt = TTY::Prompt.new
224
-
225
- server_info = prompt.ask("홈서버 IP 또는 도메인을 입력하세요:") do |q|
226
- q.validate(/\A.+\z/, "서버 정보를 입력해주세요")
227
- end
228
-
229
- # SSH 사용자 계정 입력받기
230
- ssh_user = prompt.ask("SSH 사용자 계정을 입력하세요:", default: "root")
231
-
232
- # IP인지 도메인인지 판단
233
- is_ip = server_info.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
234
- record_type = is_ip ? 'A' : 'CNAME'
235
-
271
+
236
272
  zone_id = zone['id']
237
- zone_name = zone['name']
238
-
239
- # 도메인 정보에 따라 레코드 설정
240
- final_domain = determine_final_domain(domain_info, zone_name, existing_records)
241
-
242
- # 대상 도메인의 모든 A/CNAME 레코드 확인
243
- all_records = get_dns_records(token, zone_id, final_domain[:name], ['A', 'CNAME'])
244
-
245
- if all_records.any?
246
- existing_record = all_records.first
247
-
273
+ target_domain = domain_info[:domain]
274
+ server_address = server_info[:address]
275
+ record_type = server_info[:record_type]
276
+
277
+ # 대상 도메인의 기존 A/CNAME 레코드 확인
278
+ existing_records = get_dns_records(token, zone_id, target_domain, ['A', 'CNAME'])
279
+
280
+ if existing_records.any?
281
+ existing_record = existing_records.first
282
+
248
283
  # 동일한 타입이고 같은 값이면 건너뛰기
249
- if existing_record['type'] == record_type && existing_record['content'] == server_info
284
+ if existing_record['type'] == record_type && existing_record['content'] == server_address
250
285
  puts "✅ DNS 레코드가 이미 올바르게 설정되어 있습니다.".colorize(:green)
251
- puts " #{final_domain[:full_domain]} → #{server_info} (#{record_type} 레코드)".colorize(:gray)
286
+ puts " #{target_domain} → #{server_address} (#{record_type} 레코드)".colorize(:gray)
252
287
  else
253
288
  # 타입이 다르거나 값이 다른 경우 삭제 후 재생성
254
289
  puts "⚠️ 기존 레코드를 삭제하고 새로 생성합니다.".colorize(:yellow)
255
- puts " 기존: #{existing_record['content']} (#{existing_record['type']}) → 새로운: #{server_info} (#{record_type})".colorize(:gray)
256
-
290
+ puts " 기존: #{existing_record['content']} (#{existing_record['type']}) → 새로운: #{server_address} (#{record_type})".colorize(:gray)
291
+
257
292
  # 기존 레코드 삭제
258
293
  delete_dns_record(token, zone_id, existing_record['id'])
259
-
294
+
260
295
  # 새 레코드 생성
261
- create_dns_record(token, zone_id, final_domain[:name], record_type, server_info)
296
+ create_dns_record(token, zone_id, target_domain, record_type, server_address)
262
297
  end
263
298
  else
264
299
  # DNS 레코드 생성
265
- create_dns_record(token, zone_id, final_domain[:name], record_type, server_info)
300
+ create_dns_record(token, zone_id, target_domain, record_type, server_address)
266
301
  end
267
-
268
- # 최종 도메인 정보 저장
269
- @final_domain = final_domain[:full_domain]
270
- @server_info = server_info
271
- @ssh_user = ssh_user
272
- end
273
302
 
274
- def determine_final_domain(domain_info, zone_name, existing_records)
275
- case domain_info[:type]
276
- when :root
277
- if existing_records.any?
278
- puts "⚠️ 루트 도메인에 이미 레코드가 있습니다. app.#{zone_name}을 사용합니다.".colorize(:yellow)
279
- { name: "app.#{zone_name}", full_domain: "app.#{zone_name}" }
280
- else
281
- { name: zone_name, full_domain: zone_name }
282
- end
283
- when :subdomain
284
- { name: domain_info[:domain], full_domain: domain_info[:domain] }
285
- end
303
+ puts " #{target_domain} #{server_address}".colorize(:cyan)
286
304
  end
287
305
 
288
306
  def create_dns_record(token, zone_id, name, type, content)
@@ -340,53 +358,56 @@ module Tayo
340
358
  exit 1
341
359
  end
342
360
 
343
- def update_deploy_config(domain_info)
361
+ def update_deploy_config(domain_info, server_info)
344
362
  puts "\n📝 배포 설정을 업데이트합니다...".colorize(:yellow)
345
-
363
+
346
364
  config_file = "config/deploy.yml"
347
-
365
+ final_domain = domain_info[:domain]
366
+ server_address = server_info[:address]
367
+ ssh_user = server_info[:ssh_user]
368
+
348
369
  unless File.exist?(config_file)
349
370
  puts "⚠️ config/deploy.yml 파일이 없습니다.".colorize(:yellow)
350
371
  return
351
372
  end
352
-
373
+
353
374
  content = File.read(config_file)
354
-
375
+
355
376
  # proxy.host 설정 업데이트
356
377
  if content.include?("proxy:")
357
- content.gsub!(/(\s+host:\s+).*$/, "\\1#{@final_domain}")
378
+ content.gsub!(/(\s+host:\s+).*$/, "\\1#{final_domain}")
358
379
  else
359
380
  # proxy 섹션이 없으면 추가
360
- proxy_config = "\n# Proxy configuration\nproxy:\n ssl: true\n host: #{@final_domain}\n"
381
+ proxy_config = "\n# Proxy configuration\nproxy:\n ssl: true\n host: #{final_domain}\n"
361
382
  content += proxy_config
362
383
  end
363
-
384
+
364
385
  # servers 설정 업데이트
365
386
  if content.match?(/servers:\s*\n\s*web:\s*\n\s*-\s*/)
366
- content.gsub!(/(\s*servers:\s*\n\s*web:\s*\n\s*-\s*)[\d.]+/, "\\1#{@server_info}")
387
+ content.gsub!(/(\s*servers:\s*\n\s*web:\s*\n\s*-\s*)[\d.]+/, "\\1#{server_address}")
367
388
  end
368
-
389
+
369
390
  # ssh user 설정 업데이트
370
- if @ssh_user && @ssh_user != "root"
391
+ if ssh_user && ssh_user != "root"
371
392
  if content.match?(/^ssh:/)
372
393
  # 기존 ssh 섹션 업데이트
373
- content.gsub!(/^ssh:\s*\n\s*user:\s*\w+/, "ssh:\n user: #{@ssh_user}")
394
+ content.gsub!(/^ssh:\s*\n\s*user:\s*\w+/, "ssh:\n user: #{ssh_user}")
374
395
  else
375
396
  # ssh 섹션 추가 (accessories 섹션 앞에 추가)
376
397
  if content.match?(/^# Use accessory services/)
377
- content.gsub!(/^# Use accessory services/, "# Use a different ssh user than root\nssh:\n user: #{@ssh_user}\n\n# Use accessory services")
398
+ content.gsub!(/^# Use accessory services/, "# Use a different ssh user than root\nssh:\n user: #{ssh_user}\n\n# Use accessory services")
378
399
  else
379
400
  # 파일 끝에 추가
380
- content += "\n# Use a different ssh user than root\nssh:\n user: #{@ssh_user}\n"
401
+ content += "\n# Use a different ssh user than root\nssh:\n user: #{ssh_user}\n"
381
402
  end
382
403
  end
383
404
  end
384
-
405
+
385
406
  File.write(config_file, content)
386
407
  puts "✅ config/deploy.yml이 업데이트되었습니다.".colorize(:green)
387
- puts " proxy.host: #{@final_domain}".colorize(:gray)
388
- puts " servers.web: #{@server_info}".colorize(:gray)
389
- puts " ssh.user: #{@ssh_user}".colorize(:gray) if @ssh_user && @ssh_user != "root"
408
+ puts " proxy.host: #{final_domain}".colorize(:gray)
409
+ puts " servers.web: #{server_address}".colorize(:gray)
410
+ puts " ssh.user: #{ssh_user}".colorize(:gray) if ssh_user && ssh_user != "root"
390
411
  end
391
412
 
392
413
  def commit_cloudflare_changes(domain_info)
@@ -12,11 +12,6 @@ module Tayo
12
12
  def execute
13
13
  puts "🚀 GitHub 저장소 및 컨테이너 레지스트리 설정을 시작합니다...".colorize(:green)
14
14
 
15
- unless rails_project?
16
- puts "❌ Rails 프로젝트가 아닙니다. Rails 프로젝트 루트에서 실행해주세요.".colorize(:red)
17
- return
18
- end
19
-
20
15
  puts "\n[1/7] GitHub CLI 설치 확인".colorize(:blue)
21
16
  check_github_cli
22
17
 
@@ -50,10 +45,6 @@ module Tayo
50
45
 
51
46
  private
52
47
 
53
- def rails_project?
54
- File.exist?("Gemfile") && File.exist?("config/application.rb")
55
- end
56
-
57
48
  def check_github_cli
58
49
  if system("gh --version", out: File::NULL, err: File::NULL)
59
50
  puts "✅ GitHub CLI가 이미 설치되어 있습니다.".colorize(:green)
@@ -0,0 +1,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "colorize"
4
+ require "yaml"
5
+
6
+ module Tayo
7
+ module Commands
8
+ class Sqlite
9
+ def execute
10
+ puts "🗄️ SQLite 최적화 설정을 시작합니다...".colorize(:green)
11
+
12
+ unless rails_project?
13
+ puts "❌ Rails 프로젝트가 아닙니다. Rails 프로젝트 루트에서 실행해주세요.".colorize(:red)
14
+ return
15
+ end
16
+
17
+ unless sqlite_project?
18
+ puts "❌ SQLite를 사용하는 프로젝트가 아닙니다.".colorize(:red)
19
+ return
20
+ end
21
+
22
+ unless rails_8_or_higher?
23
+ puts "❌ Rails 8 이상이 필요합니다. (현재: Rails #{detect_rails_version || '알 수 없음'})".colorize(:red)
24
+ puts " Solid Cable은 Rails 8에서 도입되었습니다.".colorize(:yellow)
25
+ return
26
+ end
27
+
28
+ puts " Rails #{detect_rails_version} 확인됨".colorize(:gray)
29
+
30
+ add_solid_cable_gem
31
+ run_bundle_install
32
+ install_solid_cable
33
+ update_database_yml
34
+ update_cable_yml
35
+ create_sqlite_initializer
36
+ run_migrations
37
+ create_documentation
38
+
39
+ puts ""
40
+ puts "✅ SQLite + Solid Cable 최적화 설정이 완료되었습니다!".colorize(:green)
41
+ puts " 📄 설정 배경 문서: docs/solid-cable-sqlite-setup.md".colorize(:gray)
42
+ end
43
+
44
+ private
45
+
46
+ def rails_project?
47
+ File.exist?("Gemfile") && File.exist?("config/application.rb")
48
+ end
49
+
50
+ def sqlite_project?
51
+ return false unless File.exist?("config/database.yml")
52
+
53
+ database_yml = File.read("config/database.yml")
54
+ database_yml.include?("sqlite3")
55
+ end
56
+
57
+ def rails_8_or_higher?
58
+ version = detect_rails_version
59
+ return false unless version
60
+
61
+ major_version = version.split(".").first.to_i
62
+ major_version >= 8
63
+ end
64
+
65
+ def detect_rails_version
66
+ # Gemfile.lock에서 rails 버전 확인
67
+ if File.exist?("Gemfile.lock")
68
+ lockfile = File.read("Gemfile.lock")
69
+ if match = lockfile.match(/^\s+rails\s+\((\d+\.\d+\.\d+)/)
70
+ return match[1]
71
+ end
72
+ end
73
+
74
+ # Gemfile에서 확인 (edge rails 등)
75
+ if File.exist?("Gemfile")
76
+ gemfile = File.read("Gemfile")
77
+ # gem "rails", "~> 8.0" 형식
78
+ if match = gemfile.match(/gem\s+["']rails["'],\s*["']~>\s*(\d+\.\d+)["']/)
79
+ return "#{match[1]}.0"
80
+ end
81
+ # github: "rails/rails" (edge) - Rails 8+ 가정
82
+ if gemfile.match?(/gem\s+["']rails["'].*github:\s*["']rails\/rails["']/)
83
+ return "8.0.0 (edge)"
84
+ end
85
+ end
86
+
87
+ nil
88
+ end
89
+
90
+ def add_solid_cable_gem
91
+ puts "📦 Gemfile에 solid_cable을 추가합니다...".colorize(:yellow)
92
+
93
+ gemfile = File.read("Gemfile")
94
+
95
+ if gemfile.include?("solid_cable")
96
+ puts " ℹ️ solid_cable이 이미 존재합니다.".colorize(:yellow)
97
+ return
98
+ end
99
+
100
+ # solid_cable gem 추가
101
+ if gemfile.include?("solid_queue")
102
+ # solid_queue 다음에 추가
103
+ gemfile.gsub!(/^gem ["']solid_queue["'].*$/) do |match|
104
+ "#{match}\ngem \"solid_cable\""
105
+ end
106
+ elsif gemfile.match?(/^gem ["']rails["']/)
107
+ # rails gem 다음에 추가
108
+ gemfile.gsub!(/^gem ["']rails["'].*$/) do |match|
109
+ "#{match}\n\n# Solid Cable - SQLite 기반 Action Cable 어댑터\ngem \"solid_cable\""
110
+ end
111
+ else
112
+ # 파일 끝에 추가
113
+ gemfile += "\n# Solid Cable - SQLite 기반 Action Cable 어댑터\ngem \"solid_cable\"\n"
114
+ end
115
+
116
+ File.write("Gemfile", gemfile)
117
+ puts " ✅ Gemfile에 solid_cable을 추가했습니다.".colorize(:green)
118
+ end
119
+
120
+ def update_database_yml
121
+ puts "🗄️ database.yml을 업데이트합니다...".colorize(:yellow)
122
+
123
+ database_yml_path = "config/database.yml"
124
+ content = File.read(database_yml_path)
125
+
126
+ # 이미 cable 설정이 있는지 확인
127
+ if content.include?("cable:") || content.include?("cable_production:")
128
+ puts " ℹ️ cable 데이터베이스가 이미 설정되어 있습니다.".colorize(:yellow)
129
+ return
130
+ end
131
+
132
+ # 기존 database.yml 파싱
133
+ # production 설정에 cable DB 추가
134
+ new_content = generate_database_yml(content)
135
+
136
+ File.write(database_yml_path, new_content)
137
+ puts " ✅ database.yml에 cable 데이터베이스를 추가했습니다.".colorize(:green)
138
+ end
139
+
140
+ def generate_database_yml(original_content)
141
+ # 기존 내용 유지하면서 cable DB 추가
142
+ lines = original_content.lines
143
+
144
+ # production 섹션 찾기
145
+ production_index = lines.find_index { |line| line.match?(/^production:/) }
146
+
147
+ if production_index
148
+ # production 섹션 끝 찾기
149
+ next_section_index = lines[(production_index + 1)..].find_index { |line| line.match?(/^\w+:/) }
150
+ insert_index = next_section_index ? production_index + 1 + next_section_index : lines.length
151
+
152
+ cable_config = <<~YAML
153
+
154
+ # Solid Cable용 별도 데이터베이스 (WAL 모드 최적화)
155
+ cable_production:
156
+ <<: *default
157
+ database: storage/db/cable_production.sqlite3
158
+ migrations_paths: db/cable_migrate
159
+ YAML
160
+
161
+ lines.insert(insert_index, cable_config)
162
+ end
163
+
164
+ # development/test에도 추가
165
+ dev_index = lines.find_index { |line| line.match?(/^development:/) }
166
+ if dev_index
167
+ next_section_index = lines[(dev_index + 1)..].find_index { |line| line.match?(/^\w+:/) }
168
+ insert_index = next_section_index ? dev_index + 1 + next_section_index : lines.length
169
+
170
+ cable_config = <<~YAML
171
+
172
+ cable_development:
173
+ <<: *default
174
+ database: storage/db/cable_development.sqlite3
175
+ migrations_paths: db/cable_migrate
176
+ YAML
177
+
178
+ lines.insert(insert_index, cable_config)
179
+ end
180
+
181
+ test_index = lines.find_index { |line| line.match?(/^test:/) }
182
+ if test_index
183
+ next_section_index = lines[(test_index + 1)..].find_index { |line| line.match?(/^\w+:/) }
184
+ insert_index = next_section_index ? test_index + 1 + next_section_index : lines.length
185
+
186
+ cable_config = <<~YAML
187
+
188
+ cable_test:
189
+ <<: *default
190
+ database: storage/db/cable_test.sqlite3
191
+ migrations_paths: db/cable_migrate
192
+ YAML
193
+
194
+ lines.insert(insert_index, cable_config)
195
+ end
196
+
197
+ lines.join
198
+ end
199
+
200
+ def update_cable_yml
201
+ puts "📡 cable.yml을 업데이트합니다...".colorize(:yellow)
202
+
203
+ cable_yml_path = "config/cable.yml"
204
+
205
+ # Development는 async (단일 프로세스), Production은 solid_cable
206
+ cable_config = <<~YAML
207
+ # Solid Cable 설정 (SQLite 기반 Action Cable)
208
+ # Development: async 어댑터 (단일 프로세스, 콘솔 디버깅 용이)
209
+ # Production: solid_cable (polling_interval: 25ms, Redis 수준 RTT)
210
+
211
+ development:
212
+ adapter: async
213
+
214
+ test:
215
+ adapter: test
216
+
217
+ production:
218
+ adapter: solid_cable
219
+ connects_to:
220
+ database:
221
+ writing: cable
222
+ polling_interval: 0.025.seconds
223
+ message_retention: 1.hour
224
+ YAML
225
+
226
+ # 기존 파일 백업
227
+ if File.exist?(cable_yml_path)
228
+ backup_path = "#{cable_yml_path}.backup"
229
+ FileUtils.cp(cable_yml_path, backup_path)
230
+ puts " 📋 기존 cable.yml을 #{backup_path}로 백업했습니다.".colorize(:gray)
231
+ end
232
+
233
+ File.write(cable_yml_path, cable_config)
234
+ puts " ✅ cable.yml을 Solid Cable 설정으로 업데이트했습니다.".colorize(:green)
235
+ end
236
+
237
+ def create_sqlite_initializer
238
+ puts "⚡ SQLite 최적화 initializer를 생성합니다...".colorize(:yellow)
239
+
240
+ initializer_path = "config/initializers/solid_cable_sqlite.rb"
241
+
242
+ if File.exist?(initializer_path)
243
+ puts " ℹ️ initializer가 이미 존재합니다.".colorize(:yellow)
244
+ return
245
+ end
246
+
247
+ initializer_content = <<~RUBY
248
+ # frozen_string_literal: true
249
+
250
+ # Solid Cable SQLite 최적화 설정
251
+ # - WAL 모드: 읽기/쓰기 동시성 향상 (폴링과 브로드캐스트 동시 처리)
252
+ # - synchronous=NORMAL: 쓰기 성능 향상 (약간의 안정성 트레이드오프)
253
+
254
+ Rails.application.config.after_initialize do
255
+ # Cable 데이터베이스에 WAL 모드 설정
256
+ if defined?(SolidCable) && ActiveRecord::Base.configurations.configs_for(name: "cable")
257
+ ActiveRecord::Base.connected_to(role: :writing, shard: :cable) do
258
+ connection = ActiveRecord::Base.connection
259
+
260
+ # WAL 모드 활성화 - 읽기/쓰기 동시 처리 가능
261
+ connection.execute("PRAGMA journal_mode=WAL")
262
+
263
+ # synchronous=NORMAL - fsync 횟수 감소로 쓰기 성능 향상
264
+ connection.execute("PRAGMA synchronous=NORMAL")
265
+
266
+ # 캐시 크기 증가 (기본값의 2배)
267
+ connection.execute("PRAGMA cache_size=4000")
268
+
269
+ Rails.logger.info "[SolidCable] SQLite WAL 모드 최적화 적용됨"
270
+ rescue ActiveRecord::ConnectionNotEstablished
271
+ # 마이그레이션 전에는 연결이 없을 수 있음
272
+ Rails.logger.debug "[SolidCable] Cable 데이터베이스 연결 대기 중..."
273
+ end
274
+ end
275
+ end
276
+ RUBY
277
+
278
+ FileUtils.mkdir_p("config/initializers")
279
+ File.write(initializer_path, initializer_content)
280
+ puts " ✅ config/initializers/solid_cable_sqlite.rb를 생성했습니다.".colorize(:green)
281
+ end
282
+
283
+ def run_bundle_install
284
+ puts "📦 bundle install을 실행합니다...".colorize(:yellow)
285
+
286
+ if system("bundle install")
287
+ puts " ✅ bundle install 완료".colorize(:green)
288
+ else
289
+ puts " ❌ bundle install 실패".colorize(:red)
290
+ puts " 수동으로 bundle install을 실행해주세요.".colorize(:yellow)
291
+ exit 1
292
+ end
293
+ end
294
+
295
+ def install_solid_cable
296
+ puts "🔌 Solid Cable을 설치합니다...".colorize(:yellow)
297
+
298
+ # solid_cable:install 태스크가 있는지 확인
299
+ if system("bin/rails solid_cable:install")
300
+ puts " ✅ Solid Cable 설치 완료".colorize(:green)
301
+ else
302
+ puts " ⚠️ solid_cable:install 태스크를 찾을 수 없습니다.".colorize(:yellow)
303
+ puts " 마이그레이션 파일을 직접 생성합니다...".colorize(:yellow)
304
+ create_cable_migration
305
+ end
306
+ end
307
+
308
+ def create_cable_migration
309
+ # db/cable_migrate 디렉토리 생성
310
+ FileUtils.mkdir_p("db/cable_migrate")
311
+
312
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S")
313
+ migration_path = "db/cable_migrate/#{timestamp}_create_solid_cable_messages.rb"
314
+
315
+ migration_content = <<~RUBY
316
+ class CreateSolidCableMessages < ActiveRecord::Migration[7.2]
317
+ def change
318
+ create_table :solid_cable_messages do |t|
319
+ t.binary :channel, null: false, limit: 1024
320
+ t.binary :payload, null: false, limit: 536870912
321
+ t.datetime :created_at, null: false
322
+ t.integer :channel_hash, null: false, limit: 8
323
+
324
+ t.index :channel
325
+ t.index :channel_hash
326
+ t.index :created_at
327
+ end
328
+ end
329
+ end
330
+ RUBY
331
+
332
+ File.write(migration_path, migration_content)
333
+ puts " ✅ 마이그레이션 파일 생성: #{migration_path}".colorize(:green)
334
+ end
335
+
336
+ def run_migrations
337
+ puts "🗄️ 데이터베이스를 준비합니다...".colorize(:yellow)
338
+
339
+ # storage 디렉토리 생성
340
+ FileUtils.mkdir_p("storage")
341
+
342
+ # db:prepare는 마이그레이션 + 스키마 로드를 모두 처리
343
+ if system("bin/rails db:prepare")
344
+ puts " ✅ 데이터베이스 준비 완료".colorize(:green)
345
+ else
346
+ puts " ⚠️ 데이터베이스 준비 실패".colorize(:yellow)
347
+ puts " 수동으로 bin/rails db:prepare를 실행해주세요.".colorize(:yellow)
348
+ end
349
+ end
350
+
351
+ def create_documentation
352
+ puts "📄 설정 문서를 생성합니다...".colorize(:yellow)
353
+
354
+ FileUtils.mkdir_p("docs")
355
+ doc_path = "docs/solid-cable-sqlite-setup.md"
356
+
357
+ if File.exist?(doc_path)
358
+ puts " ℹ️ 문서가 이미 존재합니다.".colorize(:yellow)
359
+ return
360
+ end
361
+
362
+ # 템플릿 파일에서 문서 내용 읽기
363
+ template_path = File.expand_path("../../templates/solid-cable-sqlite-setup.md", __FILE__)
364
+ doc_content = File.read(template_path)
365
+
366
+ File.write(doc_path, doc_content)
367
+ puts " ✅ docs/solid-cable-sqlite-setup.md를 생성했습니다.".colorize(:green)
368
+ end
369
+ end
370
+ end
371
+ end
@@ -0,0 +1,90 @@
1
+ # Solid Cable + SQLite 최적화 설정
2
+
3
+ > 이 문서는 `tayo sqlite` 명령어로 자동 생성되었습니다.
4
+
5
+ ## 개요
6
+
7
+ 이 프로젝트는 Redis 없이 SQLite만으로 Action Cable 실시간 기능을 구현합니다.
8
+ Rails 8에서 도입된 Solid Cable을 사용하여 WebSocket 브로드캐스트를 처리합니다.
9
+
10
+ ## 왜 Solid Cable인가?
11
+
12
+ ### Redis vs Solid Cable
13
+
14
+ | 항목 | Redis | Solid Cable |
15
+ |------|-------|-------------|
16
+ | 의존성 | Redis 서버 필요 | DB만 사용 |
17
+ | 방식 | Pub/Sub (푸시) | Polling (폴링) |
18
+ | 지연시간 | ~1ms | ~25ms (설정값) |
19
+ | 운영 복잡도 | Redis 관리 필요 | 단순 |
20
+
21
+ ### 체감 성능
22
+
23
+ - **25ms 폴링**: 사용자가 지연을 체감하기 어려움 (200ms 이하는 "즉각적"으로 느껴짐)
24
+ - **RTT 비교**: Redis ~80ms vs Solid Cable ~85ms (전체 왕복 시간 기준, 거의 동일)
25
+
26
+ ## 적용된 설정
27
+
28
+ ### 1. 데이터베이스 분리 (database.yml)
29
+
30
+ ```yaml
31
+ production:
32
+ primary:
33
+ database: storage/production.sqlite3
34
+ cable:
35
+ database: storage/production_cable.sqlite3 # 별도 DB
36
+ ```
37
+
38
+ **이유**: Cable 폴링이 메인 DB의 쓰기 작업과 락 경합하지 않도록 분리
39
+
40
+ ### 2. Cable 설정 (cable.yml)
41
+
42
+ ```yaml
43
+ development:
44
+ adapter: async # 단일 프로세스, 콘솔 디버깅 용이
45
+
46
+ production:
47
+ adapter: solid_cable
48
+ polling_interval: 0.025.seconds # 25ms
49
+ message_retention: 1.hour
50
+ ```
51
+
52
+ **폴링 간격 선택 기준**:
53
+ - 100ms: 기본값, 대부분 충분
54
+ - 25ms: 채팅/실시간 앱에 적합
55
+ - 10ms: Redis 수준, 부하 증가
56
+
57
+ ### 3. SQLite WAL 모드 최적화 (initializer)
58
+
59
+ ```ruby
60
+ # config/initializers/solid_cable_sqlite.rb
61
+ PRAGMA journal_mode=WAL # 읽기/쓰기 동시 처리
62
+ PRAGMA synchronous=NORMAL # 쓰기 성능 향상
63
+ PRAGMA cache_size=4000 # 캐시 증가
64
+ ```
65
+
66
+ **WAL 모드 장점**:
67
+ - 읽기(폴링)와 쓰기(브로드캐스트)가 동시에 가능
68
+ - Solid Cable의 폴링 패턴에 최적화
69
+
70
+ ## 성능 가이드
71
+
72
+ ### 적정 사용 범위
73
+
74
+ | 동시 접속 | 메시지/초 | 예상 지연 |
75
+ |----------|----------|----------|
76
+ | ~50명 | ~10 | 거의 없음 |
77
+ | ~100명 | ~20 | ~50ms |
78
+ | ~500명 | ~100 | 검토 필요 |
79
+
80
+ ### 스케일 아웃이 필요한 경우
81
+
82
+ - 초당 200개 이상 메시지
83
+ - 1000명 이상 동시 접속
84
+ - → Redis 또는 PostgreSQL NOTIFY 고려
85
+
86
+ ## 참고 자료
87
+
88
+ - [Solid Cable GitHub](https://github.com/rails/solid_cable)
89
+ - [SQLite WAL Mode](https://sqlite.org/wal.html)
90
+ - [Rails 8 Release Notes](https://rubyonrails.org/2024/11/7/rails-8-no-paas-required)
data/lib/tayo/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tayo
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tayo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - 이원섭wonsup Lee/Alfonso
@@ -92,6 +92,7 @@ files:
92
92
  - CHANGELOG.md
93
93
  - README.md
94
94
  - Rakefile
95
+ - docs/2025-11-29-cf-command-refactor.md
95
96
  - exe/tayo
96
97
  - lib/tayo.rb
97
98
  - lib/tayo/cli.rb
@@ -99,12 +100,14 @@ files:
99
100
  - lib/tayo/commands/gh.rb
100
101
  - lib/tayo/commands/init.rb
101
102
  - lib/tayo/commands/proxy.rb
103
+ - lib/tayo/commands/sqlite.rb
102
104
  - lib/tayo/dockerfile_modifier.rb
103
105
  - lib/tayo/proxy/cloudflare_client.rb
104
106
  - lib/tayo/proxy/docker_manager.rb
105
107
  - lib/tayo/proxy/network_config.rb
106
108
  - lib/tayo/proxy/traefik_config.rb
107
109
  - lib/tayo/proxy/welcome_service.rb
110
+ - lib/tayo/templates/solid-cable-sqlite-setup.md
108
111
  - lib/tayo/version.rb
109
112
  - lib/templates/welcome/Dockerfile
110
113
  - lib/templates/welcome/index.html