openclacky 0.9.28 → 0.9.30

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/docs/deploy-architecture.md +619 -0
  4. data/lib/clacky/agent/llm_caller.rb +14 -2
  5. data/lib/clacky/agent/message_compressor.rb +24 -6
  6. data/lib/clacky/agent/message_compressor_helper.rb +17 -10
  7. data/lib/clacky/agent/session_serializer.rb +69 -0
  8. data/lib/clacky/agent/skill_manager.rb +2 -2
  9. data/lib/clacky/agent.rb +3 -0
  10. data/lib/clacky/brand_config.rb +29 -3
  11. data/lib/clacky/clacky_auth_client.rb +152 -0
  12. data/lib/clacky/clacky_cloud_config.rb +123 -0
  13. data/lib/clacky/cli.rb +13 -0
  14. data/lib/clacky/client.rb +21 -7
  15. data/lib/clacky/cloud_project_client.rb +169 -0
  16. data/lib/clacky/default_agents/base_prompt.md +1 -0
  17. data/lib/clacky/default_parsers/doc_parser.rb +9 -9
  18. data/lib/clacky/default_skills/browser-setup/SKILL.md +9 -0
  19. data/lib/clacky/default_skills/channel-setup/SKILL.md +21 -4
  20. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +8 -2
  21. data/lib/clacky/default_skills/deploy/SKILL.md +96 -5
  22. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1268 -274
  23. data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +341 -0
  24. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +72 -147
  25. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +60 -50
  26. data/lib/clacky/default_skills/deploy/tools/list_services.rb +47 -60
  27. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +147 -96
  28. data/lib/clacky/default_skills/new/SKILL.md +117 -5
  29. data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +74 -0
  30. data/lib/clacky/default_skills/new/scripts/create_rails_project.sh +32 -0
  31. data/lib/clacky/deploy_api_client.rb +484 -0
  32. data/lib/clacky/json_ui_controller.rb +16 -10
  33. data/lib/clacky/message_format/bedrock.rb +3 -2
  34. data/lib/clacky/message_history.rb +8 -0
  35. data/lib/clacky/plain_ui_controller.rb +1 -6
  36. data/lib/clacky/providers.rb +23 -4
  37. data/lib/clacky/server/browser_manager.rb +3 -1
  38. data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +2 -1
  39. data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +3 -1
  40. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +5 -5
  41. data/lib/clacky/server/http_server.rb +12 -2
  42. data/lib/clacky/server/server_master.rb +43 -7
  43. data/lib/clacky/server/web_ui_controller.rb +17 -9
  44. data/lib/clacky/skill.rb +6 -2
  45. data/lib/clacky/tools/run_project.rb +4 -1
  46. data/lib/clacky/tools/shell.rb +7 -1
  47. data/lib/clacky/ui2/ui_controller.rb +1 -5
  48. data/lib/clacky/ui_interface.rb +5 -7
  49. data/lib/clacky/utils/arguments_parser.rb +22 -5
  50. data/lib/clacky/version.rb +1 -1
  51. data/lib/clacky/web/app.css +45 -5
  52. data/lib/clacky/web/app.js +126 -19
  53. data/lib/clacky/web/i18n.js +57 -0
  54. data/lib/clacky/web/sessions.js +108 -39
  55. data/lib/clacky/web/skills.js +8 -2
  56. data/lib/clacky.rb +3 -0
  57. metadata +8 -1
@@ -1,383 +1,1377 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../tools/list_services'
4
- require_relative '../tools/report_deploy_status'
5
- require_relative '../tools/execute_deployment'
6
- require_relative '../tools/set_deploy_variables'
7
- require_relative '../tools/fetch_runtime_logs'
8
- require_relative '../tools/check_health'
3
+ # Ensure all output is flushed immediately so users see live progress
4
+ # even when the script is run inside a subprocess (safe_shell / 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"
9
29
 
10
30
  module Clacky
11
31
  module DeployTemplates
12
- # Rails deployment template - Fixed 8-step deployment process
13
- # No AI decision-making, pure automation
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
14
37
  class RailsDeploy
15
- # Execute the Rails deployment workflow
16
- #
17
- # @return [Hash] Deployment result
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
+
18
50
  def self.execute
19
- # CRITICAL: Check environment requirements
20
- unless environment_valid?
21
- return {
22
- success: false,
23
- error: "Railway deployment is only available in the Clacky cloud environment with Rails template projects.",
24
- details: environment_check_details
25
- }
26
- end
51
+ new.run
52
+ end
27
53
 
28
- puts "\n" + "="*60
29
- puts "🚂 Rails Deployment Template (8-Step Process)"
30
- puts "="*60 + "\n"
54
+ # -----------------------------------------------------------------------
55
+ # Phase tracking prints timing for each phase
56
+ # -----------------------------------------------------------------------
31
57
 
