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,433 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../guard"
|
|
4
|
+
require_relative "../terminal"
|
|
5
|
+
require_relative "../helpers/color_helper"
|
|
6
|
+
require_relative "../helpers/project_helper"
|
|
7
|
+
require_relative "../helpers/description_helper"
|
|
8
|
+
require_relative "../api/client"
|
|
9
|
+
require_relative "../utils"
|
|
10
|
+
require_relative "../utils/archive"
|
|
11
|
+
require_relative "../utils/git"
|
|
12
|
+
require_relative "../utils/deployment_monitor"
|
|
13
|
+
require_relative "../utils/import_manager"
|
|
14
|
+
require "tty-prompt"
|
|
15
|
+
require "yaml"
|
|
16
|
+
require "fileutils"
|
|
17
|
+
|
|
18
|
+
module Strata
|
|
19
|
+
module CLI
|
|
20
|
+
module SubCommands
|
|
21
|
+
class Deploy < Thor
|
|
22
|
+
include Guard
|
|
23
|
+
include Terminal
|
|
24
|
+
extend Helpers::DescriptionHelper
|
|
25
|
+
|
|
26
|
+
default_command :deploy
|
|
27
|
+
|
|
28
|
+
class_option :environment, aliases: ["e"], type: :string
|
|
29
|
+
class_option :skip_audit, type: :boolean, default: false
|
|
30
|
+
class_option :yes, type: :boolean, default: false
|
|
31
|
+
class_option :force, aliases: ["f"], type: :boolean, default: false
|
|
32
|
+
|
|
33
|
+
desc "deploy", "Deploy project to Strata server for the current branch"
|
|
34
|
+
long_desc_from_file "deploy/deploy"
|
|
35
|
+
def deploy
|
|
36
|
+
config = load_deployment_configuration
|
|
37
|
+
validate_deployment_configuration(config)
|
|
38
|
+
run_pre_deploy_checks unless options[:skip_audit]
|
|
39
|
+
refreshed_imports = refresh_external_imports # Auto-refresh external imports before checking for changes
|
|
40
|
+
|
|
41
|
+
branch_id = determine_deployment_branch
|
|
42
|
+
run_git_status_checks(branch_id)
|
|
43
|
+
confirm_production_deploy(branch_id, config) unless options[:yes]
|
|
44
|
+
|
|
45
|
+
# Get last successful deployment to determine what files changed
|
|
46
|
+
last_deployment_commit = get_last_successful_deployment_commit(config, branch_id)
|
|
47
|
+
|
|
48
|
+
# Check local file changes
|
|
49
|
+
local_file_changes = if last_deployment_commit && Utils::Git.git_repo?
|
|
50
|
+
Utils::Git.changed_file_paths_since(last_deployment_commit)
|
|
51
|
+
else
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
metadata = prepare_deployment_metadata(last_deployment_commit)
|
|
55
|
+
|
|
56
|
+
# If only external imports changed, generate synthetic commit identifier
|
|
57
|
+
if refreshed_imports.any? && local_file_changes.empty?
|
|
58
|
+
import_commit_hash = Utils::ImportManager.generate_import_commit_hash(project_path)
|
|
59
|
+
say "\nExternal imports change found. Proceeding with deployment", ColorHelper.info
|
|
60
|
+
metadata[:commit] = "imports-#{import_commit_hash}"
|
|
61
|
+
metadata[:commit_message] = "External imports updated: #{refreshed_imports.map { |c| File.basename(c[:source]) }.join(", ")}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
archive_path = create_and_upload_archive(last_deployment_commit, refreshed_imports: refreshed_imports)
|
|
65
|
+
|
|
66
|
+
env_display = options[:environment] ? " (#{options[:environment]} environment)" : ""
|
|
67
|
+
server_info = config["server"] ? " (#{config["server"]})" : ""
|
|
68
|
+
say "\nDeploying to branch '#{branch_id}' on Strata server#{env_display}#{server_info}\n", ColorHelper.info
|
|
69
|
+
|
|
70
|
+
deployment = submit_deployment(config, branch_id, archive_path, metadata)
|
|
71
|
+
|
|
72
|
+
# Start monitoring deployment progress
|
|
73
|
+
monitor_deployment(config, branch_id, deployment)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
desc "status", "Show current deployment status of the current branch"
|
|
77
|
+
long_desc_from_file "deploy/status"
|
|
78
|
+
def status
|
|
79
|
+
config = load_deployment_configuration
|
|
80
|
+
validate_deployment_configuration(config)
|
|
81
|
+
|
|
82
|
+
branch_id = determine_deployment_branch
|
|
83
|
+
client = API::Client.new(config["server"], config["api_key"])
|
|
84
|
+
deployment = client.latest_deployment(config["project_id"], branch_id)
|
|
85
|
+
|
|
86
|
+
unless deployment
|
|
87
|
+
say "No deployments found for branch '#{branch_id}'.\n", ColorHelper.info
|
|
88
|
+
return
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
deployment_id = deployment["id"]
|
|
92
|
+
has_tests = test_files_exist?
|
|
93
|
+
monitor = Utils::DeploymentMonitor.new(client, config["project_id"], branch_id, deployment_id, has_tests: has_tests)
|
|
94
|
+
|
|
95
|
+
# Show current status
|
|
96
|
+
result = monitor.display_status
|
|
97
|
+
|
|
98
|
+
# Continue monitoring if deployment is in progress, or if tests are expected but not yet available
|
|
99
|
+
should_continue_monitoring = if result && !["succeeded", "failed"].include?(result["status"])
|
|
100
|
+
true
|
|
101
|
+
elsif result && result["status"] == "succeeded" && result["stage"] == "finished"
|
|
102
|
+
# Check if tests are expected but not yet available
|
|
103
|
+
latest_test_run = result["latest_test_run"] || result[:latest_test_run]
|
|
104
|
+
has_tests && latest_test_run.nil?
|
|
105
|
+
else
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
monitor.start(skip_initial_display: true) if should_continue_monitoring
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def project_path
|
|
115
|
+
@project_path ||= Dir.pwd
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def load_deployment_configuration
|
|
119
|
+
env = options[:environment]
|
|
120
|
+
CLI.config.get_for_environment(env)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_deployment_configuration(config)
|
|
124
|
+
ensure_api_key(config)
|
|
125
|
+
validate_server_url(config)
|
|
126
|
+
ensure_git_url_populated
|
|
127
|
+
ensure_project_id(config)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def ensure_api_key(config)
|
|
131
|
+
return if config["api_key"] && !config["api_key"].to_s.strip.empty? && config["api_key"] != "YOUR_STRATA_API_KEY"
|
|
132
|
+
|
|
133
|
+
config["api_key"] = collect_api_key_interactively
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def validate_server_url(config)
|
|
137
|
+
server_url = config["server"]
|
|
138
|
+
return if server_url && !server_url.to_s.strip.empty?
|
|
139
|
+
|
|
140
|
+
env_msg = options[:environment] ? " (or in '#{options[:environment]}' environment section)" : ""
|
|
141
|
+
say "\nServer URL not configured in project.yml#{env_msg}", :yellow
|
|
142
|
+
say "Please add or update the 'server' field in project.yml and try again.\n", :white
|
|
143
|
+
exit(1)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def ensure_git_url_populated
|
|
147
|
+
return unless Helpers::ProjectHelper.persist_git_url_if_missing
|
|
148
|
+
|
|
149
|
+
CLI.config.reload!
|
|
150
|
+
|
|
151
|
+
if Utils::Git.git_repo?
|
|
152
|
+
Utils::Git.commit_file(
|
|
153
|
+
Configuration::PROJECT_CONFIG_FILE,
|
|
154
|
+
"[Strata-CLI] Auto-populate git URL from remote",
|
|
155
|
+
project_path
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def ensure_project_id(config)
|
|
161
|
+
return if config["project_id"] && !config["project_id"].to_s.strip.empty?
|
|
162
|
+
|
|
163
|
+
project_id = create_project_on_server(config)
|
|
164
|
+
config["project_id"] = project_id
|
|
165
|
+
persist_project_id(project_id)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def collect_api_key_interactively
|
|
169
|
+
prompt = TTY::Prompt.new
|
|
170
|
+
|
|
171
|
+
say "\nAPI key not found in .strata file", :yellow
|
|
172
|
+
say "\nTo deploy to Strata, you need an API key.\nYou can get your API key from:", :white
|
|
173
|
+
say " • Your Strata server dashboard (Settings → API Keys)\n • Your Strata administrator\n", :cyan
|
|
174
|
+
|
|
175
|
+
api_key = prompt.mask("Enter your Strata API key:") do |q|
|
|
176
|
+
q.required true
|
|
177
|
+
q.validate(/\S+/, "API key cannot be empty")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Save API key to .strata file
|
|
181
|
+
with_spinner("Saving API key to .strata file") do
|
|
182
|
+
save_api_key_to_strata(api_key)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
api_key
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def save_api_key_to_strata(api_key)
|
|
189
|
+
strata_file = Configuration::STRATA_CONFIG_FILE
|
|
190
|
+
|
|
191
|
+
# Read existing content if file exists
|
|
192
|
+
existing_content = File.exist?(strata_file) ? File.read(strata_file) : ""
|
|
193
|
+
|
|
194
|
+
begin
|
|
195
|
+
if /^api_key:\s*.+$/m.match?(existing_content)
|
|
196
|
+
updated_content = existing_content.gsub(/^(\s*)api_key:\s*.+$/, "\\1api_key: #{api_key}")
|
|
197
|
+
File.write(strata_file, updated_content)
|
|
198
|
+
else
|
|
199
|
+
File.open(strata_file, "a") do |f|
|
|
200
|
+
f.puts "\n" unless existing_content.empty? || existing_content.end_with?("\n")
|
|
201
|
+
f.puts "api_key: #{api_key}"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Set restrictive permissions (read/write for owner only)
|
|
206
|
+
File.chmod(0o600, strata_file) if File.exist?(strata_file)
|
|
207
|
+
|
|
208
|
+
# Reload config to pick up the new API key
|
|
209
|
+
CLI.config.reload!
|
|
210
|
+
rescue Errno::EACCES => e
|
|
211
|
+
raise Strata::CommandError, "Permission denied writing to #{strata_file}: #{e.message}"
|
|
212
|
+
rescue Errno::ENOSPC => e
|
|
213
|
+
raise Strata::CommandError, "Disk full: #{e.message}"
|
|
214
|
+
rescue => e
|
|
215
|
+
raise Strata::CommandError, "Failed to write API key to #{strata_file}: #{e.message}"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def handle_missing_deployment_config(config, key)
|
|
220
|
+
raise Strata::CommandError, "Missing required configuration: #{key}. Check your project.yml file."
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def run_pre_deploy_checks
|
|
224
|
+
require_relative "../sub_commands/audit"
|
|
225
|
+
with_spinner("Running pre-deploy audit checks") do
|
|
226
|
+
SubCommands::Audit.new.all
|
|
227
|
+
end
|
|
228
|
+
rescue SystemExit => e
|
|
229
|
+
raise Strata::CommandError, "Audit checks failed. Fix errors before deploying." if e.status != 0
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def run_git_status_checks(branch_name)
|
|
233
|
+
return unless Utils::Git.git_repo?
|
|
234
|
+
|
|
235
|
+
with_spinner("Checking git repository status") do
|
|
236
|
+
# Check for uncommitted changes
|
|
237
|
+
if Utils::Git.uncommitted_changes?
|
|
238
|
+
raise Strata::CommandError, "You have uncommitted changes. Please commit or stash them before deploying."
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Fetch from remote
|
|
243
|
+
with_spinner("Fetching latest changes from remote") do
|
|
244
|
+
Utils::Git.fetch_branch(branch_name)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Check commit status
|
|
248
|
+
commit_status = Utils::Git.check_commit_status(branch_name)
|
|
249
|
+
case commit_status[:status]
|
|
250
|
+
when :behind
|
|
251
|
+
raise Strata::CommandError, "Your local branch is behind remote. Please pull latest changes before deploying."
|
|
252
|
+
when :diverged
|
|
253
|
+
raise Strata::CommandError, "Your local branch has diverged from remote. Please sync your branch before deploying."
|
|
254
|
+
when :ahead, :same
|
|
255
|
+
# Status is already shown via spinner, no need for additional message
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def determine_deployment_branch
|
|
260
|
+
Utils::Git.current_branch
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def prepare_deployment_metadata(last_deployment_commit = nil)
|
|
264
|
+
commit_info = Utils::Git.commit_info
|
|
265
|
+
committer_info = Utils::Git.committer_info
|
|
266
|
+
|
|
267
|
+
# Get file modifications since last deployment or since HEAD~1
|
|
268
|
+
file_modifications = if last_deployment_commit
|
|
269
|
+
Utils::Git.changed_files_since(last_deployment_commit)
|
|
270
|
+
else
|
|
271
|
+
Utils::Git.file_modifications
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
{
|
|
275
|
+
commit: commit_info[:sha],
|
|
276
|
+
commit_message: commit_info[:message],
|
|
277
|
+
committer_name: committer_info[:name],
|
|
278
|
+
committer_email: committer_info[:email],
|
|
279
|
+
file_modifications: file_modifications
|
|
280
|
+
}
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def create_and_upload_archive(last_deployment_commit = nil, refreshed_imports: [])
|
|
284
|
+
if last_deployment_commit && Utils::Git.git_repo?
|
|
285
|
+
# Get changed file paths since last deployment
|
|
286
|
+
changed_paths = Utils::Git.changed_file_paths_since(last_deployment_commit)
|
|
287
|
+
|
|
288
|
+
if changed_paths.empty? && refreshed_imports.empty?
|
|
289
|
+
unless options[:force]
|
|
290
|
+
say "\nNo files changed since last deployment.", ColorHelper.warning
|
|
291
|
+
say "To force deploy, run command with --force or -f flag.\n", ColorHelper.info
|
|
292
|
+
exit(0)
|
|
293
|
+
end
|
|
294
|
+
Utils::Archive.create(project_path)
|
|
295
|
+
else
|
|
296
|
+
# Convert relative paths to absolute paths
|
|
297
|
+
files_to_include = changed_paths.map do |relative_path|
|
|
298
|
+
File.join(project_path, relative_path)
|
|
299
|
+
end.select { |path| File.exist?(path) }
|
|
300
|
+
|
|
301
|
+
change_count = files_to_include.length
|
|
302
|
+
if refreshed_imports.any?
|
|
303
|
+
change_count += refreshed_imports.length
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
say "Including #{change_count} changed file(s) in archive...\n", ColorHelper.info
|
|
307
|
+
Utils::Archive.create(project_path, files_to_include: files_to_include)
|
|
308
|
+
end
|
|
309
|
+
else
|
|
310
|
+
# No last deployment or not a git repo - include all files
|
|
311
|
+
Utils::Archive.create(project_path)
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def submit_deployment(config, branch_id, archive_path, metadata)
|
|
316
|
+
client = API::Client.new(config["server"], config["api_key"])
|
|
317
|
+
|
|
318
|
+
with_spinner("Uploading files") do
|
|
319
|
+
client.create_deployment(config["project_id"], branch_id, archive_path, metadata)
|
|
320
|
+
end
|
|
321
|
+
ensure
|
|
322
|
+
FileUtils.rm_f(archive_path) if archive_path && File.exist?(archive_path)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def monitor_deployment(config, branch_id, deployment)
|
|
326
|
+
deployment_id = deployment["id"]
|
|
327
|
+
client = API::Client.new(config["server"], config["api_key"])
|
|
328
|
+
|
|
329
|
+
has_tests = test_files_exist?
|
|
330
|
+
Utils::DeploymentMonitor.new(client, config["project_id"], branch_id, deployment_id, has_tests: has_tests).start
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def test_files_exist?
|
|
334
|
+
tests_dir = File.join(project_path, "tests")
|
|
335
|
+
return false unless File.directory?(tests_dir)
|
|
336
|
+
|
|
337
|
+
!Dir.glob(File.join(tests_dir, "*.{yml,yaml}")).empty?
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def format_stage_name(stage)
|
|
341
|
+
stage.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def confirm_production_deploy(branch_id, config)
|
|
345
|
+
production_branch = get_production_branch(config)
|
|
346
|
+
return unless branch_id == production_branch
|
|
347
|
+
|
|
348
|
+
prompt = TTY::Prompt.new
|
|
349
|
+
message = "Deploying to production branch '#{branch_id}'. Continue? [y/N]"
|
|
350
|
+
return if prompt.yes?(message, default: false)
|
|
351
|
+
|
|
352
|
+
say "Deployment cancelled.", ColorHelper.warning
|
|
353
|
+
exit(0)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def get_production_branch(config)
|
|
357
|
+
return nil unless File.exist?("project.yml")
|
|
358
|
+
|
|
359
|
+
project_config = YAML.safe_load_file("project.yml", permitted_classes: [Date, Time], aliases: true) || {}
|
|
360
|
+
project_config["production_branch"] || "main"
|
|
361
|
+
rescue => e
|
|
362
|
+
warn "Failed to load project.yml: #{e.message}" if ENV["DEBUG"]
|
|
363
|
+
"main"
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def get_last_successful_deployment_commit(config, branch_id)
|
|
367
|
+
return nil unless Utils::Git.git_repo?
|
|
368
|
+
|
|
369
|
+
client = API::Client.new(config["server"], config["api_key"])
|
|
370
|
+
|
|
371
|
+
begin
|
|
372
|
+
last_deployment = client.last_successful_deployment(config["project_id"], branch_id)
|
|
373
|
+
return nil unless last_deployment && last_deployment["commit"]
|
|
374
|
+
|
|
375
|
+
commit_hash = last_deployment["commit"]
|
|
376
|
+
say "Found last successful deployment at commit: #{commit_hash[0..7]}...\n", ColorHelper.info
|
|
377
|
+
commit_hash
|
|
378
|
+
rescue Strata::CommandError
|
|
379
|
+
# If we can't get last deployment (e.g., first deployment), continue with all files
|
|
380
|
+
say "No previous deployment found. Including all files.\n", ColorHelper.info
|
|
381
|
+
nil
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def create_project_on_server(config)
|
|
386
|
+
project_config = YAML.safe_load_file("project.yml", permitted_classes: [Date, Time], aliases: true) || {}
|
|
387
|
+
name = project_config["name"]
|
|
388
|
+
uid = project_config["uid"]
|
|
389
|
+
description = project_config["description"]
|
|
390
|
+
git = project_config["git"]
|
|
391
|
+
production_branch = project_config["production_branch"]
|
|
392
|
+
|
|
393
|
+
unless name
|
|
394
|
+
raise Strata::CommandError, "Cannot create project: name is required in project.yml"
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
with_spinner("Creating project '#{name}' on server") do
|
|
398
|
+
client = API::Client.new(config["server"], config["api_key"])
|
|
399
|
+
project = client.create_project(name, uid, description: description, git: git, production_branch: production_branch)
|
|
400
|
+
project["id"]
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def persist_project_id(project_id)
|
|
405
|
+
with_spinner("Persisting project ID to project.yml") do
|
|
406
|
+
Helpers::ProjectHelper.persist_project_id_to_yml(project_id)
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def refresh_external_imports
|
|
411
|
+
refreshed = Utils::ImportManager.refresh_external_imports(project_path)
|
|
412
|
+
|
|
413
|
+
if refreshed.any?
|
|
414
|
+
say "\n Skimmed #{refreshed.length} external import(s):", ColorHelper.info
|
|
415
|
+
refreshed.each do |import|
|
|
416
|
+
filename = File.basename(import[:source])
|
|
417
|
+
say " • #{filename}", ColorHelper.info
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
refreshed
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def check_external_import_changes(project_path)
|
|
425
|
+
changed = Utils::ImportManager.check_changed_imports(project_path)
|
|
426
|
+
return [] unless changed.any?
|
|
427
|
+
|
|
428
|
+
changed
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../guard"
|
|
4
|
+
require_relative "../terminal"
|
|
5
|
+
require_relative "../helpers/color_helper"
|
|
6
|
+
require_relative "../helpers/project_helper"
|
|
7
|
+
require_relative "../api/client"
|
|
8
|
+
require "yaml"
|
|
9
|
+
|
|
10
|
+
module Strata
|
|
11
|
+
module CLI
|
|
12
|
+
module SubCommands
|
|
13
|
+
class Project < Thor
|
|
14
|
+
include Guard
|
|
15
|
+
include Terminal
|
|
16
|
+
|
|
17
|
+
desc "link PROJECT_ID", "Link local project to an existing project on the server"
|
|
18
|
+
def link(project_id)
|
|
19
|
+
unless project_id && !project_id.to_s.strip.empty?
|
|
20
|
+
raise Strata::CommandError, "Project ID is required."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
project_yml_path = "project.yml"
|
|
24
|
+
project_config = YAML.safe_load_file(project_yml_path, permitted_classes: [Date, Time], aliases: true) || {}
|
|
25
|
+
|
|
26
|
+
if project_config["project_id"] && !project_config["project_id"].to_s.strip.empty?
|
|
27
|
+
say "Project is already linked to project_id: #{project_config["project_id"]}", ColorHelper.warning
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if Helpers::ProjectHelper.persist_project_id_to_yml(project_id, project_yml_path: project_yml_path)
|
|
32
|
+
say "✓ Project linked successfully. project_id: #{project_id}", ColorHelper.info
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../helpers/color_helper"
|
|
4
|
+
require_relative "../helpers/prompts"
|
|
5
|
+
require_relative "../helpers/command_context"
|
|
6
|
+
|
|
7
|
+
module Strata
|
|
8
|
+
module CLI
|
|
9
|
+
module SubCommands
|
|
10
|
+
class Table < Thor
|
|
11
|
+
include Guard
|
|
12
|
+
include Terminal
|
|
13
|
+
include Prompts
|
|
14
|
+
include Thor::Actions
|
|
15
|
+
include Helpers::CommandContext
|
|
16
|
+
extend Helpers::DescriptionHelper
|
|
17
|
+
|
|
18
|
+
desc "list", "List all semantic models in the project"
|
|
19
|
+
def list
|
|
20
|
+
unless Dir.exist?("models")
|
|
21
|
+
say MSG_NO_MODELS_DIR, :yellow
|
|
22
|
+
return
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
model_files = Dir.glob("models/tbl[._]*.yml").sort
|
|
26
|
+
|
|
27
|
+
if model_files.empty?
|
|
28
|
+
say MSG_NO_MODELS_FOUND, :yellow
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
say MSG_MODELS_LIST_HEADER, :cyan
|
|
33
|
+
|
|
34
|
+
model_files.each do |file|
|
|
35
|
+
display_model_item(file)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
say MSG_MODELS_COUNT % model_files.length, :cyan
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def display_model_item(file)
|
|
44
|
+
name = File.basename(file, ".yml")
|
|
45
|
+
content = File.read(file)
|
|
46
|
+
desc = content.match(/^# Description: (.+)$/)&.[](1) || ""
|
|
47
|
+
desc = desc[0..50] + "..." if desc.length > 50
|
|
48
|
+
|
|
49
|
+
if desc.empty?
|
|
50
|
+
say " #{name}", :white
|
|
51
|
+
else
|
|
52
|
+
say " #{name.ljust(25)} #{desc}", :white
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
require "tty-spinner"
|
|
2
|
+
require "pastel"
|
|
3
|
+
require "tty-table"
|
|
4
|
+
require "io/console"
|
|
5
|
+
|
|
6
|
+
module Strata
|
|
7
|
+
module CLI
|
|
8
|
+
module Terminal
|
|
9
|
+
def create_spinner(message, message_color: :cyan, spinner_color: :cyan, format: :dots, clear: false)
|
|
10
|
+
pastel = Pastel.new
|
|
11
|
+
|
|
12
|
+
colored_message = pastel.send(message_color, message)
|
|
13
|
+
colored_spinner = "[#{pastel.send(spinner_color, ":spinner")}]"
|
|
14
|
+
|
|
15
|
+
TTY::Spinner.new("#{colored_spinner} #{colored_message}",
|
|
16
|
+
success_mark: pastel.green("✔"),
|
|
17
|
+
error_mark: pastel.red("✖"),
|
|
18
|
+
format: format,
|
|
19
|
+
clear: clear)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Shows a loader while running IO tasks
|
|
23
|
+
# Uses consistent format matching deployment monitor: cyan spinner that transitions to checkmark
|
|
24
|
+
def with_spinner(message = "Loading...",
|
|
25
|
+
success_message: "",
|
|
26
|
+
failed_message: "",
|
|
27
|
+
clear: false,
|
|
28
|
+
message_color: :cyan,
|
|
29
|
+
spinner_color: :cyan,
|
|
30
|
+
format: :dots)
|
|
31
|
+
spinner = create_spinner(message,
|
|
32
|
+
message_color: message_color,
|
|
33
|
+
spinner_color: spinner_color,
|
|
34
|
+
format: format,
|
|
35
|
+
clear: clear)
|
|
36
|
+
spinner.auto_spin
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
result = yield
|
|
40
|
+
spinner.success(success_message.empty? ? "" : Pastel.new.green(success_message))
|
|
41
|
+
result
|
|
42
|
+
rescue => e
|
|
43
|
+
spinner.error(failed_message.empty? ? "" : Pastel.new.red(failed_message))
|
|
44
|
+
raise e
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def print_table(data, headers: nil, color: :magenta)
|
|
49
|
+
if data.empty?
|
|
50
|
+
say TTY::Table.new.render(:unicode), color
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
terminal_width = begin
|
|
55
|
+
IO.console.winsize[1]
|
|
56
|
+
rescue
|
|
57
|
+
80
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
actual_headers = headers || (0...data.first.length).map { |i| "Col#{i + 1}" }
|
|
61
|
+
|
|
62
|
+
# Calculate actual width needed for each column
|
|
63
|
+
column_widths = []
|
|
64
|
+
actual_headers.each_with_index do |header, i|
|
|
65
|
+
header_width = header.to_s.length
|
|
66
|
+
data_width = data.map { |row| row[i].to_s.length }.max || 0
|
|
67
|
+
column_widths << [header_width, data_width].max
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Table overhead: 2 for outer borders + 1 per column separator
|
|
71
|
+
table_overhead = 2 + (column_widths.length - 1) * 1
|
|
72
|
+
# Add 2 chars padding per column (space on each side)
|
|
73
|
+
total_padding = column_widths.length * 2
|
|
74
|
+
|
|
75
|
+
# Find how many columns fit
|
|
76
|
+
fitted_cols = 0
|
|
77
|
+
running_width = table_overhead + total_padding
|
|
78
|
+
|
|
79
|
+
column_widths.each_with_index do |width, _i|
|
|
80
|
+
break unless running_width + width <= terminal_width
|
|
81
|
+
|
|
82
|
+
running_width += width
|
|
83
|
+
fitted_cols += 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Ensure at least one column
|
|
87
|
+
fitted_cols = [fitted_cols, 1].max
|
|
88
|
+
|
|
89
|
+
# Create the table
|
|
90
|
+
limited_headers = actual_headers.first(fitted_cols)
|
|
91
|
+
limited_data = data.map { |row| row.first(fitted_cols) }
|
|
92
|
+
|
|
93
|
+
table = TTY::Table.new(header: limited_headers, rows: limited_data)
|
|
94
|
+
say table.render(:unicode, padding: [0, 1]), color
|
|
95
|
+
return unless fitted_cols < actual_headers.length
|
|
96
|
+
|
|
97
|
+
truncated = actual_headers.length - fitted_cols
|
|
98
|
+
say "(Showing #{fitted_cols}/#{actual_headers.length} columns - #{truncated} truncated)", :cyan
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|