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,116 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'net/http'
|
|
4
|
-
require 'uri'
|
|
5
|
-
require 'json'
|
|
6
|
-
|
|
7
|
-
module Clacky
|
|
8
|
-
module DeployTools
|
|
9
|
-
# Perform HTTP health check on deployed application
|
|
10
|
-
class CheckHealth
|
|
11
|
-
DEFAULT_PATH = '/'
|
|
12
|
-
DEFAULT_TIMEOUT = 30
|
|
13
|
-
MAX_TIMEOUT = 120
|
|
14
|
-
|
|
15
|
-
# Perform health check
|
|
16
|
-
#
|
|
17
|
-
# @param url [String] Optional URL (defaults to RAILWAY_PUBLIC_DOMAIN env var)
|
|
18
|
-
# @param path [String] Health check path (default: "/")
|
|
19
|
-
# @param timeout [Integer] Request timeout in seconds (default: 30)
|
|
20
|
-
# @return [Hash] Result of health check
|
|
21
|
-
def self.execute(url: nil, path: DEFAULT_PATH, timeout: DEFAULT_TIMEOUT)
|
|
22
|
-
# Get URL from parameter or environment
|
|
23
|
-
target_url = url || ENV['RAILWAY_PUBLIC_DOMAIN']
|
|
24
|
-
|
|
25
|
-
if target_url.nil? || target_url.empty?
|
|
26
|
-
return {
|
|
27
|
-
error: "No URL provided",
|
|
28
|
-
details: "Please provide a URL or set RAILWAY_PUBLIC_DOMAIN environment variable"
|
|
29
|
-
}
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Ensure URL has protocol
|
|
33
|
-
target_url = "https://#{target_url}" unless target_url.start_with?('http://', 'https://')
|
|
34
|
-
|
|
35
|
-
# Build full URL with path
|
|
36
|
-
full_url = "#{target_url.chomp('/')}#{path}"
|
|
37
|
-
|
|
38
|
-
# Validate timeout
|
|
39
|
-
timeout = timeout.to_i
|
|
40
|
-
if timeout <= 0 || timeout > MAX_TIMEOUT
|
|
41
|
-
timeout = DEFAULT_TIMEOUT
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
puts "🏥 Checking health: #{full_url} (timeout: #{timeout}s)"
|
|
45
|
-
|
|
46
|
-
begin
|
|
47
|
-
uri = URI.parse(full_url)
|
|
48
|
-
result = perform_request(uri, timeout)
|
|
49
|
-
|
|
50
|
-
if result[:success]
|
|
51
|
-
puts "✅ Health check passed: #{result[:status_code]}"
|
|
52
|
-
else
|
|
53
|
-
puts "❌ Health check failed: #{result[:error]}"
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
result.merge(url: full_url, path: path)
|
|
57
|
-
rescue URI::InvalidURIError => e
|
|
58
|
-
{
|
|
59
|
-
success: false,
|
|
60
|
-
error: "Invalid URL",
|
|
61
|
-
details: e.message,
|
|
62
|
-
url: full_url
|
|
63
|
-
}
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# Perform HTTP request
|
|
68
|
-
#
|
|
69
|
-
# @param uri [URI] Target URI
|
|
70
|
-
# @param timeout [Integer] Timeout in seconds
|
|
71
|
-
# @return [Hash] Request result
|
|
72
|
-
def self.perform_request(uri, timeout)
|
|
73
|
-
start_time = Time.now
|
|
74
|
-
|
|
75
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
76
|
-
http.use_ssl = (uri.scheme == 'https')
|
|
77
|
-
http.open_timeout = timeout
|
|
78
|
-
http.read_timeout = timeout
|
|
79
|
-
|
|
80
|
-
request = Net::HTTP::Get.new(uri.request_uri)
|
|
81
|
-
request['User-Agent'] = 'Clacky-Deploy-Health-Check/1.0'
|
|
82
|
-
|
|
83
|
-
response = http.request(request)
|
|
84
|
-
elapsed = Time.now - start_time
|
|
85
|
-
|
|
86
|
-
{
|
|
87
|
-
success: response.is_a?(Net::HTTPSuccess),
|
|
88
|
-
status_code: response.code.to_i,
|
|
89
|
-
status_message: response.message,
|
|
90
|
-
elapsed: elapsed.round(2),
|
|
91
|
-
headers: response.to_hash,
|
|
92
|
-
body_preview: response.body&.slice(0, 500) # First 500 chars
|
|
93
|
-
}
|
|
94
|
-
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
95
|
-
{
|
|
96
|
-
success: false,
|
|
97
|
-
error: "Request timeout",
|
|
98
|
-
details: e.message,
|
|
99
|
-
elapsed: timeout
|
|
100
|
-
}
|
|
101
|
-
rescue SocketError => e
|
|
102
|
-
{
|
|
103
|
-
success: false,
|
|
104
|
-
error: "Network error",
|
|
105
|
-
details: e.message
|
|
106
|
-
}
|
|
107
|
-
rescue StandardError => e
|
|
108
|
-
{
|
|
109
|
-
success: false,
|
|
110
|
-
error: "Health check failed",
|
|
111
|
-
details: "#{e.class}: #{e.message}"
|
|
112
|
-
}
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
end
|
|
@@ -1,341 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "tempfile"
|
|
5
|
-
|
|
6
|
-
module Clacky
|
|
7
|
-
module DeployTools
|
|
8
|
-
# Create a PostgreSQL database service on Railway and wait for it to be ready.
|
|
9
|
-
#
|
|
10
|
-
# This tool handles the known Railway CLI bug where `railway add` returns an
|
|
11
|
-
# error exit code when using project tokens, but actually succeeds in creating
|
|
12
|
-
# the service.
|
|
13
|
-
#
|
|
14
|
-
# Strategy:
|
|
15
|
-
# 1. Capture existing service IDs before creation
|
|
16
|
-
# 2. Execute `railway add --database postgres` (ignore exit code)
|
|
17
|
-
# 3. Poll for new service ID (max 15 seconds)
|
|
18
|
-
# 4. Wait for DATABASE_URL to be available on the new service (max 2 minutes)
|
|
19
|
-
#
|
|
20
|
-
# @example
|
|
21
|
-
# result = CreateDatabaseService.execute(
|
|
22
|
-
# platform_token: "railway-token-here"
|
|
23
|
-
# )
|
|
24
|
-
# if result[:success]
|
|
25
|
-
# puts "Database created: #{result[:service_name]}"
|
|
26
|
-
# puts "DATABASE_URL: #{result[:database_url]}"
|
|
27
|
-
# end
|
|
28
|
-
class CreateDatabaseService
|
|
29
|
-
|
|
30
|
-
# Maximum time to wait for new service to appear after creation command
|
|
31
|
-
DETECTION_TIMEOUT = 15 # seconds
|
|
32
|
-
DETECTION_INTERVAL = 3 # seconds
|
|
33
|
-
|
|
34
|
-
# Maximum time to wait for DATABASE_URL to be available
|
|
35
|
-
PROVISION_TIMEOUT = 120 # seconds (2 minutes)
|
|
36
|
-
PROVISION_INTERVAL = 5 # seconds
|
|
37
|
-
|
|
38
|
-
# Execute database creation
|
|
39
|
-
#
|
|
40
|
-
# @param platform_token [String] RAILWAY_TOKEN for authentication
|
|
41
|
-
# @return [Hash] {
|
|
42
|
-
# success: Boolean,
|
|
43
|
-
# service_id: String (if success),
|
|
44
|
-
# service_name: String (if success),
|
|
45
|
-
# database_url: String (if success),
|
|
46
|
-
# error: String (if failed)
|
|
47
|
-
# }
|
|
48
|
-
def self.execute(platform_token:)
|
|
49
|
-
new(platform_token).execute
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def initialize(platform_token)
|
|
53
|
-
@platform_token = platform_token
|
|
54
|
-
@env = { "RAILWAY_TOKEN" => platform_token }
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def execute
|
|
58
|
-
# Step 0: Check if database already exists
|
|
59
|
-
existing_db = find_existing_database
|
|
60
|
-
|
|
61
|
-
if existing_db
|
|
62
|
-
puts " ✅ Database already exists: #{existing_db[:name]}"
|
|
63
|
-
|
|
64
|
-
# Return without DATABASE_URL - Railway automatically shares it across services
|
|
65
|
-
return {
|
|
66
|
-
success: true,
|
|
67
|
-
service_id: existing_db[:id],
|
|
68
|
-
service_name: existing_db[:name],
|
|
69
|
-
database_url: nil, # Don't fetch - Railway auto-injects
|
|
70
|
-
status: "existing"
|
|
71
|
-
}
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# No database exists - create new one
|
|
75
|
-
puts " 📦 Creating new PostgreSQL database..."
|
|
76
|
-
|
|
77
|
-
# Step 1: Get existing service IDs
|
|
78
|
-
existing_service_ids = fetch_service_ids
|
|
79
|
-
|
|
80
|
-
unless existing_service_ids
|
|
81
|
-
return { success: false, error: "Failed to fetch existing services" }
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Step 2: Execute create command (ignore exit code due to known bug)
|
|
85
|
-
execute_create_command
|
|
86
|
-
|
|
87
|
-
# Step 3: Detect new service
|
|
88
|
-
new_service = detect_new_service(existing_service_ids)
|
|
89
|
-
|
|
90
|
-
unless new_service
|
|
91
|
-
return { success: false, error: "Database service not detected after #{DETECTION_TIMEOUT}s" }
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# Step 4: Wait for DATABASE_URL
|
|
95
|
-
database_url = wait_for_database_url(new_service[:name])
|
|
96
|
-
|
|
97
|
-
unless database_url
|
|
98
|
-
return {
|
|
99
|
-
success: false,
|
|
100
|
-
error: "DATABASE_URL not available after #{PROVISION_TIMEOUT}s for service #{new_service[:name]}"
|
|
101
|
-
}
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
{
|
|
105
|
-
success: true,
|
|
106
|
-
service_id: new_service[:id],
|
|
107
|
-
service_name: new_service[:name],
|
|
108
|
-
database_url: database_url,
|
|
109
|
-
status: "created"
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
rescue => e
|
|
113
|
-
{
|
|
114
|
-
success: false,
|
|
115
|
-
error: "Unexpected error: #{e.message}"
|
|
116
|
-
}
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# Find existing Postgres database service
|
|
120
|
-
# @return [Hash, nil] { id:, name: } or nil if not found
|
|
121
|
-
private def find_existing_database
|
|
122
|
-
Tempfile.create('railway_status') do |tmpfile|
|
|
123
|
-
success = system(
|
|
124
|
-
@env,
|
|
125
|
-
"railway", "status", "--json",
|
|
126
|
-
in: :close,
|
|
127
|
-
out: tmpfile,
|
|
128
|
-
err: File::NULL
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
return nil unless success
|
|
132
|
-
|
|
133
|
-
tmpfile.rewind
|
|
134
|
-
output = tmpfile.read.strip
|
|
135
|
-
return nil if output.empty?
|
|
136
|
-
|
|
137
|
-
begin
|
|
138
|
-
data = JSON.parse(output)
|
|
139
|
-
|
|
140
|
-
# Navigate to production environment's service instances
|
|
141
|
-
edges = data.dig("environments", "edges")
|
|
142
|
-
return nil unless edges&.any?
|
|
143
|
-
|
|
144
|
-
prod_env = edges.find { |e| e.dig("node", "name") == "production" }
|
|
145
|
-
return nil unless prod_env
|
|
146
|
-
|
|
147
|
-
service_edges = prod_env.dig("node", "serviceInstances", "edges")
|
|
148
|
-
return nil unless service_edges
|
|
149
|
-
|
|
150
|
-
# Find Postgres service (by name pattern or image source)
|
|
151
|
-
postgres_edge = service_edges.find do |edge|
|
|
152
|
-
node = edge["node"]
|
|
153
|
-
next false unless node
|
|
154
|
-
|
|
155
|
-
service_name = node["serviceName"].to_s
|
|
156
|
-
source_image = node.dig("source", "image").to_s
|
|
157
|
-
|
|
158
|
-
# Match: name contains "Postgres" or "postgres", or source is postgres image
|
|
159
|
-
service_name.match?(/postgres/i) || source_image.include?("postgres")
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
return nil unless postgres_edge
|
|
163
|
-
|
|
164
|
-
node = postgres_edge["node"]
|
|
165
|
-
{ id: node["serviceId"], name: node["serviceName"] }
|
|
166
|
-
rescue JSON::ParserError
|
|
167
|
-
nil
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
# Fetch all existing service IDs from railway status
|
|
173
|
-
# @return [Array<String>, nil] Array of service IDs, or nil on failure
|
|
174
|
-
private def fetch_service_ids
|
|
175
|
-
Tempfile.create('railway_status') do |tmpfile|
|
|
176
|
-
success = system(
|
|
177
|
-
@env,
|
|
178
|
-
"railway", "status", "--json",
|
|
179
|
-
in: :close,
|
|
180
|
-
out: tmpfile,
|
|
181
|
-
err: File::NULL
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
return nil unless success
|
|
185
|
-
|
|
186
|
-
tmpfile.rewind
|
|
187
|
-
output = tmpfile.read.strip
|
|
188
|
-
return [] if output.empty?
|
|
189
|
-
|
|
190
|
-
begin
|
|
191
|
-
data = JSON.parse(output)
|
|
192
|
-
|
|
193
|
-
# Extract service IDs from environments.edges[0].node.serviceInstances.edges
|
|
194
|
-
edges = data.dig("environments", "edges")
|
|
195
|
-
return [] unless edges&.any?
|
|
196
|
-
|
|
197
|
-
service_instances = edges[0].dig("node", "serviceInstances", "edges")
|
|
198
|
-
return [] unless service_instances
|
|
199
|
-
|
|
200
|
-
service_instances.map { |edge| edge.dig("node", "serviceId") }.compact
|
|
201
|
-
rescue JSON::ParserError
|
|
202
|
-
nil
|
|
203
|
-
end
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
# Execute the railway add command
|
|
208
|
-
# NOTE: We ignore the exit code because Railway CLI has a known bug
|
|
209
|
-
# where it returns error with project tokens but still succeeds
|
|
210
|
-
private def execute_create_command
|
|
211
|
-
system(
|
|
212
|
-
@env,
|
|
213
|
-
"railway", "add", "--database", "postgres",
|
|
214
|
-
in: :close,
|
|
215
|
-
out: File::NULL,
|
|
216
|
-
err: File::NULL
|
|
217
|
-
)
|
|
218
|
-
# Exit code intentionally ignored
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
# Detect newly created service by comparing service IDs
|
|
222
|
-
# @param existing_ids [Array<String>] Service IDs before creation
|
|
223
|
-
# @return [Hash, nil] { id: String, name: String } or nil if not found
|
|
224
|
-
private def detect_new_service(existing_ids)
|
|
225
|
-
max_attempts = DETECTION_TIMEOUT / DETECTION_INTERVAL
|
|
226
|
-
|
|
227
|
-
max_attempts.times do
|
|
228
|
-
sleep DETECTION_INTERVAL
|
|
229
|
-
|
|
230
|
-
current_ids = fetch_service_ids
|
|
231
|
-
next unless current_ids
|
|
232
|
-
|
|
233
|
-
new_ids = current_ids - existing_ids
|
|
234
|
-
|
|
235
|
-
if new_ids.any?
|
|
236
|
-
# Fetch full service info to get the name
|
|
237
|
-
return fetch_service_info(new_ids.first)
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
nil
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
# Fetch service name for a given service ID
|
|
245
|
-
# @param service_id [String]
|
|
246
|
-
# @return [Hash, nil] { id: String, name: String }
|
|
247
|
-
private def fetch_service_info(service_id)
|
|
248
|
-
Tempfile.create('railway_status') do |tmpfile|
|
|
249
|
-
success = system(
|
|
250
|
-
@env,
|
|
251
|
-
"railway", "status", "--json",
|
|
252
|
-
in: :close,
|
|
253
|
-
out: tmpfile,
|
|
254
|
-
err: File::NULL
|
|
255
|
-
)
|
|
256
|
-
|
|
257
|
-
return nil unless success
|
|
258
|
-
|
|
259
|
-
tmpfile.rewind
|
|
260
|
-
output = tmpfile.read.strip
|
|
261
|
-
return nil if output.empty?
|
|
262
|
-
|
|
263
|
-
begin
|
|
264
|
-
data = JSON.parse(output)
|
|
265
|
-
edges = data.dig("environments", "edges")
|
|
266
|
-
return nil unless edges&.any?
|
|
267
|
-
|
|
268
|
-
service_instances = edges[0].dig("node", "serviceInstances", "edges")
|
|
269
|
-
return nil unless service_instances
|
|
270
|
-
|
|
271
|
-
# Find the service with matching ID
|
|
272
|
-
service_node = service_instances.find do |edge|
|
|
273
|
-
edge.dig("node", "serviceId") == service_id
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
return nil unless service_node
|
|
277
|
-
|
|
278
|
-
{
|
|
279
|
-
id: service_id,
|
|
280
|
-
name: service_node.dig("node", "serviceName")
|
|
281
|
-
}
|
|
282
|
-
rescue JSON::ParserError
|
|
283
|
-
nil
|
|
284
|
-
end
|
|
285
|
-
end
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
# Wait for DATABASE_URL to be available on the service
|
|
289
|
-
# @param service_name [String]
|
|
290
|
-
# @return [String, nil] DATABASE_URL or nil if timeout
|
|
291
|
-
private def wait_for_database_url(service_name)
|
|
292
|
-
max_attempts = PROVISION_TIMEOUT / PROVISION_INTERVAL
|
|
293
|
-
|
|
294
|
-
max_attempts.times do
|
|
295
|
-
sleep PROVISION_INTERVAL
|
|
296
|
-
|
|
297
|
-
database_url = fetch_database_url(service_name)
|
|
298
|
-
return database_url if database_url
|
|
299
|
-
end
|
|
300
|
-
|
|
301
|
-
nil
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
# Fetch DATABASE_URL from service variables
|
|
305
|
-
# @param service_name [String]
|
|
306
|
-
# @return [String, nil] DATABASE_URL value or nil
|
|
307
|
-
private def fetch_database_url(service_name)
|
|
308
|
-
tmpfile = Tempfile.new(['railway_vars', '.json'])
|
|
309
|
-
|
|
310
|
-
begin
|
|
311
|
-
success = system(
|
|
312
|
-
@env,
|
|
313
|
-
"railway", "variables", "--service", service_name, "--json",
|
|
314
|
-
in: :close,
|
|
315
|
-
out: tmpfile,
|
|
316
|
-
err: File::NULL
|
|
317
|
-
)
|
|
318
|
-
|
|
319
|
-
return nil unless success
|
|
320
|
-
|
|
321
|
-
tmpfile.rewind
|
|
322
|
-
output = tmpfile.read.strip
|
|
323
|
-
return nil if output.empty?
|
|
324
|
-
|
|
325
|
-
begin
|
|
326
|
-
# Output should be a JSON hash of variables
|
|
327
|
-
vars = JSON.parse(output)
|
|
328
|
-
|
|
329
|
-
# Prefer DATABASE_URL, fall back to DATABASE_PUBLIC_URL
|
|
330
|
-
vars["DATABASE_URL"] || vars["DATABASE_PUBLIC_URL"]
|
|
331
|
-
rescue JSON::ParserError
|
|
332
|
-
nil
|
|
333
|
-
end
|
|
334
|
-
ensure
|
|
335
|
-
tmpfile.close
|
|
336
|
-
tmpfile.unlink
|
|
337
|
-
end
|
|
338
|
-
end
|
|
339
|
-
end
|
|
340
|
-
end
|
|
341
|
-
end
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Clacky
|
|
4
|
-
module DeployTools
|
|
5
|
-
# Trigger a Railway deployment via `railway up` (blocking, with live logs).
|
|
6
|
-
# Uses RAILWAY_TOKEN passed through environment — no clackycli wrapper needed.
|
|
7
|
-
class ExecuteDeployment
|
|
8
|
-
|
|
9
|
-
# Trigger deployment for a service (blocking - waits for completion).
|
|
10
|
-
#
|
|
11
|
-
# @param service_name [String] Railway service name (from railway.toml or service list)
|
|
12
|
-
# @param platform_token [String] RAILWAY_TOKEN for this deploy task
|
|
13
|
-
# @return [Hash] { success: true, url: String } or { success: false, error: String }
|
|
14
|
-
def self.execute(service_name:, platform_token:)
|
|
15
|
-
if service_name.nil? || service_name.strip.empty?
|
|
16
|
-
return { success: false, error: "service_name is required" }
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
if platform_token.nil? || platform_token.strip.empty?
|
|
20
|
-
return { success: false, error: "platform_token is required" }
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
puts "🚀 Deploying service: #{service_name}"
|
|
24
|
-
puts " (This may take several minutes - you'll see live build logs below)"
|
|
25
|
-
puts ""
|
|
26
|
-
|
|
27
|
-
env = { "RAILWAY_TOKEN" => platform_token }
|
|
28
|
-
|
|
29
|
-
# Use railway up without --detach to block and show live logs
|
|
30
|
-
success = system(
|
|
31
|
-
env,
|
|
32
|
-
"railway", "up", "--service", service_name,
|
|
33
|
-
in: :close # Don't redirect stdout/stderr - let user see live logs
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
if success
|
|
37
|
-
puts ""
|
|
38
|
-
puts "✅ Deployment completed successfully"
|
|
39
|
-
|
|
40
|
-
# Extract URL from railway status after successful deployment
|
|
41
|
-
url = extract_url(env, service_name)
|
|
42
|
-
|
|
43
|
-
return { success: true, url: url }
|
|
44
|
-
else
|
|
45
|
-
puts ""
|
|
46
|
-
puts "❌ Deployment failed"
|
|
47
|
-
return { success: false, error: "railway up exited with error code" }
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
rescue => e
|
|
51
|
-
{ success: false, error: "Unexpected error: #{e.message}" }
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
private_class_method def self.extract_url(env, service_name)
|
|
55
|
-
require "json"
|
|
56
|
-
require "tempfile"
|
|
57
|
-
|
|
58
|
-
Tempfile.create("railway_status") do |tmpfile|
|
|
59
|
-
system(
|
|
60
|
-
env,
|
|
61
|
-
"railway", "status", "--json",
|
|
62
|
-
in: :close,
|
|
63
|
-
out: tmpfile,
|
|
64
|
-
err: File::NULL
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
tmpfile.rewind
|
|
68
|
-
output = tmpfile.read.strip
|
|
69
|
-
return nil if output.empty?
|
|
70
|
-
|
|
71
|
-
status_data = JSON.parse(output)
|
|
72
|
-
|
|
73
|
-
# Navigate: environments.edges[].node (name="production").serviceInstances.edges[].node
|
|
74
|
-
env_edges = status_data.dig("environments", "edges")
|
|
75
|
-
return nil unless env_edges.is_a?(Array)
|
|
76
|
-
|
|
77
|
-
prod_env = env_edges.find { |e| e.dig("node", "name") == "production" }
|
|
78
|
-
return nil unless prod_env
|
|
79
|
-
|
|
80
|
-
service_edges = prod_env.dig("node", "serviceInstances", "edges")
|
|
81
|
-
return nil unless service_edges.is_a?(Array)
|
|
82
|
-
|
|
83
|
-
service_edge = service_edges.find do |edge|
|
|
84
|
-
edge.dig("node", "serviceName") == service_name
|
|
85
|
-
end
|
|
86
|
-
return nil unless service_edge
|
|
87
|
-
|
|
88
|
-
domains = service_edge.dig("node", "domains", "customDomains")
|
|
89
|
-
return nil unless domains.is_a?(Array) && !domains.empty?
|
|
90
|
-
|
|
91
|
-
domain = domains.first["domain"]
|
|
92
|
-
domain ? "https://#{domain}" : nil
|
|
93
|
-
end
|
|
94
|
-
rescue
|
|
95
|
-
nil
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
end
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "net/http"
|
|
4
|
-
require "uri"
|
|
5
|
-
require "json"
|
|
6
|
-
|
|
7
|
-
module Clacky
|
|
8
|
-
module DeployTools
|
|
9
|
-
# Fetch runtime (environment) logs from a deployed Railway service via
|
|
10
|
-
# the Clacky Deploy API SSE endpoint.
|
|
11
|
-
#
|
|
12
|
-
# Uses DeployApiClient#stream_build_logs for build-phase logs (on failure),
|
|
13
|
-
# and this class for runtime log fetching (e.g. post-deploy diagnostics).
|
|
14
|
-
class FetchRuntimeLogs
|
|
15
|
-
DEFAULT_LINES = 50
|
|
16
|
-
MAX_LINES = 200
|
|
17
|
-
STREAM_TIMEOUT = 60 # seconds
|
|
18
|
-
|
|
19
|
-
# Fetch recent runtime logs for a project.
|
|
20
|
-
#
|
|
21
|
-
# @param project_id [String] Clacky project ID
|
|
22
|
-
# @param workspace_key [String] clacky_ak_* key
|
|
23
|
-
# @param base_url [String] API base URL
|
|
24
|
-
# @param lines [Integer] max log lines to collect
|
|
25
|
-
# @param keyword [String] optional filter keyword
|
|
26
|
-
# @return [Hash] { success: true, logs: Array<String> }
|
|
27
|
-
# or { success: false, error: String }
|
|
28
|
-
def self.execute(project_id:, workspace_key:, base_url:,
|
|
29
|
-
lines: DEFAULT_LINES, keyword: nil)
|
|
30
|
-
lines = [[lines.to_i, 1].max, MAX_LINES].min
|
|
31
|
-
|
|
32
|
-
params = "project_id=#{URI.encode_www_form_component(project_id)}"
|
|
33
|
-
params += "&keyword=#{URI.encode_www_form_component(keyword)}" if keyword
|
|
34
|
-
|
|
35
|
-
uri = URI.parse(
|
|
36
|
-
"#{base_url.to_s.sub(%r{/+$}, "")}" \
|
|
37
|
-
"/openclacky/v1/deploy/logs/environment/stream?#{params}"
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
collected = []
|
|
41
|
-
|
|
42
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
43
|
-
http.use_ssl = (uri.scheme == "https")
|
|
44
|
-
http.open_timeout = 10
|
|
45
|
-
http.read_timeout = STREAM_TIMEOUT
|
|
46
|
-
|
|
47
|
-
req = Net::HTTP::Get.new(uri.request_uri)
|
|
48
|
-
req["Authorization"] = "Bearer #{workspace_key}"
|
|
49
|
-
req["Accept"] = "text/event-stream"
|
|
50
|
-
|
|
51
|
-
http.request(req) do |response|
|
|
52
|
-
response.read_body do |chunk|
|
|
53
|
-
chunk.split("\n").each do |raw|
|
|
54
|
-
next unless raw.start_with?("data:")
|
|
55
|
-
|
|
56
|
-
json_str = raw.sub(/\Adata:\s*/, "")
|
|
57
|
-
next if json_str.strip.empty?
|
|
58
|
-
|
|
59
|
-
begin
|
|
60
|
-
event = JSON.parse(json_str)
|
|
61
|
-
msg = event["message"].to_s
|
|
62
|
-
collected << msg unless msg.empty?
|
|
63
|
-
return { success: true, logs: collected } if collected.size >= lines
|
|
64
|
-
rescue JSON::ParserError
|
|
65
|
-
# skip malformed events
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
{ success: true, logs: collected }
|
|
72
|
-
rescue => e
|
|
73
|
-
{ success: false, error: "Failed to fetch runtime logs: #{e.message}" }
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "open3"
|
|
4
|
-
require "json"
|
|
5
|
-
|
|
6
|
-
module Clacky
|
|
7
|
-
module DeployTools
|
|
8
|
-
# List Railway services for the linked project.
|
|
9
|
-
# Uses RAILWAY_TOKEN passed through environment — no clackycli wrapper needed.
|
|
10
|
-
#
|
|
11
|
-
# NOTE: In the new deploy flow, service discovery is primarily done via the
|
|
12
|
-
# Clacky Deploy API (deploy/services endpoint). This tool is kept as a
|
|
13
|
-
# fallback for detecting the main service name via `railway service list`.
|
|
14
|
-
class ListServices
|
|
15
|
-
|
|
16
|
-
# List services for the current Railway project.
|
|
17
|
-
#
|
|
18
|
-
# @param platform_token [String] RAILWAY_TOKEN for this deploy task
|
|
19
|
-
# @return [Hash] {
|
|
20
|
-
# success: Boolean,
|
|
21
|
-
# services: Array<Hash>,
|
|
22
|
-
# main_service: Hash | nil, # first non-middleware service
|
|
23
|
-
# db_service: Hash | nil # first postgres/mysql service
|
|
24
|
-
# }
|
|
25
|
-
def self.execute(platform_token:)
|
|
26
|
-
if platform_token.nil? || platform_token.strip.empty?
|
|
27
|
-
return { success: false, error: "platform_token is required" }
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
env = ENV.to_h.merge("RAILWAY_TOKEN" => platform_token)
|
|
31
|
-
out, err, status = Open3.capture3(env, "railway status --json")
|
|
32
|
-
|
|
33
|
-
unless status.success?
|
|
34
|
-
return {
|
|
35
|
-
success: false,
|
|
36
|
-
error: "railway service list failed (exit #{status.exitstatus})",
|
|
37
|
-
details: err
|
|
38
|
-
}
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
info = JSON.parse(out)
|
|
42
|
-
services = info["services"] || []
|
|
43
|
-
|
|
44
|
-
main_svc = services.find do |s|
|
|
45
|
-
name = s["name"].to_s.downcase
|
|
46
|
-
!%w[postgres postgresql mysql redis].any? { |db| name.include?(db) }
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
db_svc = services.find do |s|
|
|
50
|
-
name = s["name"].to_s.downcase
|
|
51
|
-
%w[postgres postgresql mysql].any? { |db| name.include?(db) }
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
{
|
|
55
|
-
success: true,
|
|
56
|
-
services: services,
|
|
57
|
-
main_service: main_svc,
|
|
58
|
-
db_service: db_svc
|
|
59
|
-
}
|
|
60
|
-
rescue JSON::ParserError => e
|
|
61
|
-
{ success: false, error: "Failed to parse service list: #{e.message}", raw: out.to_s[0, 200] }
|
|
62
|
-
rescue => e
|
|
63
|
-
{ success: false, error: "Unexpected error: #{e.message}" }
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|