32
- # Step 1: List services
33
- step1_result = step1_list_services
34
- return step1_result unless step1_result[:success]
35
-
36
- services = step1_result[:services]
37
- main_service = step1_result[:main_service]
38
- db_service = step1_result[:db_service]
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
39
70
 
40
- # Step 2: Check first deployment
41
- step2_result = step2_check_first_deployment(main_service)
42
- return step2_result unless step2_result[:success]
43
-
44
- is_first = step2_result[:is_first_deployment]
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
45
78
 
46
- # Step 3: Set Rails environment variables
47
- step3_result = step3_set_rails_variables(main_service, db_service, is_first)
48
- return step3_result unless step3_result[:success]
79
+ # -----------------------------------------------------------------------
80
+ # Top-level orchestration
81
+ # -----------------------------------------------------------------------
49
82
 
50
- # Step 4: Execute deployment
51
- step4_result = step4_execute_deployment(main_service)
52
- return step4_result unless step4_result[:success]
83
+ def run
84
+ result = nil
85
+ started_at = Time.now
53
86
 
54
- # Step 5: Run database migrations
55
- step5_result = step5_run_migrations(main_service)
56
- return step5_result unless step5_result[:success]
87
+ begin
88
+ print_banner
89
+ puts "[DEPLOY] Started at #{started_at.strftime("%Y-%m-%d %H:%M:%S")}"
57
90
 
58
- # Step 6: Run database seeds (first deployment only)
59
- if is_first
60
- step6_result = step6_run_seeds(main_service)
61
- return step6_result unless step6_result[:success]
62
- else
63
- puts "\n[Step 6] Skipping database seeds (not first deployment)\n"
64
- end
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]
65
103
 
66
- # Step 7: Health check
67
- step7_result = step7_health_check(main_service)
68
- return step7_result unless step7_result[:success]
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
69
111
 
70
- # Step 8: Report success
71
- step8_report_success(main_service)
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
72
132
  end
73
133
 
74
- # Check if environment is valid for deployment
75
- #
76
- # @return [Boolean] true if both required environment variables are "true"
77
- def self.environment_valid?
78
- ENV['IS_RAILS_TEMPLATE'] == 'true' && ENV['IS_CLACKY_CDE'] == 'true'
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"
79
139
  end
80
140
 
81
- # Get environment check details for error reporting
82
- #
83
- # @return [Hash] Details about environment check
84
- def self.environment_check_details
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
+
85
174
  {
86
- is_rails_template: ENV['IS_RAILS_TEMPLATE'],
87
- is_clacky_cde: ENV['IS_CLACKY_CDE'],
88
- required: {
89
- is_rails_template: 'true',
90
- is_clacky_cde: 'true'
91
- }
175
+ success: true,
176
+ project: project_result[:project],
177
+ project_id: project_id,
178
+ api_client: api_client,
179
+ cloud_client: cloud_client
92
180
  }
93
181
  end
94
182
 
95
- # Step 1: List Railway services
96
- def self.step1_list_services
97
- puts "\n[Step 1] Listing Railway services..."
98
- DeployTools::ReportDeployStatus.execute(
99
- status: 'analyzing',
100
- message: 'Listing Railway services'
101
- )
183
+ # -----------------------------------------------------------------------
184
+ # Phase 1 — Subscription check
185
+ # -----------------------------------------------------------------------
102
186
 
103
- result = DeployTools::ListServices.execute
104
-
105
- unless result[:success]
106
- return {
107
- success: false,
108
- error: "Failed to list services",
109
- details: result
110
- }
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)
111
205
  end
206
+ end
112
207
 
113
- services = result[:services]
114
-
115
- # Find main web service
116
- main_service = services.find { |s| s['type'] == 'web' || s['type'] == 'service' }
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]
117
282
 
118
- if main_service.nil?
119
- return {
120
- success: false,
121
- error: "No web service found",
122
- details: "Please create a web service in Railway first"
123
- }
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
124
304
  end
125
305
 
126
- # Find database service
127
- db_service = services.find { |s| s['type'] == 'postgres' || s['type'] == 'mysql' }
306
+ data = YAML.safe_load(File.read(binding_file)) || {}
307
+ project_id = data["project_id"].to_s.strip
128
308
 
129
- puts "✅ Found #{services.length} service(s)"
130
- puts " Main service: #{main_service['name']}"
131
- puts " Database: #{db_service ? db_service['name'] : 'None'}"
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
132
315
 
133
- {
134
- success: true,
135
- services: services,
136
- main_service: main_service,
137
- db_service: db_service
138
- }
316
+ { success: true, project_id: project_id }
317
+ rescue => e
318
+ hard_fail("Failed to read .clacky/openclacky.yml: #{e.message}")
139
319
  end
140
320
 
