openclacky 1.0.0 → 1.0.2

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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +87 -53
  4. data/lib/clacky/agent/cost_tracker.rb +19 -2
  5. data/lib/clacky/agent/llm_caller.rb +218 -0
  6. data/lib/clacky/agent/message_compressor_helper.rb +32 -2
  7. data/lib/clacky/agent.rb +54 -22
  8. data/lib/clacky/client.rb +44 -5
  9. data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
  10. data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
  11. data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
  12. data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
  13. data/lib/clacky/default_skills/new/SKILL.md +3 -114
  14. data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
  15. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
  16. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
  17. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
  18. data/lib/clacky/message_format/anthropic.rb +72 -8
  19. data/lib/clacky/message_format/bedrock.rb +6 -3
  20. data/lib/clacky/providers.rb +146 -3
  21. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
  22. data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
  23. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
  24. data/lib/clacky/server/channel/channel_manager.rb +12 -4
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
  26. data/lib/clacky/server/http_server.rb +746 -13
  27. data/lib/clacky/server/session_registry.rb +55 -24
  28. data/lib/clacky/skill.rb +10 -9
  29. data/lib/clacky/skill_loader.rb +23 -11
  30. data/lib/clacky/tools/file_reader.rb +232 -127
  31. data/lib/clacky/tools/security.rb +42 -64
  32. data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
  33. data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
  34. data/lib/clacky/tools/terminal/session_manager.rb +8 -3
  35. data/lib/clacky/tools/terminal.rb +263 -16
  36. data/lib/clacky/ui2/layout_manager.rb +8 -1
  37. data/lib/clacky/ui2/output_buffer.rb +83 -23
  38. data/lib/clacky/ui2/ui_controller.rb +74 -7
  39. data/lib/clacky/utils/file_processor.rb +14 -40
  40. data/lib/clacky/utils/model_pricing.rb +215 -0
  41. data/lib/clacky/utils/parser_manager.rb +70 -6
  42. data/lib/clacky/utils/string_matcher.rb +23 -1
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +673 -9
  45. data/lib/clacky/web/app.js +40 -1608
  46. data/lib/clacky/web/i18n.js +209 -0
  47. data/lib/clacky/web/index.html +166 -2
  48. data/lib/clacky/web/onboard.js +77 -1
  49. data/lib/clacky/web/profile.js +442 -0
  50. data/lib/clacky/web/sessions.js +1034 -2
  51. data/lib/clacky/web/settings.js +127 -6
  52. data/lib/clacky/web/sidebar.js +39 -0
  53. data/lib/clacky/web/skills.js +460 -0
  54. data/lib/clacky/web/trash.js +343 -0
  55. data/lib/clacky/web/ws-dispatcher.js +255 -0
  56. data/lib/clacky.rb +5 -3
  57. metadata +16 -17
  58. data/lib/clacky/clacky_auth_client.rb +0 -152
  59. data/lib/clacky/clacky_cloud_config.rb +0 -123
  60. data/lib/clacky/cloud_project_client.rb +0 -169
  61. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
  62. data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
  63. data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
  64. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
  65. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
  66. data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
  67. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
  68. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
  69. data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
  70. data/lib/clacky/deploy_api_client.rb +0 -484
