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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/.standard.yml +3 -0
  3. data/CHANGELOG.md +5 -0
  4. data/CLAUDE.md +65 -0
  5. data/LICENSE +21 -0
  6. data/README.md +465 -0
  7. data/Rakefile +10 -0
  8. data/exe/strata +6 -0
  9. data/lib/strata/cli/ai/client.rb +63 -0
  10. data/lib/strata/cli/ai/configuration.rb +48 -0
  11. data/lib/strata/cli/ai/services/table_generator.rb +282 -0
  12. data/lib/strata/cli/api/client.rb +170 -0
  13. data/lib/strata/cli/api/connection_error_handler.rb +54 -0
  14. data/lib/strata/cli/configuration.rb +135 -0
  15. data/lib/strata/cli/credentials.rb +83 -0
  16. data/lib/strata/cli/descriptions/create/migration.txt +25 -0
  17. data/lib/strata/cli/descriptions/create/relation.txt +14 -0
  18. data/lib/strata/cli/descriptions/create/table.txt +23 -0
  19. data/lib/strata/cli/descriptions/datasource/add.txt +15 -0
  20. data/lib/strata/cli/descriptions/datasource/auth.txt +14 -0
  21. data/lib/strata/cli/descriptions/datasource/exec.txt +7 -0
  22. data/lib/strata/cli/descriptions/datasource/meta.txt +11 -0
  23. data/lib/strata/cli/descriptions/datasource/tables.txt +12 -0
  24. data/lib/strata/cli/descriptions/datasource/test.txt +8 -0
  25. data/lib/strata/cli/descriptions/deploy/deploy.txt +24 -0
  26. data/lib/strata/cli/descriptions/deploy/status.txt +9 -0
  27. data/lib/strata/cli/descriptions/init.txt +14 -0
  28. data/lib/strata/cli/generators/datasource.rb +83 -0
  29. data/lib/strata/cli/generators/group.rb +13 -0
  30. data/lib/strata/cli/generators/migration.rb +71 -0
  31. data/lib/strata/cli/generators/project.rb +190 -0
  32. data/lib/strata/cli/generators/relation.rb +64 -0
  33. data/lib/strata/cli/generators/table.rb +143 -0
  34. data/lib/strata/cli/generators/templates/adapters/athena.yml +53 -0
  35. data/lib/strata/cli/generators/templates/adapters/druid.yml +42 -0
  36. data/lib/strata/cli/generators/templates/adapters/duckdb.yml +36 -0
  37. data/lib/strata/cli/generators/templates/adapters/mysql.yml +45 -0
  38. data/lib/strata/cli/generators/templates/adapters/postgres.yml +48 -0
  39. data/lib/strata/cli/generators/templates/adapters/snowflake.yml +69 -0
  40. data/lib/strata/cli/generators/templates/adapters/sqlserver.yml +45 -0
  41. data/lib/strata/cli/generators/templates/adapters/trino.yml +56 -0
  42. data/lib/strata/cli/generators/templates/datasources.yml +4 -0
  43. data/lib/strata/cli/generators/templates/migration.rename.yml +15 -0
  44. data/lib/strata/cli/generators/templates/migration.swap.yml +13 -0
  45. data/lib/strata/cli/generators/templates/project.yml +36 -0
  46. data/lib/strata/cli/generators/templates/rel.domain.yml +43 -0
  47. data/lib/strata/cli/generators/templates/strata.yml +24 -0
  48. data/lib/strata/cli/generators/templates/table.table_name.yml +118 -0
  49. data/lib/strata/cli/generators/templates/test.yml +34 -0
  50. data/lib/strata/cli/generators/test.rb +48 -0
  51. data/lib/strata/cli/guard.rb +21 -0
  52. data/lib/strata/cli/helpers/color_helper.rb +103 -0
  53. data/lib/strata/cli/helpers/command_context.rb +41 -0
  54. data/lib/strata/cli/helpers/datasource_helper.rb +62 -0
  55. data/lib/strata/cli/helpers/description_helper.rb +18 -0
  56. data/lib/strata/cli/helpers/project_helper.rb +85 -0
  57. data/lib/strata/cli/helpers/prompts.rb +42 -0
  58. data/lib/strata/cli/helpers/table_filter.rb +48 -0
  59. data/lib/strata/cli/main.rb +71 -0
  60. data/lib/strata/cli/sub_commands/audit.rb +262 -0
  61. data/lib/strata/cli/sub_commands/create.rb +419 -0
  62. data/lib/strata/cli/sub_commands/datasource.rb +353 -0
  63. data/lib/strata/cli/sub_commands/deploy.rb +433 -0
  64. data/lib/strata/cli/sub_commands/project.rb +38 -0
  65. data/lib/strata/cli/sub_commands/table.rb +58 -0
  66. data/lib/strata/cli/terminal.rb +102 -0
  67. data/lib/strata/cli/ui/autocomplete.rb +93 -0
  68. data/lib/strata/cli/ui/field_editor.rb +215 -0
  69. data/lib/strata/cli/utils/archive.rb +137 -0
  70. data/lib/strata/cli/utils/deployment_monitor.rb +445 -0
  71. data/lib/strata/cli/utils/git.rb +253 -0
  72. data/lib/strata/cli/utils/import_manager.rb +190 -0
  73. data/lib/strata/cli/utils/test_reporter.rb +131 -0
  74. data/lib/strata/cli/utils/yaml_import_resolver.rb +91 -0
  75. data/lib/strata/cli/utils.rb +39 -0
  76. data/lib/strata/cli/version.rb +7 -0
  77. data/lib/strata/cli.rb +36 -0
  78. data/sig/strata/cli.rbs +6 -0
  79. 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