141
- # Step 2: Check if this is first deployment
142
- def self.step2_check_first_deployment(main_service)
143
- puts "\n[Step 2] Checking deployment history..."
321
+ def load_clacky_cloud_config
322
+ cfg = ClackyCloudConfig.load
144
323
 
145
- deployments = main_service['deployments'] || []
146
- is_first = deployments.empty?
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
147
331
 
148
- if is_first
149
- puts "📦 This is the FIRST deployment"
150
- else
151
- puts "📦 This is a SUBSEQUENT deployment (#{deployments.length} previous deployment(s))"
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
152
345
  end
153
346
 
154
- {
155
- success: true,
156
- is_first_deployment: is_first,
157
- previous_deployments: deployments.length
158
- }
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] }
159
353
  end
160
354
 
161
- # Step 3: Set Rails environment variables
162
- def self.step3_set_rails_variables(main_service, db_service, is_first)
163
- puts "\n[Step 3] Setting Rails environment variables..."
164
- DeployTools::ReportDeployStatus.execute(
165
- status: 'analyzing',
166
- message: 'Configuring Rails environment variables'
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
167
364
  )
168
365
 
169
- service_name = main_service['name']
170
-
171
- # Build simple variables
172
- variables = {
173
- 'RAILS_ENV' => 'production',
174
- 'RAILS_SERVE_STATIC_FILES' => 'true',
175
- 'RAILS_LOG_TO_STDOUT' => 'true'
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
176
374
  }
177
375
 
178
- # Get or prompt for SECRET_KEY_BASE
179
- secret_key_base = ENV['SECRET_KEY_BASE']
180
- if secret_key_base.nil? || secret_key_base.empty?
181
- puts "⚠️ SECRET_KEY_BASE not found. Please generate one:"
182
- puts " Run: rails secret"
183
- print "Enter SECRET_KEY_BASE: "
184
- secret_key_base = $stdin.gets.chomp
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})")
185
380
  end
186
- variables['SECRET_KEY_BASE'] = secret_key_base
187
381
 
188
- # Build reference variables
189
- ref_variables = {}
190
- if db_service
191
- ref_variables['DATABASE_URL'] = "#{db_service['name']}.DATABASE_URL"
382
+ result = JSON.parse(output.strip)
383
+
384
+ unless result["success"]
385
+ return hard_fail("Cloud project creation failed: #{result["error"]}")
192
386
  end
193
387
 
194
- # Set variables
195
- result = DeployTools::SetDeployVariables.execute(
196
- service_name: service_name,
197
- variables: variables,
198
- ref_variables: ref_variables
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"
199
520
  )
521
+ end
200
522
 
201
- unless result[:success]
202
- return {
203
- success: false,
204
- error: "Failed to set environment variables",
205
- details: result
206
- }
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
207
558
  end
208
559
 
209
- puts "✅ Set #{result[:set_variables].length} variable(s)"
210
- if result[:skipped_variables].any?
211
- puts "⚠️ Skipped #{result[:skipped_variables].length} protected variable(s)"
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 }
212
568
  end
213
569
 
214
- {
215
- success: true,
216
- set_count: result[:set_variables].length
217
- }
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
218
612
  end
219
613
 
220
- # Step 4: Execute deployment
221
- def self.step4_execute_deployment(main_service)
222
- puts "\n[Step 4] Executing deployment..."
223
- DeployTools::ReportDeployStatus.execute(
224
- status: 'deploying',
225
- message: 'Starting deployment to Railway'
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."
226
652
  )
653
+ end
227
654
 
228
- service_name = main_service['name']
229
- result = DeployTools::ExecuteDeployment.execute(service_name: service_name)
655
+ def ensure_railway_toml
656
+ toml_path = "railway.toml"
230
657
 
231
- unless result[:success]
232
- # Fetch logs for debugging
233
- puts "\n❌ Deployment failed. Fetching logs..."
234
- log_result = DeployTools::FetchRuntimeLogs.execute(
235
- service_name: service_name,
236
- lines: 100
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."
237
677
  )
238
-
239
- if log_result[:success]
240
- puts "\n📋 Last 100 lines of logs:"
241
- puts log_result[:logs]
242
- end
678
+ end
243
679
 
244
- return {
245
- success: false,
246
- error: "Deployment failed",
247
- details: result
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"
248
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}")
249
763
  end
250
764
 
251
- puts "✅ Deployment completed in #{result[:elapsed].round(1)}s"
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}"
252
771
 
253
- {
254
- success: true,
255
- elapsed: result[:elapsed]
256
- }
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 }
257
797
  end
258
798
 
259
- # Step 5: Run database migrations
260
- def self.step5_run_migrations(main_service)
261
- puts "\n[Step 5] Running database migrations..."
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..."
262
803
 
263
- service_name = main_service['name']
264
- command = "clackycli run -s '#{service_name}' rake db:migrate"
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"
265
816
 
