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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +87 -53
- data/lib/clacky/agent/cost_tracker.rb +19 -2
- data/lib/clacky/agent/llm_caller.rb +218 -0
- data/lib/clacky/agent/message_compressor_helper.rb +32 -2
- data/lib/clacky/agent.rb +54 -22
- data/lib/clacky/client.rb +44 -5
- data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
- data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
- data/lib/clacky/default_skills/new/SKILL.md +3 -114
- data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
- data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
- data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
- data/lib/clacky/message_format/anthropic.rb +72 -8
- data/lib/clacky/message_format/bedrock.rb +6 -3
- data/lib/clacky/providers.rb +146 -3
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
- data/lib/clacky/server/channel/channel_manager.rb +12 -4
- data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
- data/lib/clacky/server/http_server.rb +746 -13
- data/lib/clacky/server/session_registry.rb +55 -24
- data/lib/clacky/skill.rb +10 -9
- data/lib/clacky/skill_loader.rb +23 -11
- data/lib/clacky/tools/file_reader.rb +232 -127
- data/lib/clacky/tools/security.rb +42 -64
- data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
- data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
- data/lib/clacky/tools/terminal/session_manager.rb +8 -3
- data/lib/clacky/tools/terminal.rb +263 -16
- data/lib/clacky/ui2/layout_manager.rb +8 -1
- data/lib/clacky/ui2/output_buffer.rb +83 -23
- data/lib/clacky/ui2/ui_controller.rb +74 -7
- data/lib/clacky/utils/file_processor.rb +14 -40
- data/lib/clacky/utils/model_pricing.rb +215 -0
- data/lib/clacky/utils/parser_manager.rb +70 -6
- data/lib/clacky/utils/string_matcher.rb +23 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +673 -9
- data/lib/clacky/web/app.js +40 -1608
- data/lib/clacky/web/i18n.js +209 -0
- data/lib/clacky/web/index.html +166 -2
- data/lib/clacky/web/onboard.js +77 -1
- data/lib/clacky/web/profile.js +442 -0
- data/lib/clacky/web/sessions.js +1034 -2
- data/lib/clacky/web/settings.js +127 -6
- data/lib/clacky/web/sidebar.js +39 -0
- data/lib/clacky/web/skills.js +460 -0
- data/lib/clacky/web/trash.js +343 -0
- data/lib/clacky/web/ws-dispatcher.js +255 -0
- data/lib/clacky.rb +5 -3
- metadata +16 -17
- data/lib/clacky/clacky_auth_client.rb +0 -152
- data/lib/clacky/clacky_cloud_config.rb +0 -123
- data/lib/clacky/cloud_project_client.rb +0 -169
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
- data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
- data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
- data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
- data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
- data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
- data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
- data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
- data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
- 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
|