@@ -1,1377 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Ensure all output is flushed immediately so users see live progress
4
- # even when the script is run inside a subprocess (terminal / Open3).
5
- $stdout.sync = true
6
- $stderr.sync = true
7
-
8
- require "yaml"
9
- require "json"
10
- require "fileutils"
11
- require "open3"
12
-
13
- # Load gem libs — resolve path relative to this script's location
14
- DEPLOY_SCRIPT_DIR = File.expand_path("..", __FILE__)
15
- GEM_LIB_DIR = File.expand_path("../../../../..", DEPLOY_SCRIPT_DIR)
16
-
17
- $LOAD_PATH.unshift(GEM_LIB_DIR) unless $LOAD_PATH.include?(GEM_LIB_DIR)
18
-
19
- require "clacky/clacky_cloud_config"
20
- require "clacky/cloud_project_client"
21
- require "clacky/deploy_api_client"
22
-
23
- require_relative "../tools/execute_deployment"
24
- require_relative "../tools/set_deploy_variables"
25
- require_relative "../tools/create_database_service"
26
- require_relative "../tools/list_services"
27
- require_relative "../tools/fetch_runtime_logs"
28
- require_relative "../tools/check_health"
29
-
30
- module Clacky
31
- module DeployTemplates
32
- # RailsDeploy — three-phase automated deployment to Railway via Clacky platform.
33
- #
34
- # Phase 0: Cloud project binding check (openclacky.yml + workspace_key + API)
35
- # Phase 1: Subscription / payment check
36
- # Phase 2: 8-step Railway deployment
37
- class RailsDeploy
38
-
39
- PAYMENT_POLL_INTERVAL = 10 # seconds between payment status checks
40
- PAYMENT_POLL_MAX = 18 # max attempts (180 seconds / 3 minutes total)
41
- DB_POLL_INTERVAL = 2 # seconds between service readiness checks
42
- DB_POLL_MAX = 60 # max attempts (120 seconds total)
43
- DEPLOY_POLL_INTERVAL = 5 # seconds between deploy status checks
44
- DEPLOY_POLL_MAX = 60 # max attempts (300 seconds total)
45
-
46
- # DASHBOARD_BASE_URL is resolved dynamically from ClackyCloudConfig#dashboard_url
47
- # so it automatically tracks the environment (prod / staging / local).
48
- DASHBOARD_PATH = "/dashboard/openclacky-project"
49
-
50
- def self.execute
51
- new.run
52
- end
53
-
54
- # -----------------------------------------------------------------------
55
- # Phase tracking — prints timing for each phase
56
- # -----------------------------------------------------------------------
57
-
58
- private def set_phase(label)
59
- now = Time.now
60
- # Print timing for the phase that just completed
61
- if @current_phase && @phase_started_at
62
- elapsed = (now - @phase_started_at).round
63
- puts " ⏱ #{@current_phase} — #{elapsed}s"
64
- end
65
- @current_phase = label
66
- @phase_started_at = now
67
- puts "\n[#{label}]"
68
- $stdout.flush
69
- end
70
-
71
- # Call at the very end of run to print timing for the last phase.
72
- private def finish_phase
73
- return unless @current_phase && @phase_started_at
74
- elapsed = (Time.now - @phase_started_at).round
75
- puts " ⏱ #{@current_phase} — #{elapsed}s"
76
- $stdout.flush
77
- end
78
-
79
- # -----------------------------------------------------------------------
80
- # Top-level orchestration
81
- # -----------------------------------------------------------------------
82
-
83
- def run
84
- result = nil
85
- started_at = Time.now
86
-
87
- begin
88
- print_banner
89
- puts "[DEPLOY] Started at #{started_at.strftime("%Y-%m-%d %H:%M:%S")}"
90
-
91
- # Phase 0: binding + workspace key + project details
92
- set_phase("Phase 0: verifying cloud project binding")
93
- phase0 = run_phase0
94
- unless phase0[:success]
95
- result = phase0
96
- return result
97
- end
98
-
99
- # @dashboard_base_url is set by load_clacky_cloud_config during Phase 0
100
- project = phase0[:project]
101
- project_id = phase0[:project_id]
102
- api_client = phase0[:api_client]
103
-
104
- # Phase 1: subscription / payment
105
- set_phase("Phase 1: checking subscription")
106
- phase1 = run_phase1(project, project_id, api_client)
107
- unless phase1[:success]
108
- result = phase1
109
- return result
110
- end
111
-
112
- # Phase 2: deploy
113
- set_phase("Phase 2: Railway deployment")
114
- result = run_phase2(project, project_id, api_client, started_at: started_at)
115
- result
116
- rescue => e
117
- result = { success: false, error: "Unexpected error: #{e.message}" }
118
- puts "❌ Unexpected error: #{e.message}"
119
- puts e.backtrace.first(10).join("\n")
120
- result
121
- ensure
122
- finish_phase # print timing for the last phase
123
- result ||= { success: false, error: "Unknown error" }
124
- elapsed_total = (Time.now - started_at).round
125
- duration_str = format_duration(elapsed_total)
126
- if result[:success]
127
- puts "\n[DEPLOY] RESULT: SUCCESS (#{duration_str})"
128
- else
129
- puts "\n[DEPLOY] RESULT: FAILED (#{duration_str}) — #{result[:error]}"
130
- end
131
- end
132
- end
133
-
134
- private def format_duration(seconds)
135
- return "#{seconds}s" if seconds < 60
136
- m = seconds / 60
137
- s = seconds % 60
138
- s > 0 ? "#{m}m #{s}s" : "#{m}m"
139
- end
140
-
141
- # -----------------------------------------------------------------------
142
- # Phase 0 — Cloud project binding check
143
- # -----------------------------------------------------------------------
144
-
145
- def run_phase0
146
- puts "\n[Phase 0] Verifying cloud project binding...\n"
147
-
148
- # 0.1 Read .clacky/openclacky.yml
149
- print " 📄 Reading project binding file..."
150
- binding_result = load_binding_file
151
- return binding_result unless binding_result[:success]
152
- project_id = binding_result[:project_id]
153
- puts " ✅ (#{project_id})"
154
-
155
- # 0.2 Load platform config (workspace_key)
156
- print " 🔑 Loading platform config..."
157
- cfg_result = load_clacky_cloud_config
158
- return cfg_result unless cfg_result[:success]
159
- workspace_key = cfg_result[:workspace_key]
160
- base_url = cfg_result[:base_url]
161
- puts " ✅"
162
-
163
- # 0.3 Fetch project details from API
164
- print " 🌐 Verifying project with Clacky API..."
165
- api_client = DeployApiClient.new(workspace_key, base_url: base_url)
166
- cloud_client = CloudProjectClient.new(workspace_key, base_url: base_url)
167
-
168
- project_result = fetch_project(cloud_client, api_client, project_id, workspace_key, base_url)
169
- return project_result unless project_result[:success]
170
-
171
- puts " ✅"
172
- puts "✅ Cloud project verified: #{project_result[:project]["name"]} (#{project_id})"
173
-
174
- {
175
- success: true,
176
- project: project_result[:project],
177
- project_id: project_id,
178
- api_client: api_client,
179
- cloud_client: cloud_client
180
- }
181
- end
182
-
183
- # -----------------------------------------------------------------------
184
- # Phase 1 — Subscription check
185
- # -----------------------------------------------------------------------
186
-
187
- def run_phase1(project, project_id, api_client)
188
- puts "\n[Phase 1] Checking subscription status...\n"
189
-
190
- subscription = project["subscription"]
191
- status = subscription&.dig("status").to_s.upcase
192
-
193
- case status
194
- when "PAID"
195
- puts "✅ Subscription active (PAID)"
196
- { success: true }
197
- when "FREEZE"
198
- puts "⚠️ Subscription expiring soon (FREEZE). Continuing deployment..."
199
- { success: true }
200
- when "SUSPENDED"
201
- hard_fail("Subscription is SUSPENDED. Please contact support.")
202
- else
203
- # nil / "OFF" / "CANCELLED" → payment required
204
- run_payment_flow(project, project_id, api_client)
205
- end
206
- end
207
-
208
- # -----------------------------------------------------------------------
209
- # Phase 2 — Railway deployment (8 steps)
210
- # -----------------------------------------------------------------------
211
-
212
- def run_phase2(project, project_id, api_client, started_at: nil)
213
- puts "\n[Phase 2] Starting Railway deployment...\n"
214
-
215
- # Pre-check: railway CLI installed?
216
- unless railway_cli_available?
217
- return hard_fail(
218
- "Railway CLI not found.\n" \
219
- " Install: npm install -g @railway/cli\n" \
220
- " Then retry deployment."
221
- )
222
- end
223
-
224
- # Step 0: ensure Gemfile.lock includes x86_64-linux platform
225
- set_phase("Step 0: preparing project for Railway")
226
- step0 = step0_prepare_linux_platform
227
- return step0 unless step0[:success]
228
-
229
- # Step 0b: let user choose a deployment region
230
- set_phase("Step 0b: selecting deployment region")
231
- region_step = step0b_select_region(api_client, project_id)
232
- return region_step unless region_step[:success]
233
- selected_region = region_step[:region]
234
-
235
- # Step 1: create deploy task (pass selected region if any)
236
- set_phase("Step 1: creating deploy task")
237
- task = step1_create_task(project_id, api_client, region: selected_region)
238
- return task unless task[:success]
239
-
240
- platform_token = task[:platform_token]
241
- # platform_token is used for all Railway CLI commands (link, variables, up, run, etc.)
242
- # as well as Clacky internal API calls.
243
- railway_token = platform_token
244
- platform_project_id = task[:platform_project_id]
245
- deploy_task_id = task[:deploy_task_id]
246
- deploy_service_id = task[:deploy_service_id]
247
-
248
- # Step 2: railway link
249
- set_phase("Step 2: linking Railway project")
250
- link = step2_railway_link(railway_token, platform_project_id)
251
- return link unless link[:success]
252
- main_service_name = link[:service_name]
253
-
254
- # Step 3: inject env vars
255
- set_phase("Step 3: injecting env vars")
256
- env_result = step3_inject_env_vars(main_service_name, project, railway_token)
257
- return env_result unless env_result[:success]
258
-
259
- # Step 3.5: ensure database exists (create only on first deploy, reuse on updates)
260
- set_phase("Step 3.5: ensuring database service")
261
- db_result = step3_5_create_database(main_service_name, railway_token)
262
- return db_result unless db_result[:success]
263
-
264
- # Step 4: wait for domain binding and fetch storage bucket credentials
265
- set_phase("Step 4: waiting for domain binding")
266
- step4 = step4_wait_domain_and_bucket(deploy_task_id, api_client)
267
- return step4 unless step4[:success]
268
- domain_name = step4[:domain_name]
269
- bucket_credentials = step4[:bucket_credentials]
270
- bucket_name = step4[:bucket_name]
271
-
272
- # Inject storage bucket credentials if available (separate from main env vars
273
- # because we need to call services API first to get them)
274
- if bucket_credentials
275
- step3b_inject_bucket_vars(main_service_name, bucket_credentials, bucket_name, railway_token)
276
- end
277
-
278
- # Step 5: trigger build and wait for completion (blocking)
279
- set_phase("Step 5: building and deploying")
280
- build = step5_deploy_and_wait(main_service_name, project_id, deploy_task_id, api_client, railway_token)
281
- return build unless build[:success]
282
-
283
- deployment_url = build[:url]
284
-
285
- # Step 6: database migrations
286
- set_phase("Step 6: running database migrations")
287
- step6_run_migrations(main_service_name, railway_token)
288
-
289
- # Step 7: health check + notify success
290
- set_phase("Step 7: health check + finalising")
291
- step7_finish(deployment_url, project_id, deploy_task_id, deploy_service_id, api_client,
292
- started_at: started_at)
293
- end
294
-
295
- # -----------------------------------------------------------------------
296
- # Phase 0 helpers
297
- # -----------------------------------------------------------------------
298
-
299
- def load_binding_file
300
- binding_file = ".clacky/openclacky.yml"
301
-
302
- unless File.exist?(binding_file)
303
- return run_create_cloud_project
304
- end
305
-
306
- data = YAML.safe_load(File.read(binding_file)) || {}
307
- project_id = data["project_id"].to_s.strip
308
-
309
- if project_id.empty?
310
- return hard_fail(
311
- ".clacky/openclacky.yml exists but project_id is missing.\n" \
312
- " The file may be corrupted. Delete it and run /new to reinitialize."
313
- )
314
- end
315
-
316
- { success: true, project_id: project_id }
317
- rescue => e
318
- hard_fail("Failed to read .clacky/openclacky.yml: #{e.message}")
319
- end
320
-
321
- def load_clacky_cloud_config
322
- cfg = ClackyCloudConfig.load
323
-
324
- if cfg.workspace_key.nil? || cfg.workspace_key.empty?
325
- return hard_fail(
326
- "No Clacky workspace key configured (~/.clacky/clacky_cloud.yml).\n" \
327
- " Obtain a workspace key offline, then run:\n" \
328
- " clacky config set workspace_key <clacky_ak_xxx>"
329
- )
330
- end
331
-
332
- # Store dashboard base so payment_flow and step8 use the right environment URL
333
- @dashboard_base_url = "#{cfg.dashboard_url}#{DASHBOARD_PATH}"
334
-
335
- { success: true, workspace_key: cfg.workspace_key, base_url: cfg.base_url }
336
- end
337
-
338
- def fetch_project(cloud_client, api_client, project_id, workspace_key, base_url)
339
- result = cloud_client.get_project(project_id)
340
-
341
- # 404 or project missing → recreate cloud project
342
- if !result[:success] && result[:error].to_s.include?("404")
343
- puts "⚠️ Cloud project not found (404). Creating a new one..."
344
- return run_create_cloud_project
345
- end
346
-
347
- unless result[:success]
348
- return hard_fail("Unable to verify project: #{result[:error]}\n" \
349
- " Check your network connection and workspace key.")
350
- end
351
-
352
- { success: true, project: result[:project] }
353
- end
354
-
355
- # Inline cloud project creation — reuses cloud_project_init.sh
356
- # Only creates the cloud record + writes .clacky/openclacky.yml.
357
- # Does NOT clone template, run bin/setup, or start a server.
358
- def run_create_cloud_project
359
- puts "\n📦 Initializing cloud project binding...\n"
360
-
361
- script = File.expand_path(
362
- "../../new/scripts/cloud_project_init.sh",
363
- DEPLOY_SCRIPT_DIR
364
- )
365
-
366
- unless File.exist?(script)
367
- return hard_fail("cloud_project_init.sh not found at: #{script}")
368
- end
369
-
370
- project_name = File.basename(Dir.pwd)
371
- env = {
372
- "GEM_LIB_DIR" => GEM_LIB_DIR,
373
- "PROJECT_NAME" => project_name
374
- }
375
-
376
- output, status = Open3.capture2(env, "bash", script, project_name)
377
-
378
- unless status.success?
379
- return hard_fail("Cloud project creation failed (exit #{status.exitstatus})")
380
- end
381
-
382
- result = JSON.parse(output.strip)
383
-
384
- unless result["success"]
385
- return hard_fail("Cloud project creation failed: #{result["error"]}")
386
- end
387
-
388
- project_id = result["project_id"]
389
- project_name = result["project_name"]
390
-
391
- # Write .clacky/openclacky.yml
392
- write_binding_file(project_id, project_name)
393
-
394
- # Write integration env vars if categorized_config present
395
- write_categorized_config(result["categorized_config"]) if result["categorized_config"]
396
-
397
- puts "✅ Cloud project created: #{project_name} (#{project_id})"
398
- puts " Restarting Phase 0 with new project..."
399
-
400
- # Re-enter Phase 0 with new binding
401
- load_binding_file
402
- rescue JSON::ParserError => e
403
- hard_fail("Cloud project init returned invalid JSON: #{e.message}\n Raw: #{output.to_s[0, 200]}")
404
- rescue => e
405
- hard_fail("Cloud project creation error: #{e.message}")
406
- end
407
-
408
- def write_binding_file(project_id, project_name)
409
- FileUtils.mkdir_p(".clacky")
410
- File.write(".clacky/openclacky.yml", <<~YAML)
411
- project_id: #{project_id}
412
- project_name: #{project_name}
413
- YAML
414
- end
415
-
416
- # Persist the most recent deploy_task_id into .clacky/openclacky.yml.
417
- # Merges into the existing file so project_id / project_name are preserved.
418
- def write_deploy_task_id(deploy_task_id)
419
- return if deploy_task_id.to_s.strip.empty?
420
-
421
- binding_file = ".clacky/openclacky.yml"
422
- data = if File.exist?(binding_file)
423
- YAML.safe_load(File.read(binding_file)) || {}
424
- else
425
- {}
426
- end
427
-
428
- data["deploy_task_id"] = deploy_task_id.to_s.strip
429
-
430
- FileUtils.mkdir_p(".clacky")
431
- File.write(binding_file, data.to_yaml)
432
- rescue => e
433
- warn " ⚠️ Could not write deploy_task_id to #{binding_file}: #{e.message}"
434
- end
435
-
436
- # Read the most recent deploy_task_id from .clacky/openclacky.yml.
437
- # Returns nil if the file doesn't exist or the key is absent.
438
- def read_deploy_task_id
439
- binding_file = ".clacky/openclacky.yml"
440
- return nil unless File.exist?(binding_file)
441
-
442
- data = YAML.safe_load(File.read(binding_file)) || {}
443
- id = data["deploy_task_id"].to_s.strip
444
- id.empty? ? nil : id
445
- rescue => e
446
- warn " ⚠️ Could not read deploy_task_id from #{binding_file}: #{e.message}"
447
- nil
448
- end
449
-
450
- def write_categorized_config(categorized_config)
451
- return if categorized_config.nil? || categorized_config.empty?
452
-
453
- # Flatten all categories into a single env hash
454
- env_vars = {}
455
- categorized_config.each_value do |vars|
456
- next unless vars.is_a?(Hash)
457
- vars.each { |k, v| env_vars[k.to_s] = v.to_s }
458
- end
459
-
460
- return if env_vars.empty?
461
-
462
- # Append to .env.development.local
463
- env_file = ".env.development.local"
464
- File.open(env_file, "a") do |f|
465
- f.puts "\n# Clacky platform integrations (auto-generated)"
466
- env_vars.each { |k, v| f.puts "#{k}=#{v}" }
467
- end
468
-
469
- # Append to config/application.yml if it exists
470
- app_yml = "config/application.yml"
471
- if File.exist?(app_yml)
472
- File.open(app_yml, "a") do |f|
473
- f.puts "\n # Clacky platform integrations (auto-generated)"
474
- env_vars.each { |k, v| f.puts " #{k}: \"#{v}\"" }
475
- end
476
- end
477
- end
478
-
479
- # -----------------------------------------------------------------------
480
- # Phase 1 helpers
481
- # -----------------------------------------------------------------------
482
-
483
- def run_payment_flow(project, project_id, api_client)
484
- project_name = project["name"]
485
-
486
- puts "\n❌ Deployment blocked: Clacky subscription required."
487
- puts " Project : #{project_name} (#{project_id})"
488
- puts " Status : #{project.dig("subscription", "status") || "none"}"
489
- puts "\n A subscription is needed before deployment can proceed."
490
-
491
- payment_url = "#{@dashboard_base_url}/#{project_id}"
492
- open_browser(payment_url)
493
- puts "\n🌐 Payment page opened:"
494
- puts " #{payment_url}"
495
- puts "\n⏳ Waiting for payment confirmation (max 3 minutes)...\n"
496
-
497
- # Poll payment status every PAYMENT_POLL_INTERVAL seconds.
498
- total_seconds = PAYMENT_POLL_INTERVAL * PAYMENT_POLL_MAX # 180s
499
-
500
- PAYMENT_POLL_MAX.times do |i|
501
- elapsed = i * PAYMENT_POLL_INTERVAL
502
- result = api_client.payment_status(project_id: project_id)
503
-
504
- if result[:success] && result[:is_paid]
505
- puts " ✅ Payment confirmed (#{elapsed}s)"
506
- return { success: true }
507
- end
508
-
509
- remaining = total_seconds - elapsed
510
- puts " ⏳ [#{elapsed}s] Waiting for payment... (#{remaining}s remaining)"
511
-
512
- sleep PAYMENT_POLL_INTERVAL unless i == PAYMENT_POLL_MAX - 1
513
- end
514
-
515
- # Timeout — exit with clear guidance
516
- puts ""
517
- hard_fail(
518
- "Payment not confirmed within 3 minutes.\n" \
519
- " Once payment is complete, re-run: /deploy"
520
- )
521
- end
522
-
523
- # -----------------------------------------------------------------------
524
- # Phase 2 step helpers
525
- # -----------------------------------------------------------------------
526
-
527
- # Fetch deployment regions from the API and prompt the user to pick one.
528
- # Falls back gracefully: if API fails or returns an empty list, asks the user
529
- # to input a region manually; entering nothing skips region selection entirely.
530
- #
531
- # @param api_client [DeployApiClient]
532
- # @param project_id [String]
533
- # @return [Hash] { success: true, region: String | nil }
534
- def step0b_select_region(api_client, project_id)
535
- puts "\n[Step 0b] Selecting deployment region..."
536
-
537
- result = api_client.regions(project_id: project_id)
538
-
539
- regions = if result[:success] && result[:regions].any?
540
- result[:regions]
541
- else
542
- warn " ⚠️ Could not fetch region list: #{result[:error]}" unless result[:success]
543
- []
544
- end
545
-
546
- # Non-interactive mode: skip stdin prompts entirely when not running in a real TTY
547
- # (e.g. called from agent subshell). This prevents indefinite blocking on $stdin.gets.
548
- unless $stdin.isatty
549
- if regions.any?
550
- selected = regions.first
551
- region = selected["region"] # Always use region field (ID like "us-west2")
552
- puts " ℹ️ Non-interactive mode — auto-selecting first region: #{region}"
553
- return { success: true, region: region }
554
- else
555
- puts " ℹ️ Non-interactive mode — using platform default region"
556
- return { success: true, region: nil }
557
- end
558
- end
559
-
560
- if regions.empty?
561
- # Manual fallback with 20s timeout to prevent indefinite blocking
562
- print " Enter a region slug (leave blank to skip, auto-skip in 20s): "
563
- $stdout.flush
564
- input = timed_gets(20).to_s.strip
565
- region = input.empty? ? nil : input
566
- puts region ? " ✅ Region set to: #{region}" : " ℹ️ No region specified, using platform default"
567
- return { success: true, region: region }
568
- end
569
-
570
- # Display numbered list
571
- puts " Available regions:"
572
- regions.each_with_index do |r, idx|
573
- region_id = r["region"]
574
- puts " #{idx + 1}) #{region_id}"
575
- end
576
-
577
- # Prompt for selection with 20s timeout to prevent indefinite blocking
578
- print " Enter region number (1-#{regions.size}, or press Enter/wait 20s for default): "
579
- $stdout.flush
580
- input = timed_gets(20).to_s.strip
581
-
582
- if input.empty?
583
- puts " ℹ️ No region selected, using platform default"
584
- return { success: true, region: nil }
585
- end
586
-
587
- choice = input.to_i
588
- unless choice.between?(1, regions.size)
589
- puts " ⚠️ Invalid selection '#{input}', using platform default"
590
- return { success: true, region: nil }
591
- end
592
-
593
- selected = regions[choice - 1]
594
- region = selected["region"]
595
- puts " ✅ Region selected: #{region}"
596
-
597
- { success: true, region: region }
598
- rescue Interrupt
599
- puts "\n ℹ️ Region selection cancelled, using platform default"
600
- { success: true, region: nil }
601
- end
602
-
603
- # Read a line from stdin with a timeout. Returns nil (treated as empty) if the
604
- # timeout fires or stdin is not a TTY. Uses IO.select so it works on both
605
- # MRI and JRuby without spawning an extra thread.
606
- def timed_gets(seconds)
607
- ready = IO.select([$stdin], nil, nil, seconds)
608
- return nil unless ready
609
- $stdin.gets
610
- rescue
611
- nil
612
- end
613
-
614
- def step0_prepare_linux_platform
615
- puts "\n[Step 0] Preparing project for Railway deployment..."
616
-
617
- # 0-A: Ensure Dockerfile exists with optimal layer-caching structure
618
- dockerfile_result = ensure_dockerfile
619
- return dockerfile_result unless dockerfile_result[:success]
620
-
621
- # 0-B: Ensure railway.toml exists with DOCKERFILE builder + preDeployCommand
622
- toml_result = ensure_railway_toml
623
- return toml_result unless toml_result[:success]
624
-
625
- # 0-C: Ensure Gemfile.lock includes x86_64-linux platform
626
- gemfile_result = ensure_linux_platform
627
- return gemfile_result unless gemfile_result[:success]
628
-
629
- # 0-D: Commit any generated/modified files so Railway picks them up
630
- commit_result = commit_deploy_files
631
- return commit_result unless commit_result[:success]
632
-
633
- puts "✅ Step 0 complete — project is Railway-ready"
634
- { success: true }
635
- end
636
-
637
- # -----------------------------------------------------------------------
638
- # Step 0 sub-helpers
639
- # -----------------------------------------------------------------------
640
-
641
- def ensure_dockerfile
642
- if File.exist?("Dockerfile")
643
- puts " ✅ Dockerfile already exists"
644
- return { success: true }
645
- end
646
-
647
- hard_fail(
648
- "Dockerfile not found.\n" \
649
- " A Dockerfile is required for Railway deployment.\n" \
650
- " The rails-template-7x-starter includes one by default — " \
651
- "make sure you haven't accidentally deleted it."
652
- )
653
- end
654
-
655
- def ensure_railway_toml
656
- toml_path = "railway.toml"
657
-
658
- if File.exist?(toml_path)
659
- puts " ✅ railway.toml already exists"
660
- return { success: true }
661
- end
662
-
663
- hard_fail(
664
- "railway.toml not found.\n" \
665
- " A railway.toml is required for Railway deployment.\n" \
666
- " The rails-template-7x-starter includes one by default — " \
667
- "make sure you haven't accidentally deleted it."
668
- )
669
- end
670
-
671
- def ensure_linux_platform
672
- # Check 1: Gemfile.lock must exist
673
- unless File.exist?("Gemfile.lock")
674
- return hard_fail(
675
- "Gemfile.lock not found.\n" \
676
- " Run `bundle install` first to generate it."
677
- )
678
- end
679
-
680
- # Check 2: x86_64-linux must already be present
681
- lock_content = File.read("Gemfile.lock")
682
- if platform_already_present?(lock_content, "x86_64-linux")
683
- puts " ✅ x86_64-linux platform already present in Gemfile.lock"
684
- return { success: true }
685
- end
686
-
687
- hard_fail(
688
- "x86_64-linux platform is missing from Gemfile.lock.\n" \
689
- " Run: bundle lock --add-platform x86_64-linux\n" \
690
- " Then commit the updated Gemfile.lock and retry."
691
- )
692
- end
693
-
694
- def commit_deploy_files
695
- # Collect files that are git-tracked and have uncommitted changes
696
- files_to_commit = %w[Dockerfile railway.toml Gemfile.lock].select do |f|
697
- next false unless File.exist?(f)
698
- # Check if tracked by git
699
- _, _, tracked = Open3.capture3("git ls-files --error-unmatch #{f}")
700
- next false unless tracked.success?
701
- # Check if modified or new (untracked-but-staged)
702
- diff_out, _, _ = Open3.capture3("git status --porcelain #{f}")
703
- !diff_out.strip.empty?
704
- end
705
-
706
- if files_to_commit.empty?
707
- puts " ℹ️ No deploy files changed — skipping commit"
708
- return { success: true }
709
- end
710
-
711
- print " 📝 Committing deploy files (#{files_to_commit.join(", ")})..."
712
- _out, err, status = Open3.capture3(
713
- "git add #{files_to_commit.map { |f| "'#{f}'" }.join(" ")} && " \
714
- "git commit -m 'chore: prepare project for Railway deployment'"
715
- )
716
-
717
- unless status.success?
718
- puts " ❌"
719
- return hard_fail("git commit failed:\n#{err}")
720
- end
721
-
722
- puts " ✅"
723
- { success: true }
724
- end
725
-
726
- def step1_create_task(project_id, api_client, region: nil)
727
- puts "\n[Step 1] Creating deploy task..."
728
- result = api_client.create_task(project_id: project_id, region: region)
729
-
730
- unless result[:success]
731
- return hard_fail("Failed to create deploy task: #{result[:error]}")
732
- end
733
-
734
- # Persist deploy_task_id to .clacky/openclacky.yml so other tools can
735
- # query the most recent deployment without needing to call the API.
736
- write_deploy_task_id(result[:deploy_task_id])
737
-
738
- puts "✅ Deploy task created: #{result[:deploy_task_id]}"
739
- result
740
- end
741
-
742
- def step2_railway_link(railway_token, platform_project_id)
743
- puts "\n[Step 2] Linking Railway project..."
744
-
745
- # Write .railway/config.json directly instead of running `railway link`.
746
- # `railway link` requires an account-level token (RAILWAY_API_TOKEN) to list
747
- # workspaces/projects, but we only have a Project Token (RAILWAY_TOKEN).
748
- # Writing the config file is exactly what `railway link` does internally, and
749
- # all subsequent CLI commands (up, variables, run, logs) work fine with a
750
- # Project Token once the project binding is in place.
751
- print " 📝 Writing .railway/config.json..."
752
- begin
753
- FileUtils.mkdir_p(".railway")
754
- config = {
755
- "projectId" => platform_project_id,
756
- "environmentName" => "production"
757
- }
758
- File.write(".railway/config.json", JSON.generate(config))
759
- puts " ✅"
760
- rescue => e
761
- puts " ❌"
762
- return hard_fail("Failed to write .railway/config.json: #{e.message}")
763
- end
764
-
765
- # Detect main service name using RAILWAY_TOKEN (Project Token supports `railway status`)
766
- env = railway_env(railway_token)
767
- print " 🔍 Detecting service name..."
768
- svc_name = detect_service_name(env)
769
- puts " ✅ #{svc_name}"
770
- puts "✅ Linked to Railway project. Main service: #{svc_name}"
771
-
772
- { success: true, service_name: svc_name, railway_token: railway_token }
773
- end
774
-
775
- def step3_inject_env_vars(service_name, project, platform_token)
776
- puts "\n[Step 3] Injecting environment variables..."
777
-
778
- print " ⚙️ Building env vars (generating SECRET_KEY_BASE)..."
779
- vars = build_env_vars(project)
780
- puts " ✅ (#{vars.size} vars)"
781
-
782
- print " 📤 Pushing env vars to Railway..."
783
- result = DeployTools::SetDeployVariables.execute(
784
- service_name: service_name,
785
- variables: vars,
786
- platform_token: platform_token
787
- )
788
-
789
- unless result[:success]
790
- puts " ❌"
791
- return hard_fail("Failed to set environment variables: #{result[:errors].inspect}")
792
- end
793
-
794
- puts " ✅"
795
- puts "✅ Set #{result[:set_variables].length} environment variable(s)"
796
- { success: true }
797
- end
798
-
799
- # Step 3.5: Ensure database service exists (create if needed, reuse if exists)
800
- # Returns { success: true, db_service_name: "...", status: "existing|created" }
801
- def step3_5_create_database(main_service_name, railway_token)
802
- puts "\n[Step 3.5] Ensuring PostgreSQL database service..."
803
-
804
- result = DeployTools::CreateDatabaseService.execute(
805
- platform_token: railway_token
806
- )
807
-
808
- unless result[:success]
809
- puts " ❌"
810
- return hard_fail("Failed to ensure database: #{result[:error]}")
811
- end
812
-
813
- db_service_name = result[:service_name]
814
- database_url = result[:database_url]
815
- status = result[:status] # "existing" or "created"
816
-
817
- if status == "existing"
818
- puts " ♻️ Reusing existing database: #{db_service_name}"
819
- puts " ℹ️ DATABASE_URL is automatically shared by Railway"
820
- elsif status == "created"
821
- puts " ✅ Created new database: #{db_service_name}"
822
-
823
- # Only inject DATABASE_URL for newly created databases
824
- print " 💉 Injecting DATABASE_URL into #{main_service_name}..."
825
- inject_result = DeployTools::SetDeployVariables.execute(
826
- service_name: main_service_name,
827
- variables: { "DATABASE_URL" => database_url },
828
- platform_token: railway_token,
829
- raw_value: true
830
- )
831
-
832
- unless inject_result[:success]
833
- puts " ❌"
834
- return hard_fail("Failed to inject DATABASE_URL: #{inject_result[:errors].inspect}")
835
- end
836
- puts " ✅"
837
- end
838
-
839
- puts "✅ Database ready: #{db_service_name} (#{status})"
840
- { success: true, db_service_name: db_service_name, database_url: database_url, status: status }
841
-
842
- rescue => e
843
- puts " ❌"
844
- hard_fail("Unexpected error in step3_5: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
845
- end
846
-
847
- # Inject S3-compatible storage bucket credentials as STORAGE_BUCKET_* env vars.
848
- # Called after step4 because bucket credentials come from the services API response.
849
- # The bucket_credentials hash comes from platform_bucket_credentials in the API.
850
- #
851
- # Maps API fields → Railway env vars:
852
- # endpoint → STORAGE_BUCKET_ENDPOINT
853
- # accessKeyId → STORAGE_BUCKET_ACCESS_KEY_ID
854
- # secretAccessKey → STORAGE_BUCKET_SECRET_ACCESS_KEY
855
- # region → STORAGE_BUCKET_REGION + AWS_REGION
856
- # bucketName → STORAGE_BUCKET_NAME
857
- def step3b_inject_bucket_vars(service_name, bucket_credentials, bucket_name, platform_token)
858
- return unless bucket_credentials.is_a?(Hash)
859
-
860
- name = bucket_name.to_s.empty? ? bucket_credentials["bucketName"].to_s : bucket_name
861
-
862
- vars = {}
863
- vars["STORAGE_BUCKET_ENDPOINT"] = bucket_credentials["endpoint"].to_s unless bucket_credentials["endpoint"].to_s.empty?
864
- vars["STORAGE_BUCKET_ACCESS_KEY_ID"] = bucket_credentials["accessKeyId"].to_s unless bucket_credentials["accessKeyId"].to_s.empty?
865
- vars["STORAGE_BUCKET_SECRET_ACCESS_KEY"] = bucket_credentials["secretAccessKey"].to_s unless bucket_credentials["secretAccessKey"].to_s.empty?
866
- vars["STORAGE_BUCKET_NAME"] = name unless name.empty?
867
-
868
- region = bucket_credentials["region"].to_s
869
- region = "auto" if region.empty?
870
- vars["STORAGE_BUCKET_REGION"] = region
871
- vars["AWS_REGION"] = region
872
-
873
- return if vars.empty?
874
-
875
- puts "\n[Step 3b] Injecting storage bucket credentials (#{vars.size} vars)..."
876
- result = DeployTools::SetDeployVariables.execute(
877
- service_name: service_name,
878
- variables: vars,
879
- platform_token: platform_token
880
- )
881
-
882
- if result[:success]
883
- puts "✅ Storage bucket vars injected (STORAGE_BUCKET_*, AWS_REGION)"
884
- else
885
- puts "⚠️ Storage bucket vars partially failed: #{result[:errors].inspect}"
886
- end
887
- end
888
-
889
- # Step 4: Wait for domain binding and fetch storage bucket credentials
890
- # Database is now created in step3_5, so this step only handles domain + bucket
891
- def step4_wait_domain_and_bucket(deploy_task_id, api_client)
892
- puts "\n[Step 4] Waiting for domain binding..."
893
-
894
- domain_name = nil
895
- bucket_credentials = nil
896
- bucket_name = nil
897
- elapsed = 0
898
-
899
- # Poll for domain and bucket credentials
900
- puts " ⏳ Waiting for domain assignment (max #{DB_POLL_MAX * DB_POLL_INTERVAL}s)..."
901
-
902
- DB_POLL_MAX.times do |i|
903
- result = api_client.services(deploy_task_id: deploy_task_id)
904
-
905
- if result[:success]
906
- # Capture bucket credentials on first available result
907
- bucket_credentials ||= result[:bucket_credentials]
908
- bucket_name ||= result[:bucket_name]
909
-
910
- # Capture domain once available
911
- if domain_name.nil? && !result[:domain_name].to_s.empty?
912
- domain_name = result[:domain_name]
913
- puts " ✅ Domain assigned: #{domain_name} (#{elapsed}s)"
914
- break # Domain is all we need now
915
- end
916
- else
917
- puts " ⚠️ Domain poll failed: #{result[:error]} (attempt #{i + 1}/#{DB_POLL_MAX})"
918
- end
919
-
920
- # Sleep before next iteration (skip sleep on last attempt)
921
- unless i == DB_POLL_MAX - 1
922
- sleep DB_POLL_INTERVAL
923
- elapsed += DB_POLL_INTERVAL
924
- puts " ⏳ [#{elapsed}s] Waiting for domain... (attempt #{i + 2}/#{DB_POLL_MAX})"
925
- end
926
- end
927
-
928
- # Always call bind_domain — services API only pre-allocates the name,
929
- # actual Railway-side binding requires an explicit bind_domain call.
930
- print " 🌐 Binding domain via API..."
931
- bind = api_client.bind_domain(deploy_task_id: deploy_task_id)
932
- if bind[:success]
933
- domain_name = bind[:domain] if bind[:domain] && !bind[:domain].to_s.empty?
934
- puts " ✅ #{domain_name}"
935
- else
936
- puts " ⚠️ bind_domain failed: #{bind[:error]}"
937
- puts " ℹ️ Using pre-allocated domain: #{domain_name}" if domain_name
938
- end
939
-
940
- # Persist domain to .clacky/deploy.yml for future reference
941
- if domain_name
942
- deploy_config_path = ".clacky/deploy.yml"
943
- existing = File.exist?(deploy_config_path) ? YAML.load_file(deploy_config_path) || {} : {}
944
- updated = existing.merge("domain" => domain_name, "deployed_at" => Time.now.strftime("%Y-%m-%d %H:%M:%S"))
945
- FileUtils.mkdir_p(".clacky")
946
- File.write(deploy_config_path, YAML.dump(updated))
947
- puts " 💾 Domain saved to #{deploy_config_path}"
948
- end
949
-
950
- puts "✅ Step 4 complete. Domain: #{domain_name || "(not available yet)"}"
951
- {
952
- success: true,
953
- domain_name: domain_name,
954
- bucket_credentials: bucket_credentials,
955
- bucket_name: bucket_name
956
- }
957
- end
958
-
959
- # Step 5: Deploy and wait for completion (blocking)
960
- # Uses railway up without --detach to show live logs and wait for completion
961
- def step5_deploy_and_wait(service_name, project_id, deploy_task_id, api_client, platform_token)
962
- puts "\n[Step 5] Building and deploying..."
963
-
964
- # Notify backend that we're starting deployment
965
- api_client.notify(project_id: project_id, deploy_task_id: deploy_task_id, status: "deploying")
966
-
967
- result = DeployTools::ExecuteDeployment.execute(
968
- service_name: service_name,
969
- platform_token: platform_token
970
- )
971
-
972
- unless result[:success]
973
- api_client.notify(
974
- project_id: project_id,
975
- deploy_task_id: deploy_task_id,
976
- status: "failed",
977
- message: result[:error]
978
- )
979
- return hard_fail("Deployment failed: #{result[:error]}")
980
- end
981
-
982
- puts "✅ Build and deployment complete"
983
-
984
- # Don't notify success yet - wait until after migrations
985
- { success: true, url: result[:url] }
986
-
987
- rescue => e
988
- api_client.notify(
989
- project_id: project_id,
990
- deploy_task_id: deploy_task_id,
991
- status: "failed",
992
- message: "Unexpected error: #{e.message}"
993
- )
994
- hard_fail("Unexpected error in step5: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
995
- end
996
-
997
- def step6_run_migrations(service_name, platform_token)
998
- puts "\n[Step 6] Running database migrations..."
999
- env = railway_env(platform_token)
1000
-
1001
- # If railway.toml has preDeployCommand = "bundle exec rails db:migrate",
1002
- # migrations already ran inside the deploy container — skip redundant railway run.
1003
- if pre_deploy_migrate_configured?
1004
- puts " ⚡ preDeployCommand detected — db:migrate ran automatically during deploy"
1005
- puts " ✅ Skipping redundant migration step (~30s saved)"
1006
- else
1007
- # No preDeployCommand — run migrations explicitly
1008
- print " 🗄️ Running db:migrate (this may take ~30s)..."
1009
- migrate_cmd = "railway run --service #{shell_escape(service_name)} bundle exec rails db:migrate"
1010
- out, err, status = Open3.capture3(env, migrate_cmd)
1011
-
1012
- if status.success?
1013
- puts " ✅"
1014
- puts out unless out.strip.empty?
1015
- else
1016
- puts " ⚠️"
1017
- puts "⚠️ Migration warning (continuing): #{err}"
1018
- end
1019
- end
1020
-
1021
- # Seed detection: only on first deployment (no preDeployCommand equivalent for seeds)
1022
- print " 🔍 Checking if db:seed is needed..."
1023
- is_first = first_deployment?(service_name, env)
1024
-
1025
- if is_first
1026
- puts " ✅ First deployment — running db:seed"
1027
- print " 🌱 Running db:seed..."
1028
- seed_cmd = "railway run --service #{shell_escape(service_name)} bundle exec rails db:seed"
1029
- out, _err, _status = Open3.capture3(env, seed_cmd)
1030
- puts " ✅"
1031
- puts out unless out.strip.empty?
1032
- else
1033
- puts " ✅ Update deployment — skipping db:seed"
1034
- end
1035
-
1036
- puts "✅ Step 6 complete"
1037
- { success: true }
1038
- end
1039
-
1040
- def step7_finish(deployment_url, project_id, deploy_task_id, deploy_service_id, api_client,
1041
- started_at: nil)
1042
- puts "\n[Step 7] Finalising deployment..."
1043
-
1044
- app_url = deployment_url ? deployment_url.sub(/\Ahttps?:\/\//, "") : nil
1045
- app_url = "https://#{app_url}" if app_url && !app_url.start_with?("https://")
1046
- dash_url = "#{@dashboard_base_url}/#{project_id}"
1047
-
1048
- # Detect app port from project config — only sent on success notify
1049
- app_port = detect_app_port
1050
- puts " 🔌 Detected app port: #{app_port}"
1051
-
1052
- # Notify success immediately — don't block on health check
1053
- api_client.notify(
1054
- project_id: project_id,
1055
- deploy_task_id: deploy_task_id,
1056
- deploy_service_id: deploy_service_id,
1057
- status: "success",
1058
- target_port: app_port
1059
- )
1060
-
1061
- # Calculate total elapsed time
1062
- total_seconds = started_at ? (Time.now - started_at).round : nil
1063
- duration_str = total_seconds ? format_duration(total_seconds) : nil
1064
-
1065
- # Print success banner right away so user sees the URL without waiting
1066
- puts "\n" + "=" * 60
1067
- puts "✅ DEPLOYMENT SUCCESSFUL"
1068
- puts "=" * 60
1069
- puts "🌐 URL : #{app_url || "(not available)"}"
1070
- puts "📊 Dashboard : #{dash_url}"
1071
- puts "⏱️ Total time : #{duration_str || "n/a"}"
1072
- puts "=" * 60
1073
-
1074
- # Health check runs after banner — non-fatal, purely informational
1075
- if app_url
1076
- puts "\n🏥 Running health check (non-blocking)..."
1077
- health_passed = false
1078
- 3.times do |i|
1079
- result = DeployTools::CheckHealth.execute(url: app_url, timeout: 30)
1080
- if result[:success]
1081
- puts "✅ App is live! Health check passed (HTTP #{result[:status_code]})"
1082
- health_passed = true
1083
- break
1084
- else
1085
- puts " ⚠️ Health check #{i + 1}/3: #{result[:error]}"
1086
- sleep 10 unless i == 2
1087
- end
1088
- end
1089
- puts " ℹ️ App may still be warming up — visit the URL in a moment." unless health_passed
1090
- end
1091
-
1092
- puts ""
1093
- { success: true, url: app_url || "(not available)", dashboard_url: dash_url }
1094
- end
1095
-
1096
- # -----------------------------------------------------------------------
1097
- # Utility helpers
1098
- # -----------------------------------------------------------------------
1099
-
1100
- def platform_already_present?(lock_content, platform)
1101
- # Gemfile.lock PLATFORMS section looks like:
1102
- # PLATFORMS
1103
- # arm64-darwin-23
1104
- # x86_64-linux
1105
- in_platforms = false
1106
- lock_content.each_line do |line|
1107
- if line.strip == "PLATFORMS"
1108
- in_platforms = true
1109
- next
1110
- end
1111
- # A non-indented line signals the end of the PLATFORMS block
1112
- break if in_platforms && !line.start_with?(" ")
1113
- return true if in_platforms && line.strip == platform
1114
- end
1115
- false
1116
- end
1117
-
1118
- # Detect the app's HTTP port from project config files.
1119
- # Checks (in order): config/puma.rb → Procfile → defaults to 3000.
1120
- # Format seconds into a human-readable duration string (e.g. "2m 34s", "45s").
1121
- def format_duration(seconds)
1122
- return "#{seconds}s" if seconds < 60
1123
- m = seconds / 60
1124
- s = seconds % 60
1125
- s > 0 ? "#{m}m #{s}s" : "#{m}m"
1126
- end
1127
-
1128
- def detect_app_port
1129
- # config/puma.rb: port ENV.fetch("PORT", 3000) or port 3000
1130
- if File.exist?("config/puma.rb")
1131
- content = File.read("config/puma.rb")
1132
- if content =~ /port\s+ENV\.fetch\(["']PORT["']\s*,\s*(\d+)\s*\)/
1133
- return $1.to_i
1134
- end
1135
- if content =~ /port\s+(\d+)/
1136
- return $1.to_i
1137
- end
1138
- end
1139
-
1140
- # Procfile: web: bundle exec puma -p 3000 or -p $PORT
1141
- if File.exist?("Procfile")
1142
- content = File.read("Procfile")
1143
- if content =~ /web:.*-p\s+(\d+)/
1144
- return $1.to_i
1145
- end
1146
- end
1147
-
1148
- 3000
1149
- end
1150
-
1151
- def railway_cli_available?
1152
- system("which railway > /dev/null 2>&1")
1153
- end
1154
-
1155
- def railway_env(platform_token)
1156
- ENV.to_h.merge("RAILWAY_TOKEN" => platform_token)
1157
- end
1158
-
1159
- def detect_service_name(env)
1160
- # 1. Try railway.toml [service] name field
1161
- toml = "railway.toml"
1162
- if File.exist?(toml)
1163
- content = File.read(toml)
1164
- m = content.match(/\[service\][^\[]*name\s*=\s*["']?([^"'\n]+)["']?/m)
1165
- return m[1].strip if m
1166
- end
1167
-
1168
- # 2. Use railway status --json to find the linked service
1169
- out, _err, status = Open3.capture3(env, "railway status --json")
1170
- if status.success?
1171
- begin
1172
- info = JSON.parse(out)
1173
- # Railway v4 status JSON uses edges/node format:
1174
- # { "services": { "edges": [ { "node": { "id": "...", "name": "..." } } ] } }
1175
- # Older format was: { "services": [ { "name": "..." } ] }
1176
- raw_svcs = info["services"]
1177
- svcs = if raw_svcs.is_a?(Hash) && raw_svcs["edges"]
1178
- raw_svcs["edges"].map { |e| e["node"] }.compact
1179
- elsif raw_svcs.is_a?(Array)
1180
- raw_svcs
1181
- else
1182
- []
1183
- end
1184
-
1185
- svc = svcs.find do |s|
1186
- name = s["name"].to_s.downcase
1187
- !%w[postgres postgresql mysql redis].any? { |db| name.include?(db) }
1188
- end
1189
- return svc["name"] if svc
1190
- rescue JSON::ParserError
1191
- # fall through
1192
- end
1193
- end
1194
-
1195
- # 3. Fallback to directory name
1196
- File.basename(Dir.pwd)
1197
- end
1198
-
1199
- def build_env_vars(project)
1200
- vars = {
1201
- "RAILS_ENV" => "production",
1202
- "RAILS_SERVE_STATIC_FILES" => "true",
1203
- "RAILS_LOG_TO_STDOUT" => "true",
1204
- "RAILWAY_RUN_UID" => "0"
1205
- }
1206
-
1207
- # Generate SECRET_KEY_BASE
1208
- secret = generate_secret_key_base
1209
- vars["SECRET_KEY_BASE"] = secret if secret
1210
-
1211
- # Figaro: parse config/application.yml (ERB-rendered).
1212
- # This already contains the CLACKY_* integration vars written by /new,
1213
- # so we don't need to inject categorized_config separately.
1214
- figaro_vars = parse_figaro_production
1215
- if figaro_vars.any?
1216
- vars.merge!(figaro_vars)
1217
- else
1218
- # Fallback: inject categorized_config directly if no application.yml
1219
- vars.merge!(extract_categorized_config(project["categorized_config"]))
1220
- end
1221
-
1222
- # Filter out empty values - Railway CLI doesn't accept them
1223
- vars.reject { |_k, v| v.to_s.strip.empty? }
1224
- end
1225
-
1226
- def generate_secret_key_base
1227
- # Use a 30s timeout so a slow Rails boot doesn't silently hang the deploy.
1228
- # Open3.capture3 blocks indefinitely; Timeout::Error is raised if it exceeds the limit.
1229
- require "timeout"
1230
- begin
1231
- out = nil
1232
- Timeout.timeout(30) do
1233
- out, _err, status = Open3.capture3("bundle exec rails secret")
1234
- return out.strip if status.success? && !out.strip.empty?
1235
- end
1236
- rescue Timeout::Error
1237
- warn " ⚠️ `bundle exec rails secret` timed out (>30s) — using SecureRandom fallback"
1238
- rescue => e
1239
- warn " ⚠️ `bundle exec rails secret` failed (#{e.message}) — using SecureRandom fallback"
1240
- end
1241
- # Fallback: generate a cryptographically secure key using SecureRandom
1242
- require "securerandom"
1243
- SecureRandom.hex(64)
1244
- end
1245
-
1246
- def parse_figaro_production
1247
- app_yml = "config/application.yml"
1248
- return {} unless File.exist?(app_yml)
1249
-
1250
- require "erb"
1251
- require "timeout"
1252
-
1253
- raw = File.read(app_yml)
1254
-
1255
- # ERB.new(raw).result can hang if it calls ENV.fetch on a missing key
1256
- # (raises KeyError before YAML parse) — wrap in a 10s timeout.
1257
- rendered = begin
1258
- Timeout.timeout(10) { ERB.new(raw).result }
1259
- rescue Timeout::Error
1260
- warn " ⚠️ ERB render of config/application.yml timed out (>10s) — skipping figaro vars"
1261
- return {}
1262
- rescue => e
1263
- warn " ⚠️ ERB render error in config/application.yml: #{e.message} — skipping figaro vars"
1264
- return {}
1265
- end
1266
-
1267
- data = YAML.safe_load(rendered) || {}
1268
-
1269
- # Figaro stores all vars at the top level (no "production:" block).
1270
- # Skip blank values — those are placeholders to be filled by the user.
1271
- data.each_with_object({}) do |(k, v), h|
1272
- next if v.to_s.strip.empty?
1273
- h[k.to_s] = v.to_s
1274
- end
1275
- rescue => e
1276
- warn "[deploy] parse_figaro_production error: #{e.message}"
1277
- {}
1278
- end
1279
-
1280
- def extract_categorized_config(categorized_config)
1281
- return {} unless categorized_config.is_a?(Hash)
1282
-
1283
- categorized_config.each_with_object({}) do |(_category, vars), h|
1284
- next unless vars.is_a?(Hash)
1285
- vars.each { |k, v| h[k.to_s] = v.to_s }
1286
- end
1287
- end
1288
-
1289
- def pre_deploy_migrate_configured?
1290
- toml_path = "railway.toml"
1291
- return false unless File.exist?(toml_path)
1292
- content = File.read(toml_path)
1293
- content.include?("preDeployCommand") && content.include?("db:migrate")
1294
- end
1295
-
1296
- def first_deployment?(service_name, env)
1297
- require "timeout"
1298
-
1299
- run_with_timeout = lambda do |cmd, limit|
1300
- out = nil
1301
- status = nil
1302
- Timeout.timeout(limit) do
1303
- out, _err, status = Open3.capture3(env, cmd)
1304
- end
1305
- [out, status]
1306
- rescue Timeout::Error
1307
- warn " ⚠️ Command timed out (>#{limit}s): #{cmd.split.first(4).join(" ")}..."
1308
- [nil, nil]
1309
- rescue => e
1310
- warn " ⚠️ Command error: #{e.message}"
1311
- [nil, nil]
1312
- end
1313
-
1314
- # Check 1: can we connect to the DB at all? (60s timeout)
1315
- check1_cmd = "railway run --service #{shell_escape(service_name)} " \
1316
- "bundle exec rails runner \"ActiveRecord::Base.connection; puts 'connected'\""
1317
- _out1, status1 = run_with_timeout.call(check1_cmd, 60)
1318
- return true if status1.nil? || !status1.success?
1319
-
1320
- # Check 2: any migrations recorded? (60s timeout)
1321
- check2_cmd = "railway run --service #{shell_escape(service_name)} " \
1322
- "bundle exec rails db:migrate:status 2>&1"
1323
- out2, _status2 = run_with_timeout.call(check2_cmd, 60)
1324
-
1325
- return false if out2.nil?
1326
-
1327
- # If no schema_migrations entries exist, output mentions "up" lines
1328
- !out2.match?(/^\s*(up|down)\s+\d{14}/)
1329
- rescue
1330
- false
1331
- end
1332
-
1333
- def show_build_logs(service_name, platform_token)
1334
- puts "\n📋 Last build log lines:"
1335
- puts "-" * 40
1336
-
1337
- env = railway_env(platform_token)
1338
- cmd = "railway logs --build --lines 30 --service #{shell_escape(service_name)}"
1339
- out, err, _status = Open3.capture3(env, cmd)
1340
-
1341
- output = out.empty? ? err : out
1342
- output.each_line { |line| puts " #{line.chomp}" }
1343
-
1344
- puts "-" * 40
1345
- end
1346
-
1347
- def open_browser(url)
1348
- case RbConfig::CONFIG["host_os"]
1349
- when /darwin/ then system("open #{shell_escape(url)}")
1350
- when /linux/ then system("xdg-open #{shell_escape(url)}")
1351
- when /mingw|mswin/ then system("start #{shell_escape(url)}")
1352
- end
1353
- end
1354
-
1355
- def shell_escape(str)
1356
- "'#{str.to_s.gsub("'", "'\\\\''")}'"
1357
- end
1358
-
1359
- def hard_fail(message)
1360
- puts "\n❌ #{message}"
1361
- { success: false, error: message }
1362
- end
1363
-
1364
- def print_banner
1365
- puts "\n" + "=" * 60
1366
- puts "🚂 Clacky Rails Deploy"
1367
- puts "=" * 60
1368
- end
1369
- end
1370
- end
1371
- end
1372
-
1373
- # Run when executed directly
1374
- if __FILE__ == $PROGRAM_NAME
1375
- result = Clacky::DeployTemplates::RailsDeploy.execute
1376
- exit(result[:success] ? 0 : 1)
1377
- end