266
- puts "Running: rake db:migrate"
267
- output = `#{command} 2>&1`
268
- exit_code = $?.exitstatus
269
-
270
- if exit_code != 0
271
- return {
272
- success: false,
273
- error: "Database migration failed",
274
- details: output
275
- }
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 " ✅"
276
837
  end
277
838
 
278
- puts "✅ Database migrations completed"
279
- puts output if output && !output.empty?
839
+ puts "✅ Database ready: #{db_service_name} (#{status})"
840
+ { success: true, db_service_name: db_service_name, database_url: database_url, status: status }
280
841
 
281
- {
282
- success: true
283
- }
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
284
887
  end
285
888
 
286
- # Step 6: Run database seeds
287
- def self.step6_run_seeds(main_service)
288
- puts "\n[Step 6] Running database seeds..."
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..."
289
893
 
290
- service_name = main_service['name']
291
- command = "clackycli run -s '#{service_name}' rake db:seed"
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)..."
292
901
 
293
- puts "Running: rake db:seed"
294
- output = `#{command} 2>&1`
295
- exit_code = $?.exitstatus
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]
296
909
 
297
- if exit_code != 0
298
- puts "⚠️ Warning: Database seeding failed (this may be expected)"
299
- puts output if output && !output.empty?
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}"
300
935
  else
301
- puts " Database seeding completed"
302
- puts output if output && !output.empty?
936
+ puts " ⚠️ bind_domain failed: #{bind[:error]}"
937
+ puts " ℹ️ Using pre-allocated domain: #{domain_name}" if domain_name
303
938
  end
304
939
 
305
- # Don't fail deployment if seeding fails
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)"}"
306
951
  {
307
- success: true
952
+ success: true,
953
+ domain_name: domain_name,
954
+ bucket_credentials: bucket_credentials,
955
+ bucket_name: bucket_name
308
956
  }
309
957
  end
310
958
 
311
- # Step 7: Health check
312
- def self.step7_health_check(main_service)
313
- puts "\n[Step 7] Performing health check..."
314
- DeployTools::ReportDeployStatus.execute(
315
- status: 'checking',
316
- message: 'Verifying application health'
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
317
970
  )
318
971
 
319
- public_url = main_service['public_url']
320
-
321
- if public_url.nil? || public_url.empty?
322
- puts "⚠️ Warning: No public URL found. Skipping health check."
323
- return { success: true }
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]}")
324
980
  end
325
981
 
326
- # Wait a bit for service to be fully ready
327
- puts "⏳ Waiting 10 seconds for service to be ready..."
328
- sleep 10
982
+ puts "✅ Build and deployment complete"
983
+
984
+ # Don't notify success yet - wait until after migrations
985
+ { success: true, url: result[:url] }
329
986
 
330
- result = DeployTools::CheckHealth.execute(
331
- url: public_url,
332
- path: '/',
333
- timeout: 30
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}"
334
993
  )
994
+ hard_fail("Unexpected error in step5: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
995
+ end
335
996
 
336
- unless result[:success]
337
- puts "⚠️ Warning: Health check failed (service may still be starting)"
338
- puts " Error: #{result[:error]}"
339
- puts " You can manually check: #{public_url}"
340
- # Don't fail deployment on health check failure
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)"
341
1006
  else
342
- puts "✅ Health check passed (#{result[:status_code]} - #{result[:elapsed]}s)"
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
343
1019
  end
344
1020
 
345
- {
346
- success: true,
347
- health_check_result: result
348
- }
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 }
349
1038
  end
350
1039
 
351
- # Step 8: Report success
352
- def self.step8_report_success(main_service)
353
- puts "\n[Step 8] Deployment completed successfully! 🎉"
354
-
355
- public_url = main_service['public_url']
356
-
357
- DeployTools::ReportDeployStatus.execute(
358
- status: 'success',
359
- message: "Rails app deployed successfully"
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
360
1059
  )
361
1060
 
362
- puts "\n" + "="*60
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
363
1067
  puts "✅ DEPLOYMENT SUCCESSFUL"
364
- puts "="*60
365
- puts "Service: #{main_service['name']}"
366
- puts "URL: #{public_url || 'Not available yet'}"
367
- puts "="*60 + "\n"
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
368
1073
 
369
- {
370
- success: true,
371
- service: main_service['name'],
372
- url: public_url
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"
373
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
374
1368
  end
375
1369
  end
376
1370
  end
377
1371
  end
378
1372
 
379
- # Run deployment if executed directly
380
- if __FILE__ == $0
1373
+ # Run when executed directly
1374
+ if __FILE__ == $PROGRAM_NAME
381
1375
  result = Clacky::DeployTemplates::RailsDeploy.execute
382
1376
  exit(result[:success] ? 0 : 1)
383
1377
  end