strata-cli 0.1.0.beta
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 +7 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +65 -0
- data/LICENSE +21 -0
- data/README.md +465 -0
- data/Rakefile +10 -0
- data/exe/strata +6 -0
- data/lib/strata/cli/ai/client.rb +63 -0
- data/lib/strata/cli/ai/configuration.rb +48 -0
- data/lib/strata/cli/ai/services/table_generator.rb +282 -0
- data/lib/strata/cli/api/client.rb +170 -0
- data/lib/strata/cli/api/connection_error_handler.rb +54 -0
- data/lib/strata/cli/configuration.rb +135 -0
- data/lib/strata/cli/credentials.rb +83 -0
- data/lib/strata/cli/descriptions/create/migration.txt +25 -0
- data/lib/strata/cli/descriptions/create/relation.txt +14 -0
- data/lib/strata/cli/descriptions/create/table.txt +23 -0
- data/lib/strata/cli/descriptions/datasource/add.txt +15 -0
- data/lib/strata/cli/descriptions/datasource/auth.txt +14 -0
- data/lib/strata/cli/descriptions/datasource/exec.txt +7 -0
- data/lib/strata/cli/descriptions/datasource/meta.txt +11 -0
- data/lib/strata/cli/descriptions/datasource/tables.txt +12 -0
- data/lib/strata/cli/descriptions/datasource/test.txt +8 -0
- data/lib/strata/cli/descriptions/deploy/deploy.txt +24 -0
- data/lib/strata/cli/descriptions/deploy/status.txt +9 -0
- data/lib/strata/cli/descriptions/init.txt +14 -0
- data/lib/strata/cli/generators/datasource.rb +83 -0
- data/lib/strata/cli/generators/group.rb +13 -0
- data/lib/strata/cli/generators/migration.rb +71 -0
- data/lib/strata/cli/generators/project.rb +190 -0
- data/lib/strata/cli/generators/relation.rb +64 -0
- data/lib/strata/cli/generators/table.rb +143 -0
- data/lib/strata/cli/generators/templates/adapters/athena.yml +53 -0
- data/lib/strata/cli/generators/templates/adapters/druid.yml +42 -0
- data/lib/strata/cli/generators/templates/adapters/duckdb.yml +36 -0
- data/lib/strata/cli/generators/templates/adapters/mysql.yml +45 -0
- data/lib/strata/cli/generators/templates/adapters/postgres.yml +48 -0
- data/lib/strata/cli/generators/templates/adapters/snowflake.yml +69 -0
- data/lib/strata/cli/generators/templates/adapters/sqlserver.yml +45 -0
- data/lib/strata/cli/generators/templates/adapters/trino.yml +56 -0
- data/lib/strata/cli/generators/templates/datasources.yml +4 -0
- data/lib/strata/cli/generators/templates/migration.rename.yml +15 -0
- data/lib/strata/cli/generators/templates/migration.swap.yml +13 -0
- data/lib/strata/cli/generators/templates/project.yml +36 -0
- data/lib/strata/cli/generators/templates/rel.domain.yml +43 -0
- data/lib/strata/cli/generators/templates/strata.yml +24 -0
- data/lib/strata/cli/generators/templates/table.table_name.yml +118 -0
- data/lib/strata/cli/generators/templates/test.yml +34 -0
- data/lib/strata/cli/generators/test.rb +48 -0
- data/lib/strata/cli/guard.rb +21 -0
- data/lib/strata/cli/helpers/color_helper.rb +103 -0
- data/lib/strata/cli/helpers/command_context.rb +41 -0
- data/lib/strata/cli/helpers/datasource_helper.rb +62 -0
- data/lib/strata/cli/helpers/description_helper.rb +18 -0
- data/lib/strata/cli/helpers/project_helper.rb +85 -0
- data/lib/strata/cli/helpers/prompts.rb +42 -0
- data/lib/strata/cli/helpers/table_filter.rb +48 -0
- data/lib/strata/cli/main.rb +71 -0
- data/lib/strata/cli/sub_commands/audit.rb +262 -0
- data/lib/strata/cli/sub_commands/create.rb +419 -0
- data/lib/strata/cli/sub_commands/datasource.rb +353 -0
- data/lib/strata/cli/sub_commands/deploy.rb +433 -0
- data/lib/strata/cli/sub_commands/project.rb +38 -0
- data/lib/strata/cli/sub_commands/table.rb +58 -0
- data/lib/strata/cli/terminal.rb +102 -0
- data/lib/strata/cli/ui/autocomplete.rb +93 -0
- data/lib/strata/cli/ui/field_editor.rb +215 -0
- data/lib/strata/cli/utils/archive.rb +137 -0
- data/lib/strata/cli/utils/deployment_monitor.rb +445 -0
- data/lib/strata/cli/utils/git.rb +253 -0
- data/lib/strata/cli/utils/import_manager.rb +190 -0
- data/lib/strata/cli/utils/test_reporter.rb +131 -0
- data/lib/strata/cli/utils/yaml_import_resolver.rb +91 -0
- data/lib/strata/cli/utils.rb +39 -0
- data/lib/strata/cli/version.rb +7 -0
- data/lib/strata/cli.rb +36 -0
- data/sig/strata/cli.rbs +6 -0
- metadata +306 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../client"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Strata
|
|
7
|
+
module CLI
|
|
8
|
+
module AI
|
|
9
|
+
module Services
|
|
10
|
+
# Generates semantic model fields from database table metadata using AI.
|
|
11
|
+
# Returns structured JSON for use in the field editor.
|
|
12
|
+
class TableGenerator
|
|
13
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
14
|
+
You are a semantic modeling expert for analytics and BI systems.
|
|
15
|
+
Analyze database columns and generate semantic field definitions.
|
|
16
|
+
|
|
17
|
+
For each column, determine:
|
|
18
|
+
- name: Human-friendly field name (e.g., "customer_id" → "Customer ID")
|
|
19
|
+
- description: Brief description of what this field represents
|
|
20
|
+
- schema_type: "dimension" for categorical/text, "measure" for numeric aggregations
|
|
21
|
+
- data_type: string, integer, bigint, decimal, date, date_time, boolean
|
|
22
|
+
- expression: SQL expression (for measures include aggregation like "sum(amount)")
|
|
23
|
+
|
|
24
|
+
Output ONLY valid JSON array, no explanations.
|
|
25
|
+
PROMPT
|
|
26
|
+
|
|
27
|
+
attr_reader :client
|
|
28
|
+
|
|
29
|
+
def initialize(client: Client.new)
|
|
30
|
+
@client = client
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Generate field definitions from table metadata
|
|
34
|
+
# @param table_name [String] Full table name (schema.table)
|
|
35
|
+
# @param columns [Array<Hash>] Column metadata with :name, :type
|
|
36
|
+
# @param datasource [String] Datasource key
|
|
37
|
+
# @param user_context [Hash, nil] Optional user-provided context with :description
|
|
38
|
+
# @return [Hash, nil] Generated model data or nil if AI disabled
|
|
39
|
+
def call(table_name:, columns:, datasource:, user_context: nil)
|
|
40
|
+
unless ai_available?
|
|
41
|
+
warn "AI not available - using rule-based fallback"
|
|
42
|
+
return fallback_fields(columns)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
existing_models = load_existing_models_context
|
|
46
|
+
prompt = build_prompt(
|
|
47
|
+
table_name: table_name,
|
|
48
|
+
columns: columns,
|
|
49
|
+
existing_models_context: existing_models,
|
|
50
|
+
user_context: user_context
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
response = @client.complete(prompt, system_prompt: SYSTEM_PROMPT)
|
|
55
|
+
parse_response(response, columns)
|
|
56
|
+
rescue => e
|
|
57
|
+
# Fallback to basic field generation on AI error
|
|
58
|
+
warn "AI failed to generate fields: #{e.message} - performing basic field generation"
|
|
59
|
+
fallback_fields(columns)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Generate fields with user prompt for modification/regeneration
|
|
64
|
+
# @param table_name [String] Full table name
|
|
65
|
+
# @param columns [Array<Hash>] Column metadata
|
|
66
|
+
# @param datasource [String] Datasource key
|
|
67
|
+
# @param user_prompt [String] User's natural language prompt
|
|
68
|
+
# @param current_fields [Array<Hash>] Current field definitions
|
|
69
|
+
# @return [Array<Hash>] Regenerated fields
|
|
70
|
+
def call_with_prompt(table_name:, columns:, datasource:, user_prompt:, current_fields:)
|
|
71
|
+
unless ai_available?
|
|
72
|
+
warn "AI not available - could not use prompt mode"
|
|
73
|
+
return nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
prompt = build_prompt_with_user_input(
|
|
77
|
+
table_name: table_name,
|
|
78
|
+
columns: columns,
|
|
79
|
+
user_prompt: user_prompt,
|
|
80
|
+
current_fields: current_fields
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
begin
|
|
84
|
+
response = @client.complete(prompt, system_prompt: SYSTEM_PROMPT)
|
|
85
|
+
parse_response(response, columns)
|
|
86
|
+
rescue => e
|
|
87
|
+
warn "AI failed to generate fields: #{e.message}"
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Check if AI is available
|
|
93
|
+
def ai_available?
|
|
94
|
+
@client.enabled?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def build_prompt(table_name:, columns:, existing_models_context: nil, user_context: nil)
|
|
100
|
+
column_list = columns.map do |col|
|
|
101
|
+
name = col[:name] || col["name"]
|
|
102
|
+
type = col[:type] || col["type"]
|
|
103
|
+
"- #{name}: #{type}"
|
|
104
|
+
end.join("\n")
|
|
105
|
+
|
|
106
|
+
context_section = if existing_models_context && !existing_models_context.empty?
|
|
107
|
+
"\n\nExisting model patterns for consistency:\n#{existing_models_context}"
|
|
108
|
+
else
|
|
109
|
+
""
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
user_context_section = if user_context && !user_context[:description].to_s.empty?
|
|
113
|
+
"\n\nBusiness context provided by user: #{user_context[:description]}"
|
|
114
|
+
else
|
|
115
|
+
""
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
<<~PROMPT
|
|
119
|
+
Analyze these columns from table "#{table_name}" and generate semantic field definitions:
|
|
120
|
+
|
|
121
|
+
#{column_list}#{context_section}#{user_context_section}
|
|
122
|
+
|
|
123
|
+
Return JSON array with objects containing: name, description, schema_type, data_type, expression
|
|
124
|
+
Example: [{"name": "Order ID", "description": "Primary key", "schema_type": "dimension", "data_type": "bigint", "expression": "id"}]
|
|
125
|
+
PROMPT
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_prompt_with_user_input(table_name:, columns:, user_prompt:, current_fields:)
|
|
129
|
+
column_list = columns.map do |col|
|
|
130
|
+
name = col[:name] || col["name"]
|
|
131
|
+
type = col[:type] || col["type"]
|
|
132
|
+
"- #{name}: #{type}"
|
|
133
|
+
end.join("\n")
|
|
134
|
+
|
|
135
|
+
current_fields_summary = current_fields.map do |f|
|
|
136
|
+
"- #{f[:name] || f["name"]}: #{f[:schema_type] || f["schema_type"]} (#{f[:expression] || f["expression"]})"
|
|
137
|
+
end.join("\n")
|
|
138
|
+
|
|
139
|
+
existing_models = load_existing_models_context
|
|
140
|
+
context_section = if existing_models && !existing_models.empty?
|
|
141
|
+
"\n\nExisting model patterns for consistency:\n#{existing_models}"
|
|
142
|
+
else
|
|
143
|
+
""
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
<<~PROMPT
|
|
147
|
+
User request: #{user_prompt}
|
|
148
|
+
|
|
149
|
+
Current fields:
|
|
150
|
+
#{current_fields_summary}
|
|
151
|
+
|
|
152
|
+
Table columns:
|
|
153
|
+
#{column_list}#{context_section}
|
|
154
|
+
|
|
155
|
+
Based on the user's request, regenerate or modify the field definitions.
|
|
156
|
+
Return JSON array with objects containing: name, description, schema_type, data_type, expression
|
|
157
|
+
PROMPT
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def load_existing_models_context
|
|
161
|
+
return nil unless Dir.exist?("models")
|
|
162
|
+
|
|
163
|
+
model_files = Dir.glob("models/tbl_*.yml").first(5) # Limit to 5 for performance
|
|
164
|
+
return nil if model_files.empty?
|
|
165
|
+
|
|
166
|
+
contexts = model_files.map do |file|
|
|
167
|
+
model = YAML.safe_load_file(file, permitted_classes: [Date, Time], aliases: true) || {}
|
|
168
|
+
fields_summary = (model["fields"] || []).map do |f|
|
|
169
|
+
"#{f["name"]} (#{f["schema_type"]})"
|
|
170
|
+
end.join(", ")
|
|
171
|
+
"Model: #{model["name"]} - Fields: #{fields_summary}"
|
|
172
|
+
rescue => e
|
|
173
|
+
warn "Failed to load model file #{file}: #{e.message}" if ENV["DEBUG"]
|
|
174
|
+
nil
|
|
175
|
+
end.compact
|
|
176
|
+
|
|
177
|
+
contexts.join("\n")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def parse_response(response, columns)
|
|
181
|
+
# Clean up response - remove markdown if present
|
|
182
|
+
json_str = response.gsub(/```json\n?/, "").gsub(/```\n?/, "").strip
|
|
183
|
+
|
|
184
|
+
fields = JSON.parse(json_str, symbolize_names: true)
|
|
185
|
+
|
|
186
|
+
# Ensure each field has column reference
|
|
187
|
+
fields.each_with_index do |field, idx|
|
|
188
|
+
col = columns[idx]
|
|
189
|
+
field[:column] = col[:name] || col["name"] if col
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
fields
|
|
193
|
+
rescue JSON::ParserError => e
|
|
194
|
+
raise AIError, "Failed to parse AI response as JSON: #{e.message}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def fallback_fields(columns)
|
|
198
|
+
columns.map do |col|
|
|
199
|
+
name = col[:name] || col["name"]
|
|
200
|
+
type = col[:type] || col["type"]
|
|
201
|
+
|
|
202
|
+
is_measure = should_be_measure?(name, type)
|
|
203
|
+
|
|
204
|
+
{
|
|
205
|
+
column: name,
|
|
206
|
+
name: humanize(name),
|
|
207
|
+
description: "",
|
|
208
|
+
schema_type: is_measure ? "measure" : "dimension",
|
|
209
|
+
data_type: map_data_type(type),
|
|
210
|
+
expression: is_measure ? "sum(#{name})" : name
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def humanize(str)
|
|
216
|
+
str.to_s.tr("_", " ").gsub(/\b\w/, &:upcase)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Smarter logic: only true metrics should be measures
|
|
220
|
+
def should_be_measure?(name, type)
|
|
221
|
+
name_lower = name.to_s.downcase
|
|
222
|
+
|
|
223
|
+
# These patterns indicate dimensions, not measures
|
|
224
|
+
dimension_patterns = [
|
|
225
|
+
/_sk$/, # Surrogate keys (TPC-DS pattern)
|
|
226
|
+
/_id$/, # Foreign/primary keys
|
|
227
|
+
/_key$/, # Key columns
|
|
228
|
+
/_code$/, # Code columns
|
|
229
|
+
/_flag$/, # Boolean flags
|
|
230
|
+
/_date/, # Date columns
|
|
231
|
+
/_year$/, # Year (attribute, not sum)
|
|
232
|
+
/_month$/, # Month (attribute, not sum)
|
|
233
|
+
/_day$/, # Day (attribute, not sum)
|
|
234
|
+
/^id$/, # Just "id"
|
|
235
|
+
/created_at/, # Timestamps
|
|
236
|
+
/updated_at/,
|
|
237
|
+
/deleted_at/
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
return false if dimension_patterns.any? { |pattern| name_lower.match?(pattern) }
|
|
241
|
+
|
|
242
|
+
# Measure patterns (things you actually sum)
|
|
243
|
+
measure_patterns = [
|
|
244
|
+
/amount/,
|
|
245
|
+
/price/,
|
|
246
|
+
/cost/,
|
|
247
|
+
/total/,
|
|
248
|
+
/qty/,
|
|
249
|
+
/quantity/,
|
|
250
|
+
/count/,
|
|
251
|
+
/sum/,
|
|
252
|
+
/revenue/,
|
|
253
|
+
/sales/,
|
|
254
|
+
/fee/,
|
|
255
|
+
/tax/,
|
|
256
|
+
/discount/
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
# If name matches measure pattern and is numeric, it's a measure
|
|
260
|
+
numeric_type?(type) && measure_patterns.any? { |pattern| name_lower.match?(pattern) }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def numeric_type?(type)
|
|
264
|
+
type.to_s.downcase.match?(/int|decimal|numeric|float|double|number/)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def map_data_type(type)
|
|
268
|
+
case type.to_s.downcase
|
|
269
|
+
when /bigint/, /int8/ then "bigint"
|
|
270
|
+
when /int/ then "integer"
|
|
271
|
+
when /decimal/, /numeric/, /float/, /double/ then "decimal"
|
|
272
|
+
when /bool/ then "boolean"
|
|
273
|
+
when /timestamp/, /datetime/ then "date_time"
|
|
274
|
+
when /date/ then "date"
|
|
275
|
+
else "string"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/multipart"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "connection_error_handler"
|
|
7
|
+
|
|
8
|
+
module Strata
|
|
9
|
+
module CLI
|
|
10
|
+
module API
|
|
11
|
+
class Client
|
|
12
|
+
include ConnectionErrorHandler
|
|
13
|
+
|
|
14
|
+
def initialize(server_url, api_key)
|
|
15
|
+
@server_url = server_url.chomp("/")
|
|
16
|
+
@api_key = api_key
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def last_successful_deployment(project_id, branch_id)
|
|
20
|
+
url = deployment_url(project_id, branch_id)
|
|
21
|
+
response = with_connection_error_handling(@server_url) { connection.get(url) }
|
|
22
|
+
deployments = handle_response(response)
|
|
23
|
+
|
|
24
|
+
return nil unless deployments.is_a?(Array)
|
|
25
|
+
|
|
26
|
+
# Find the first successful deployment
|
|
27
|
+
deployments.find { |d| d["status"] == "succeeded" }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create_deployment(project_id, branch_id, archive_path, metadata)
|
|
31
|
+
url = deployment_url(project_id, branch_id)
|
|
32
|
+
file = Faraday::Multipart::FilePart.new(
|
|
33
|
+
archive_path,
|
|
34
|
+
"application/gzip"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
response = with_connection_error_handling(@server_url) do
|
|
38
|
+
connection.post(url) do |req|
|
|
39
|
+
req.body = {
|
|
40
|
+
"deployment[archive]" => file,
|
|
41
|
+
"deployment[commit]" => metadata[:commit],
|
|
42
|
+
"deployment[commit_message]" => metadata[:commit_message],
|
|
43
|
+
"deployment[committer_name]" => metadata[:committer_name],
|
|
44
|
+
"deployment[committer_email]" => metadata[:committer_email],
|
|
45
|
+
"deployment[file_modifications]" => metadata[:file_modifications].to_json
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
handle_response(response)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def get_deployment(project_id, branch_id, deployment_id)
|
|
54
|
+
url = "#{deployment_url(project_id, branch_id)}/#{deployment_id}"
|
|
55
|
+
response = with_connection_error_handling(@server_url) { connection.get(url) }
|
|
56
|
+
handle_response(response)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def latest_deployment(project_id, branch_id)
|
|
60
|
+
url = deployment_url(project_id, branch_id)
|
|
61
|
+
response = with_connection_error_handling(@server_url) { connection.get(url) }
|
|
62
|
+
deployments = handle_response(response)
|
|
63
|
+
return nil unless deployments.is_a?(Array) && deployments.any?
|
|
64
|
+
|
|
65
|
+
deployments.first
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def create_project(name, uid, description: nil, git: nil, production_branch: "main")
|
|
69
|
+
url = "#{@server_url}/api/v1/projects"
|
|
70
|
+
response = with_connection_error_handling(@server_url) do
|
|
71
|
+
connection.post(url) do |req|
|
|
72
|
+
req.headers["Content-Type"] = "application/json"
|
|
73
|
+
req.body = {
|
|
74
|
+
project: {
|
|
75
|
+
name: name,
|
|
76
|
+
uid: uid,
|
|
77
|
+
description: description,
|
|
78
|
+
git: git
|
|
79
|
+
},
|
|
80
|
+
production_branch: production_branch
|
|
81
|
+
}.to_json
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
handle_response(response)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def connection
|
|
91
|
+
@connection ||= Faraday.new(url: @server_url) do |f|
|
|
92
|
+
f.request :multipart
|
|
93
|
+
f.request :authorization, "Bearer", @api_key
|
|
94
|
+
f.response :json
|
|
95
|
+
f.adapter Faraday.default_adapter
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def handle_response(response)
|
|
100
|
+
case response.status
|
|
101
|
+
when 200..299
|
|
102
|
+
response.body
|
|
103
|
+
when 401
|
|
104
|
+
raise Strata::CommandError, "Authentication failed. Check your API key."
|
|
105
|
+
when 403
|
|
106
|
+
raise Strata::CommandError, "Access denied. You don't have permission for this action."
|
|
107
|
+
when 404
|
|
108
|
+
raise Strata::CommandError, "Resource not found."
|
|
109
|
+
when 422
|
|
110
|
+
errors = extract_errors(response.body)
|
|
111
|
+
raise Strata::CommandError, "Validation failed: #{errors}"
|
|
112
|
+
when 500..599
|
|
113
|
+
raise Strata::CommandError, "Server error: #{response.status}"
|
|
114
|
+
else
|
|
115
|
+
raise Strata::CommandError, "Unexpected response: #{response.status}"
|
|
116
|
+
end
|
|
117
|
+
rescue Faraday::Error => e
|
|
118
|
+
raise Strata::CommandError, "Network error: #{e.message}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def extract_errors(body)
|
|
122
|
+
# Handle nil or empty body
|
|
123
|
+
return "Unknown error" if body.nil? || body.empty?
|
|
124
|
+
|
|
125
|
+
# Check if it's an HTML error page (Rails error page)
|
|
126
|
+
if body.is_a?(String) && body.include?("<!DOCTYPE html>")
|
|
127
|
+
# Try to extract error message from HTML
|
|
128
|
+
if body =~ /<h1[^>]*>(.*?)<\/h1>/
|
|
129
|
+
return "Server error: #{$1.strip}"
|
|
130
|
+
elsif body =~ /<title[^>]*>(.*?)<\/title>/
|
|
131
|
+
return "Server error: #{$1.strip}"
|
|
132
|
+
else
|
|
133
|
+
return "Server returned an HTML error page. Check server logs for details."
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Parse JSON string if needed
|
|
138
|
+
parsed_body = if body.is_a?(String)
|
|
139
|
+
begin
|
|
140
|
+
JSON.parse(body)
|
|
141
|
+
rescue JSON::ParserError
|
|
142
|
+
return body[0..200] # Return first 200 chars if not JSON
|
|
143
|
+
end
|
|
144
|
+
else
|
|
145
|
+
body
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Extract error messages from various formats
|
|
149
|
+
if parsed_body.is_a?(Hash)
|
|
150
|
+
if parsed_body["errors"].is_a?(Array)
|
|
151
|
+
parsed_body["errors"].join(", ")
|
|
152
|
+
elsif parsed_body["error"].is_a?(String)
|
|
153
|
+
parsed_body["error"]
|
|
154
|
+
elsif parsed_body["message"].is_a?(String)
|
|
155
|
+
parsed_body["message"]
|
|
156
|
+
else
|
|
157
|
+
parsed_body.to_json
|
|
158
|
+
end
|
|
159
|
+
else
|
|
160
|
+
parsed_body.to_s[0..200] # Limit length
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def deployment_url(project_id, branch_id)
|
|
165
|
+
"#{@server_url}/api/v1/projects/#{project_id}/b/#{branch_id}/deploys"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
5
|
+
module Strata
|
|
6
|
+
module CLI
|
|
7
|
+
module API
|
|
8
|
+
# Handles connection errors and provides user-friendly error messages
|
|
9
|
+
module ConnectionErrorHandler
|
|
10
|
+
def with_connection_error_handling(server_url)
|
|
11
|
+
yield
|
|
12
|
+
rescue Faraday::ConnectionFailed => e
|
|
13
|
+
handle_connection_error(server_url, e)
|
|
14
|
+
rescue Faraday::TimeoutError => e
|
|
15
|
+
handle_connection_error(server_url, e, timeout: true)
|
|
16
|
+
rescue Faraday::Error => e
|
|
17
|
+
# Check if it's a connection-related error
|
|
18
|
+
if e.message.include?("Connection refused") ||
|
|
19
|
+
e.message.include?("Failed to open TCP connection") ||
|
|
20
|
+
e.message.include?("getaddrinfo") ||
|
|
21
|
+
e.message.include?("No route to host")
|
|
22
|
+
handle_connection_error(server_url, e)
|
|
23
|
+
else
|
|
24
|
+
raise Strata::CommandError, "Network error: #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EHOSTUNREACH => e
|
|
27
|
+
handle_connection_error(server_url, e)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def handle_connection_error(server_url, error, timeout: false)
|
|
33
|
+
server_display = server_url
|
|
34
|
+
|
|
35
|
+
message = "\n"
|
|
36
|
+
message += "Cannot connect to Strata server\n\n"
|
|
37
|
+
message += "Server: #{server_display}\n"
|
|
38
|
+
|
|
39
|
+
if timeout
|
|
40
|
+
message += "Error: Connection timeout\n\n"
|
|
41
|
+
message += "The server did not respond in time. This could mean:\n"
|
|
42
|
+
message += " • The server is overloaded or slow\n"
|
|
43
|
+
message += " • Network connectivity issues\n"
|
|
44
|
+
else
|
|
45
|
+
message += "Error: Connection refused\n\n"
|
|
46
|
+
message += "The server appears to be offline or unreachable. Please check:\n"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
raise Strata::CommandError, message
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Strata
|
|
4
|
+
module CLI
|
|
5
|
+
class Configuration
|
|
6
|
+
STRATA_CONFIG_FILE = ".strata"
|
|
7
|
+
PROJECT_CONFIG_FILE = "project.yml"
|
|
8
|
+
|
|
9
|
+
# Default configuration
|
|
10
|
+
DEFAULT = {
|
|
11
|
+
"api_key" => "",
|
|
12
|
+
"server" => "http://localhost:3030",
|
|
13
|
+
"log_level" => "info"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :data
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@data = DEFAULT.merge({})
|
|
20
|
+
load_config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def [](key)
|
|
24
|
+
@data[key]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def method_missing(method_name, *args, &block)
|
|
28
|
+
if @data.key?(method_name.to_s)
|
|
29
|
+
@data[method_name.to_s]
|
|
30
|
+
else
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
36
|
+
@data.key?(method_name.to_s) || super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def strata_project?
|
|
40
|
+
File.exist?(project_config_file_path)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def config_file_path
|
|
44
|
+
@config_file_path ||= File.expand_path(STRATA_CONFIG_FILE)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def project_config_file_path
|
|
48
|
+
@project_config_file_path ||= File.expand_path(PROJECT_CONFIG_FILE)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get configuration value with type conversion
|
|
52
|
+
def get(key, type: nil)
|
|
53
|
+
value = self[key]
|
|
54
|
+
return value unless type
|
|
55
|
+
|
|
56
|
+
case type
|
|
57
|
+
when :boolean
|
|
58
|
+
convert_to_boolean(value)
|
|
59
|
+
when :integer
|
|
60
|
+
Integer(value)
|
|
61
|
+
when :float
|
|
62
|
+
Float(value)
|
|
63
|
+
when :array
|
|
64
|
+
Array(value)
|
|
65
|
+
else
|
|
66
|
+
value
|
|
67
|
+
end
|
|
68
|
+
rescue ArgumentError, TypeError => e
|
|
69
|
+
raise StrataError, "Invalid #{type} value for '#{key}': #{value} \n\t#{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def reload!
|
|
73
|
+
@data.clear
|
|
74
|
+
load_config
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def get_for_environment(env)
|
|
78
|
+
root_config = extract_root_config
|
|
79
|
+
return root_config if env.nil? || env.empty?
|
|
80
|
+
|
|
81
|
+
env_data = @data[env.to_s]
|
|
82
|
+
root_config.merge(env_data)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def load_config
|
|
88
|
+
return unless strata_project?
|
|
89
|
+
|
|
90
|
+
strata_data = load_yaml_file(config_file_path)
|
|
91
|
+
project_data = load_yaml_file(project_config_file_path)
|
|
92
|
+
|
|
93
|
+
@data = DEFAULT.merge(strata_data).merge(project_data)
|
|
94
|
+
|
|
95
|
+
@api_key = @data["api_key"]
|
|
96
|
+
@server = @data["server"]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def load_yaml_file(file_path)
|
|
100
|
+
return {} unless File.exist?(file_path)
|
|
101
|
+
|
|
102
|
+
YAML.safe_load_file(file_path, permitted_classes: [Date, Time], aliases: true) || {}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def extract_root_config
|
|
106
|
+
root_config = {}
|
|
107
|
+
@data.each do |key, value|
|
|
108
|
+
next if is_environment_key?(key, value)
|
|
109
|
+
|
|
110
|
+
root_config[key] = value
|
|
111
|
+
end
|
|
112
|
+
root_config
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def is_environment_key?(key, value)
|
|
116
|
+
return false unless value.is_a?(Hash)
|
|
117
|
+
|
|
118
|
+
value.key?("api_key") || value.key?("server")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def convert_to_boolean(value)
|
|
122
|
+
case value
|
|
123
|
+
when true, false
|
|
124
|
+
value
|
|
125
|
+
when "true", "yes", "1", 1
|
|
126
|
+
true
|
|
127
|
+
when "false", "no", "0", 0, nil
|
|
128
|
+
false
|
|
129
|
+
else
|
|
130
|
+
raise ArgumentError, "Cannot convert #{value.inspect} to boolean"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|