tayo 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 29025c061dcf5f809269fcb2e1af0435a7d24ac28bf21062f4ab065b7b4a17ff
4
+ data.tar.gz: 6e6f64d2ec7345b8966256a75ead710ce3e479a3e2f05e3624a66936b6c9464f
5
+ SHA512:
6
+ metadata.gz: d29bbf7c0b505c53d777bda3262ccaeb14f1ba195a2c21e65cac50e5875862edaf8cb323e16c59ca56f4801e23984a6c0680ec7a4f9c4a478bb941f5fd4bea59
7
+ data.tar.gz: 5b664d65d34f5b9e3b258025750e0ad863660dab0bc472447fad605db92c340af16d4a7e55817ccbd2703bf5563d00c75d620f37c9267b15af77b5be137877ee
data/.DS_Store ADDED
Binary file
data/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # Tayo
2
+
3
+ Rails 애플리케이션을 홈서버에 배포하기 위한 도구입니다.
4
+
5
+ ## 설치
6
+
7
+ 시스템 와이드로 설치:
8
+
9
+ ```bash
10
+ gem install tayo
11
+ ```
12
+
13
+ ## 사용법
14
+
15
+ ### 1. `tayo init` - Rails 프로젝트 초기화
16
+
17
+ Rails 프로젝트를 홈서버 배포를 위해 준비합니다.
18
+
19
+ ```bash
20
+ tayo init
21
+ ```
22
+
23
+ 이 명령어는 다음 작업들을 수행합니다:
24
+
25
+ - **OrbStack 설치 확인**: Docker 컨테이너를 실행하기 위한 OrbStack이 설치되어 있는지 확인합니다
26
+ - **Gemfile 수정**: development 그룹에 tayo gem을 추가합니다
27
+ - **Bundle 설치**: 의존성을 설치합니다
28
+ - **Linux 플랫폼 추가**: `x86_64-linux`와 `aarch64-linux` 플랫폼을 Gemfile.lock에 추가합니다
29
+ - **Dockerfile 생성**: Rails 7 기본 Dockerfile이 없으면 생성합니다
30
+ - **Welcome 페이지 생성**:
31
+ - `app/controllers/welcome_controller.rb` 컨트롤러 생성
32
+ - `app/views/welcome/index.html.erb` 뷰 파일 생성 (애니메이션이 있는 예쁜 랜딩 페이지)
33
+ - `config/routes.rb`에 `root 'welcome#index'` 설정 추가
34
+ - **Git 커밋**: 변경사항을 자동으로 커밋합니다
35
+ - **Docker 캐시 정리**: 디스크 공간 확보를 위해 Docker 캐시를 정리합니다
36
+
37
+ ### 2. `tayo gh` - GitHub 저장소 및 Container Registry 설정
38
+
39
+ GitHub 저장소를 생성하고 Container Registry를 설정합니다.
40
+
41
+ ```bash
42
+ tayo gh
43
+ ```
44
+
45
+ 이 명령어는 다음 작업들을 수행합니다:
46
+
47
+ - **GitHub CLI 설치 확인**: `gh` 명령어가 설치되어 있는지 확인합니다
48
+ - **GitHub 인증 확인**:
49
+ - GitHub에 로그인되어 있는지 확인
50
+ - 필요한 권한(repo, read:org, write:packages) 확인
51
+ - 권한이 없으면 브라우저에서 토큰 생성 페이지를 엽니다
52
+ - **Git 저장소 초기화**: 아직 git 저장소가 아니면 초기화합니다
53
+ - **GitHub 원격 저장소 설정**:
54
+ - 기존 원격 저장소가 있으면 사용
55
+ - 없으면 새 저장소 생성 (public/private 선택 가능)
56
+ - 코드를 GitHub에 푸시
57
+ - **GitHub Container Registry 설정**:
58
+ - Registry URL 생성: `ghcr.io/username/repository-name`
59
+ - Docker로 자동 로그인 실행
60
+ - **배포 설정 파일 생성**:
61
+ - `config/deploy.yml` 파일 생성 또는 업데이트
62
+ - 서버 IP, 도메인, 데이터베이스 등 설정 포함
63
+ - **환경 변수 파일 준비**:
64
+ - `.env.production` 파일 생성
65
+ - `.gitignore`에 추가하여 보안 유지
66
+
67
+ ### 3. `tayo cf` - Cloudflare DNS 설정
68
+
69
+ Cloudflare를 통해 도메인을 홈서버 IP에 연결합니다.
70
+
71
+ ```bash
72
+ tayo cf
73
+ ```
74
+
75
+ 이 명령어는 다음 작업들을 수행합니다:
76
+
77
+ - **설정 파일 확인**: `config/deploy.yml` 파일에서 서버 IP와 도메인 정보를 읽습니다
78
+ - **Cloudflare 인증**:
79
+ - API 토큰 입력 요청 (처음 실행 시)
80
+ - 토큰을 안전하게 저장 (macOS Keychain 사용)
81
+ - **도메인 Zone 확인**:
82
+ - Cloudflare 계정에서 도메인을 찾습니다
83
+ - Zone ID를 자동으로 가져옵니다
84
+ - **DNS 레코드 생성/업데이트**:
85
+ - A 레코드 생성: 도메인을 서버 IP에 연결
86
+ - 기존 레코드가 있으면 업데이트
87
+ - Proxied 설정 (Cloudflare CDN 사용)
88
+ - **설정 완료 확인**:
89
+ - DNS 설정이 완료되면 성공 메시지 표시
90
+ - 도메인으로 접속 가능함을 안내
91
+
92
+ 각 명령어는 단계별로 진행 상황을 표시하며, 오류가 발생하면 친절한 안내 메시지를 제공합니다.
93
+
94
+ rails new 로 프로젝트 생성 후
95
+ bundle exec tayo init
96
+ bundle exec tayo gh
97
+ bundle exec tayo cf
98
+
99
+ 순으로 진행 후
100
+ bin/kamal setup 으로 배포 진행
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
data/exe/tayo ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "tayo"
4
+
5
+ Tayo::CLI.start(ARGV)
data/lib/tayo/cli.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "colorize"
5
+ require_relative "commands/init"
6
+ require_relative "commands/gh"
7
+ require_relative "commands/cf"
8
+
9
+ module Tayo
10
+ class CLI < Thor
11
+ desc "init", "Rails 프로젝트에 Tayo를 설정합니다"
12
+ def init
13
+ Commands::Init.new.execute
14
+ end
15
+
16
+ desc "gh", "GitHub 저장소와 컨테이너 레지스트리를 설정합니다"
17
+ def gh
18
+ Commands::Gh.new.execute
19
+ end
20
+
21
+ desc "cf", "Cloudflare DNS를 설정하여 홈서버에 도메인을 연결합니다"
22
+ def cf
23
+ Commands::Cf.new.execute
24
+ end
25
+
26
+ desc "version", "Tayo 버전을 표시합니다"
27
+ def version
28
+ puts "Tayo #{VERSION}"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "colorize"
4
+ require "tty-prompt"
5
+ require "net/http"
6
+ require "json"
7
+ require "uri"
8
+
9
+ module Tayo
10
+ module Commands
11
+ class Cf
12
+ def execute
13
+ puts "☁️ Cloudflare DNS 설정을 시작합니다...".colorize(:green)
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 토큰 생성 페이지 열기 및 권한 안내
24
+ open_token_creation_page
25
+
26
+ # 3. 토큰 입력받기
27
+ token = get_cloudflare_token
28
+
29
+ # 4. Cloudflare API로 도메인 목록 조회 및 선택
30
+ 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)
40
+
41
+ puts "\n🎉 Cloudflare DNS 설정이 완료되었습니다!".colorize(:green)
42
+ end
43
+
44
+ private
45
+
46
+ def rails_project?
47
+ File.exist?("Gemfile") && File.exist?("config/application.rb")
48
+ end
49
+
50
+ def get_domain_input
51
+ prompt = TTY::Prompt.new
52
+
53
+ puts "\n📝 배포할 도메인을 설정합니다.".colorize(:yellow)
54
+
55
+ domain = prompt.ask("배포할 도메인을 입력하세요 (예: myapp.com, api.example.com):") do |q|
56
+ q.validate(/\A[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z/, "올바른 도메인 형식을 입력해주세요 (예: myapp.com)")
57
+ end
58
+
59
+ # 도메인이 루트인지 서브도메인인지 판단
60
+ parts = domain.split('.')
61
+ if parts.length == 2
62
+ { type: :root, domain: domain, zone: domain }
63
+ else
64
+ zone = parts[-2..-1].join('.')
65
+ { type: :subdomain, domain: domain, zone: zone, subdomain: parts[0..-3].join('.') }
66
+ end
67
+ end
68
+
69
+ def open_token_creation_page
70
+ puts "\n🔑 Cloudflare API 토큰이 필요합니다.".colorize(:yellow)
71
+ puts "토큰 생성 페이지를 엽니다...".colorize(:cyan)
72
+
73
+ # Cloudflare API 토큰 생성 페이지 열기
74
+ system("open 'https://dash.cloudflare.com/profile/api-tokens'")
75
+
76
+ puts "\n다음 권한으로 토큰을 생성해주세요:".colorize(:yellow)
77
+ puts ""
78
+ puts "한국어 화면:".colorize(:gray)
79
+ puts "• 영역 → DNS → 읽기".colorize(:white)
80
+ puts "• 영역 → DNS → 편집".colorize(:white)
81
+ puts " (영역 리소스는 '모든 영역' 선택)".colorize(:gray)
82
+ puts ""
83
+ puts "English:".colorize(:gray)
84
+ puts "• Zone → DNS → Read".colorize(:white)
85
+ puts "• Zone → DNS → Edit".colorize(:white)
86
+ puts " (Zone Resources: Select 'All zones')".colorize(:gray)
87
+ puts ""
88
+ end
89
+
90
+ def get_cloudflare_token
91
+ prompt = TTY::Prompt.new
92
+
93
+ token = prompt.mask("생성된 Cloudflare API 토큰을 붙여넣으세요:")
94
+
95
+ if token.nil? || token.strip.empty?
96
+ puts "❌ 토큰이 입력되지 않았습니다.".colorize(:red)
97
+ exit 1
98
+ end
99
+
100
+ # 토큰 유효성 간단 확인
101
+ if test_cloudflare_token(token.strip)
102
+ puts "✅ 토큰이 확인되었습니다.".colorize(:green)
103
+ return token.strip
104
+ else
105
+ puts "❌ 토큰이 올바르지 않거나 권한이 부족합니다.".colorize(:red)
106
+ exit 1
107
+ end
108
+ end
109
+
110
+ def test_cloudflare_token(token)
111
+ uri = URI('https://api.cloudflare.com/client/v4/user/tokens/verify')
112
+ http = Net::HTTP.new(uri.host, uri.port)
113
+ http.use_ssl = true
114
+
115
+ request = Net::HTTP::Get.new(uri)
116
+ request['Authorization'] = "Bearer #{token}"
117
+ request['Content-Type'] = 'application/json'
118
+
119
+ response = http.request(request)
120
+ return response.code == '200'
121
+ rescue
122
+ return false
123
+ end
124
+
125
+ def select_cloudflare_zone(token)
126
+ puts "\n🌐 Cloudflare 도메인 목록을 조회합니다...".colorize(:yellow)
127
+
128
+ zones = get_cloudflare_zones(token)
129
+
130
+ if zones.empty?
131
+ puts "❌ Cloudflare에 등록된 도메인이 없습니다.".colorize(:red)
132
+ puts "먼저 https://dash.cloudflare.com 에서 도메인을 추가해주세요.".colorize(:cyan)
133
+ exit 1
134
+ end
135
+
136
+ prompt = TTY::Prompt.new
137
+ zone_choices = zones.map { |zone| "#{zone['name']} (#{zone['status']})" }
138
+
139
+ selected = prompt.select("도메인을 선택하세요:", zone_choices)
140
+ zone_name = selected.split(' ').first
141
+
142
+ selected_zone = zones.find { |zone| zone['name'] == zone_name }
143
+ puts "✅ 선택된 도메인: #{zone_name}".colorize(:green)
144
+
145
+ return selected_zone
146
+ end
147
+
148
+ def get_cloudflare_zones(token)
149
+ uri = URI('https://api.cloudflare.com/client/v4/zones')
150
+ http = Net::HTTP.new(uri.host, uri.port)
151
+ http.use_ssl = true
152
+
153
+ request = Net::HTTP::Get.new(uri)
154
+ request['Authorization'] = "Bearer #{token}"
155
+ request['Content-Type'] = 'application/json'
156
+
157
+ response = http.request(request)
158
+
159
+ if response.code == '200'
160
+ data = JSON.parse(response.body)
161
+ return data['result'] || []
162
+ else
163
+ puts "❌ 도메인 목록 조회에 실패했습니다: #{response.code}".colorize(:red)
164
+ exit 1
165
+ end
166
+ rescue => e
167
+ puts "❌ API 요청 중 오류가 발생했습니다: #{e.message}".colorize(:red)
168
+ exit 1
169
+ end
170
+
171
+ def check_existing_records(token, zone, domain_info)
172
+ puts "\n🔍 기존 DNS 레코드를 확인합니다...".colorize(:yellow)
173
+
174
+ zone_id = zone['id']
175
+ zone_name = zone['name']
176
+
177
+ # 루트 도메인의 A/CNAME 레코드 확인
178
+ records = get_dns_records(token, zone_id, zone_name, ['A', 'CNAME'])
179
+
180
+ puts "기존 레코드: #{records.length}개 발견".colorize(:gray)
181
+
182
+ return records
183
+ end
184
+
185
+ def get_dns_records(token, zone_id, name, types)
186
+ records = []
187
+
188
+ types.each do |type|
189
+ uri = URI("https://api.cloudflare.com/client/v4/zones/#{zone_id}/dns_records")
190
+ uri.query = URI.encode_www_form({
191
+ type: type,
192
+ name: name
193
+ })
194
+
195
+ http = Net::HTTP.new(uri.host, uri.port)
196
+ http.use_ssl = true
197
+
198
+ request = Net::HTTP::Get.new(uri)
199
+ request['Authorization'] = "Bearer #{token}"
200
+ request['Content-Type'] = 'application/json'
201
+
202
+ response = http.request(request)
203
+
204
+ if response.code == '200'
205
+ data = JSON.parse(response.body)
206
+ records.concat(data['result'] || [])
207
+ end
208
+ end
209
+
210
+ return records
211
+ rescue => e
212
+ puts "❌ DNS 레코드 조회 중 오류: #{e.message}".colorize(:red)
213
+ return []
214
+ end
215
+
216
+ def setup_dns_record(token, zone, domain_info, existing_records)
217
+ puts "\n⚙️ DNS 레코드를 설정합니다...".colorize(:yellow)
218
+
219
+ # 홈서버 IP/URL 입력받기
220
+ prompt = TTY::Prompt.new
221
+
222
+ server_info = prompt.ask("홈서버 IP 또는 도메인을 입력하세요:") do |q|
223
+ q.validate(/\A.+\z/, "서버 정보를 입력해주세요")
224
+ end
225
+
226
+ # SSH 사용자 계정 입력받기
227
+ ssh_user = prompt.ask("SSH 사용자 계정을 입력하세요:", default: "root")
228
+
229
+ # IP인지 도메인인지 판단
230
+ is_ip = server_info.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
231
+ record_type = is_ip ? 'A' : 'CNAME'
232
+
233
+ zone_id = zone['id']
234
+ zone_name = zone['name']
235
+
236
+ # 도메인 정보에 따라 레코드 설정
237
+ final_domain = determine_final_domain(domain_info, zone_name, existing_records)
238
+
239
+ # 대상 도메인의 모든 A/CNAME 레코드 확인
240
+ all_records = get_dns_records(token, zone_id, final_domain[:name], ['A', 'CNAME'])
241
+
242
+ if all_records.any?
243
+ existing_record = all_records.first
244
+
245
+ # 동일한 타입이고 같은 값이면 건너뛰기
246
+ if existing_record['type'] == record_type && existing_record['content'] == server_info
247
+ puts "✅ DNS 레코드가 이미 올바르게 설정되어 있습니다.".colorize(:green)
248
+ puts " #{final_domain[:full_domain]} → #{server_info} (#{record_type} 레코드)".colorize(:gray)
249
+ else
250
+ # 타입이 다르거나 값이 다른 경우 삭제 후 재생성
251
+ puts "⚠️ 기존 레코드를 삭제하고 새로 생성합니다.".colorize(:yellow)
252
+ puts " 기존: #{existing_record['content']} (#{existing_record['type']}) → 새로운: #{server_info} (#{record_type})".colorize(:gray)
253
+
254
+ # 기존 레코드 삭제
255
+ delete_dns_record(token, zone_id, existing_record['id'])
256
+
257
+ # 새 레코드 생성
258
+ create_dns_record(token, zone_id, final_domain[:name], record_type, server_info)
259
+ end
260
+ else
261
+ # DNS 레코드 생성
262
+ create_dns_record(token, zone_id, final_domain[:name], record_type, server_info)
263
+ end
264
+
265
+ # 최종 도메인 정보 저장
266
+ @final_domain = final_domain[:full_domain]
267
+ @server_info = server_info
268
+ @ssh_user = ssh_user
269
+ end
270
+
271
+ def determine_final_domain(domain_info, zone_name, existing_records)
272
+ case domain_info[:type]
273
+ when :root
274
+ if existing_records.any?
275
+ puts "⚠️ 루트 도메인에 이미 레코드가 있습니다. app.#{zone_name}을 사용합니다.".colorize(:yellow)
276
+ { name: "app.#{zone_name}", full_domain: "app.#{zone_name}" }
277
+ else
278
+ { name: zone_name, full_domain: zone_name }
279
+ end
280
+ when :subdomain
281
+ { name: domain_info[:domain], full_domain: domain_info[:domain] }
282
+ end
283
+ end
284
+
285
+ def create_dns_record(token, zone_id, name, type, content)
286
+ uri = URI("https://api.cloudflare.com/client/v4/zones/#{zone_id}/dns_records")
287
+ http = Net::HTTP.new(uri.host, uri.port)
288
+ http.use_ssl = true
289
+
290
+ request = Net::HTTP::Post.new(uri)
291
+ request['Authorization'] = "Bearer #{token}"
292
+ request['Content-Type'] = 'application/json'
293
+
294
+ data = {
295
+ type: type,
296
+ name: name,
297
+ content: content,
298
+ ttl: 300
299
+ }
300
+
301
+ request.body = data.to_json
302
+ response = http.request(request)
303
+
304
+ if response.code == '200'
305
+ puts "✅ DNS 레코드가 생성되었습니다.".colorize(:green)
306
+ puts " #{name} → #{content} (#{type} 레코드)".colorize(:gray)
307
+ else
308
+ puts "❌ DNS 레코드 생성에 실패했습니다: #{response.code}".colorize(:red)
309
+ puts response.body
310
+ exit 1
311
+ end
312
+ rescue => e
313
+ puts "❌ DNS 레코드 생성 중 오류: #{e.message}".colorize(:red)
314
+ exit 1
315
+ end
316
+
317
+ def delete_dns_record(token, zone_id, record_id)
318
+ uri = URI("https://api.cloudflare.com/client/v4/zones/#{zone_id}/dns_records/#{record_id}")
319
+ http = Net::HTTP.new(uri.host, uri.port)
320
+ http.use_ssl = true
321
+
322
+ request = Net::HTTP::Delete.new(uri)
323
+ request['Authorization'] = "Bearer #{token}"
324
+ request['Content-Type'] = 'application/json'
325
+
326
+ response = http.request(request)
327
+
328
+ if response.code == '200'
329
+ puts "✅ 기존 DNS 레코드가 삭제되었습니다.".colorize(:green)
330
+ else
331
+ puts "❌ DNS 레코드 삭제에 실패했습니다: #{response.code}".colorize(:red)
332
+ puts response.body
333
+ exit 1
334
+ end
335
+ rescue => e
336
+ puts "❌ DNS 레코드 삭제 중 오류: #{e.message}".colorize(:red)
337
+ exit 1
338
+ end
339
+
340
+ def update_deploy_config(domain_info)
341
+ puts "\n📝 배포 설정을 업데이트합니다...".colorize(:yellow)
342
+
343
+ config_file = "config/deploy.yml"
344
+
345
+ unless File.exist?(config_file)
346
+ puts "⚠️ config/deploy.yml 파일이 없습니다.".colorize(:yellow)
347
+ return
348
+ end
349
+
350
+ content = File.read(config_file)
351
+
352
+ # proxy.host 설정 업데이트
353
+ if content.include?("proxy:")
354
+ content.gsub!(/(\s+host:\s+).*$/, "\\1#{@final_domain}")
355
+ else
356
+ # proxy 섹션이 없으면 추가
357
+ proxy_config = "\n# Proxy configuration\nproxy:\n ssl: true\n host: #{@final_domain}\n"
358
+ content += proxy_config
359
+ end
360
+
361
+ # servers 설정 업데이트
362
+ if content.match?(/servers:\s*\n\s*web:\s*\n\s*-\s*/)
363
+ content.gsub!(/(\s*servers:\s*\n\s*web:\s*\n\s*-\s*)[\d.]+/, "\\1#{@server_info}")
364
+ end
365
+
366
+ # ssh user 설정 업데이트
367
+ if @ssh_user && @ssh_user != "root"
368
+ if content.match?(/^ssh:/)
369
+ # 기존 ssh 섹션 업데이트
370
+ content.gsub!(/^ssh:\s*\n\s*user:\s*\w+/, "ssh:\n user: #{@ssh_user}")
371
+ else
372
+ # ssh 섹션 추가 (accessories 섹션 앞에 추가)
373
+ if content.match?(/^# Use accessory services/)
374
+ content.gsub!(/^# Use accessory services/, "# Use a different ssh user than root\nssh:\n user: #{@ssh_user}\n\n# Use accessory services")
375
+ else
376
+ # 파일 끝에 추가
377
+ content += "\n# Use a different ssh user than root\nssh:\n user: #{@ssh_user}\n"
378
+ end
379
+ end
380
+ end
381
+
382
+ File.write(config_file, content)
383
+ puts "✅ config/deploy.yml이 업데이트되었습니다.".colorize(:green)
384
+ puts " proxy.host: #{@final_domain}".colorize(:gray)
385
+ puts " servers.web: #{@server_info}".colorize(:gray)
386
+ puts " ssh.user: #{@ssh_user}".colorize(:gray) if @ssh_user && @ssh_user != "root"
387
+ end
388
+ end
389
+ end
390
+ end