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,93 @@
1
+ require "tty-prompt"
2
+ require_relative "../helpers/table_filter"
3
+
4
+ module Strata
5
+ module CLI
6
+ # Reusable autocomplete component for fuzzy search selection.
7
+ # Works with any collection of items - tables, models, dimensions, etc.
8
+ class Autocomplete
9
+ attr_reader :prompt
10
+
11
+ def initialize(prompt: TTY::Prompt.new)
12
+ @prompt = prompt
13
+ end
14
+
15
+ # Select an item with fuzzy search filtering
16
+ # @param message [String] The prompt message
17
+ # @param items [Array<String>] Items to search through
18
+ # @param per_page [Integer] Number of items to show at once
19
+ # @param display_transform [Proc, nil] Optional proc to transform display names
20
+ # @param default_filter [String, nil] Optional initial filter to pre-apply
21
+ # @return [String, nil] Selected item or nil if cancelled
22
+ def select(message, items, per_page: 10, display_transform: nil, default_filter: nil)
23
+ return nil if items.empty?
24
+
25
+ # Create display mapping if transform provided
26
+ display_map = {}
27
+ display_items = if display_transform
28
+ items.map do |item|
29
+ display_name = display_transform.call(item)
30
+ display_map[display_name] = item
31
+ display_name
32
+ end
33
+ else
34
+ items
35
+ end
36
+
37
+ # Pre-filter items if default_filter provided
38
+ filtered_items = display_items
39
+ if default_filter && !default_filter.empty?
40
+ pattern = Regexp.new(Regexp.escape(default_filter), Regexp::IGNORECASE)
41
+ filtered_items = display_items.select { |item| item.match?(pattern) }
42
+
43
+ # If filter matches exactly one item, still show all but highlight the match
44
+ # If filter matches some items, show those
45
+ # If no matches, show all items
46
+ filtered_items = display_items if filtered_items.empty?
47
+ end
48
+
49
+ # Build the prompt message with filter hint
50
+ full_message = if default_filter && !default_filter.empty? && filtered_items.length < display_items.length
51
+ "#{message} (filtered by '#{default_filter}'. Type in characters to apply different filter)"
52
+ else
53
+ message
54
+ end
55
+
56
+ selected_display = prompt.select(
57
+ full_message,
58
+ filtered_items,
59
+ filter: true,
60
+ per_page: per_page,
61
+ show_help: :always,
62
+ help: "(Type to filter, ↑/↓ to navigate)"
63
+ )
64
+
65
+ return nil if selected_display.nil?
66
+
67
+ # Map back to original item if transform was used
68
+ display_transform ? display_map[selected_display] : selected_display
69
+ rescue TTY::Reader::InputInterrupt
70
+ nil
71
+ end
72
+
73
+ # Multi-select with fuzzy search
74
+ # @param message [String] The prompt message
75
+ # @param items [Array<String>] Items to search through
76
+ # @return [Array<String>] Selected items
77
+ def multi_select(message, items, per_page: 10)
78
+ return [] if items.empty?
79
+
80
+ prompt.multi_select(
81
+ message,
82
+ items,
83
+ filter: true,
84
+ per_page: per_page,
85
+ show_help: :always,
86
+ help: "(Type to filter, Space to select, Enter to confirm)"
87
+ )
88
+ rescue TTY::Reader::InputInterrupt
89
+ []
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,215 @@
1
+ require "tty-prompt"
2
+ require "tty-table"
3
+ require "pastel"
4
+ require_relative "../helpers/color_helper"
5
+
6
+ module Strata
7
+ module CLI
8
+ # Interactive field editor with row-by-row navigation.
9
+ # Displays a table of AI-generated fields and allows inline editing.
10
+ class FieldEditor
11
+ include Terminal
12
+
13
+ attr_reader :fields, :prompt, :pastel, :model_context
14
+
15
+ # @param fields [Array<Hash>] Fields from AI with :name, :description, :expression, :schema_type, :data_type
16
+ # @param table_context [Hash, nil] Optional context with :table_name, :columns, :datasource for prompt mode
17
+ # @param model_context [Hash, nil] Optional model metadata with :description
18
+ def initialize(fields, prompt: TTY::Prompt.new, table_context: nil, model_context: nil)
19
+ @fields = fields.map { |f| f.merge(skipped: false) }
20
+ @prompt = prompt
21
+ @pastel = Pastel.new
22
+ @selected_index = 0
23
+ @model_context = model_context || {description: ""}
24
+ @table_context = table_context
25
+ end
26
+
27
+ # Run the interactive editor
28
+ # @return [Array<Hash>, nil] Confirmed fields or nil if cancelled
29
+ def run
30
+ loop do
31
+ clear_screen
32
+ display_table
33
+ display_help
34
+ show_cursor
35
+
36
+ key = prompt.reader.read_keypress
37
+
38
+ case key
39
+ when "\e[A", "k" # Up arrow or k
40
+ @selected_index = [@selected_index - 1, 0].max
41
+ when "\e[B", "j" # Down arrow or j
42
+ @selected_index = [@selected_index + 1, @fields.length - 1].min
43
+ when "\r", "\n" # Enter
44
+ handle_edit
45
+ when "s", "S"
46
+ toggle_skip
47
+ when "p", "P"
48
+ handle_prompt_mode
49
+ when "c", "C"
50
+ if prompt.yes?("Confirm and generate model file?")
51
+ show_cursor
52
+ return {fields: active_fields, model_context: @model_context}
53
+ end
54
+ when "q", "Q", "\e" # q or Escape
55
+ if prompt.yes?("Quit without saving?")
56
+ show_cursor
57
+ return nil
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def clear_screen
66
+ # Hide cursor, move to home, clear screen, then show cursor after render
67
+ # This reduces perceived flicker
68
+ print "\e[?25l" # Hide cursor
69
+ print "\e[H" # Move to home (top-left)
70
+ print "\e[2J" # Clear screen
71
+ end
72
+
73
+ def show_cursor
74
+ print "\e[?25h" # Show cursor
75
+ end
76
+
77
+ def display_table
78
+ colors = ColorHelper
79
+ puts colors.highlight("\n Field Editor - Review & Edit Generated Fields\n")
80
+
81
+ rows = @fields.each_with_index.map do |field, idx|
82
+ indicator = (idx == @selected_index) ? colors.selected("❯") : " "
83
+ status = field[:skipped] ? colors.disabled("[SKIP]") : ""
84
+
85
+ name_cell = field[:skipped] ? colors.disabled(field[:name]) : field[:name]
86
+ desc_cell = if field[:skipped]
87
+ colors.disabled(truncate(field[:description],
88
+ 25))
89
+ else
90
+ truncate(field[:description], 25)
91
+ end
92
+ expr_cell = field[:skipped] ? colors.disabled(field[:expression]) : field[:expression]
93
+
94
+ [
95
+ "#{indicator} #{status}",
96
+ name_cell,
97
+ desc_cell,
98
+ expr_cell
99
+ ]
100
+ end
101
+
102
+ table = TTY::Table.new(
103
+ header: [" ", "Field Name", "Description", "Expression"],
104
+ rows: rows
105
+ )
106
+
107
+ puts table.render(:unicode, padding: [0, 1]) do |renderer|
108
+ renderer.border.style = :bright_cyan
109
+ end
110
+ end
111
+
112
+ def display_help
113
+ colors = ColorHelper
114
+ puts ""
115
+ puts " #{colors.info("↑/↓")} Navigate " \
116
+ "#{colors.success("[Enter]")} Edit " \
117
+ "#{colors.warning("[S]")}kip " \
118
+ "#{colors.primary("[P]")}rompt " \
119
+ "#{colors.secondary("[C]")}onfirm " \
120
+ "#{colors.error("[Q/Esc]")}uit"
121
+ puts ""
122
+ end
123
+
124
+ def handle_edit
125
+ edit_current_field
126
+ end
127
+
128
+ def edit_current_field
129
+ field = @fields[@selected_index]
130
+ colors = ColorHelper
131
+
132
+ puts colors.highlight("\n Editing: #{field[:name]}\n")
133
+ puts colors.dim(" (Press Ctrl+C or type 'back' to go back without saving)\n")
134
+
135
+ begin
136
+ field[:name] = prompt.ask(" Field Name:", default: field[:name])
137
+ return if field[:name] == "back"
138
+
139
+ field[:description] = prompt.ask(" Description:", default: field[:description])
140
+ return if field[:description] == "back"
141
+
142
+ field[:expression] = prompt.ask(" Expression:", default: field[:expression])
143
+ return if field[:expression] == "back"
144
+
145
+ field[:schema_type] = prompt.select(" Type:", %w[dimension measure], default: field[:schema_type])
146
+ rescue TTY::Reader::InputInterrupt
147
+ # User pressed Ctrl+C, go back without saving
148
+ nil
149
+ end
150
+ end
151
+
152
+ def toggle_skip
153
+ @fields[@selected_index][:skipped] = !@fields[@selected_index][:skipped]
154
+ end
155
+
156
+ def active_fields
157
+ @fields.reject { |f| f[:skipped] }
158
+ end
159
+
160
+ def truncate(str, length)
161
+ return "" if str.nil?
162
+
163
+ (str.length > length) ? "#{str[0...length - 2]}.." : str
164
+ end
165
+
166
+ def handle_prompt_mode
167
+ require_relative "../ai/services/model_generator"
168
+ require_relative "../terminal"
169
+ generator = AI::Services::ModelGenerator.new
170
+
171
+ colors = ColorHelper
172
+
173
+ unless generator.ai_available?
174
+ puts colors.warning("\n AI is not available. Configure AI in .strata file.")
175
+ sleep 1.5
176
+ return
177
+ end
178
+
179
+ unless @table_context
180
+ puts colors.warning("\n Prompt mode requires table context.")
181
+ sleep 1.5
182
+ return
183
+ end
184
+
185
+ print "\n"
186
+ user_prompt = prompt.ask(" Enter your prompt to regenerate/modify the table:")
187
+ return if user_prompt.nil? || user_prompt.strip.empty?
188
+
189
+ begin
190
+ new_fields = with_spinner("Regenerating fields with AI") do
191
+ generator.call_with_prompt(
192
+ table_name: @table_context[:table_name],
193
+ columns: @table_context[:columns],
194
+ datasource: @table_context[:datasource],
195
+ user_prompt: user_prompt,
196
+ current_fields: active_fields
197
+ )
198
+ end
199
+
200
+ if new_fields && !new_fields.empty?
201
+ @fields = new_fields.map { |f| f.merge(skipped: false) }
202
+ @selected_index = 0
203
+ # No message needed - table will refresh and show new fields
204
+ else
205
+ puts colors.warning(" No changes - keeping current fields")
206
+ sleep 1
207
+ end
208
+ rescue => e
209
+ puts colors.error("\n Error: #{e.message}")
210
+ sleep 2
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require "rubygems/package"
5
+ require "fileutils"
6
+ require "tempfile"
7
+ require "pathname"
8
+ require "securerandom"
9
+ require "yaml"
10
+ require_relative "yaml_import_resolver"
11
+ require_relative "import_manager"
12
+
13
+ module Strata
14
+ module CLI
15
+ module Utils
16
+ module Archive
17
+ module_function
18
+
19
+ def create(project_path, files_to_include: nil)
20
+ if files_to_include
21
+ # Use provided file list, but ensure they exist and are yml files
22
+ files = files_to_include.select do |file_path|
23
+ File.file?(file_path) && file_path.end_with?(".yml", ".yaml")
24
+ end
25
+ # Ensure required files are included if they exist
26
+ files = ensure_required_files(files, project_path)
27
+ else
28
+ # Collect all yml files if no list provided
29
+ files = collect_yml_files(project_path)
30
+ end
31
+ files = exclude_secrets(files)
32
+ inlined_files = process_imports(files, project_path)
33
+ build_archive(files, project_path, inlined_files)
34
+ end
35
+
36
+ private_class_method def collect_yml_files(project_path)
37
+ Dir.glob(File.join(project_path, "**", "*.yml")).select do |file_path|
38
+ File.file?(file_path)
39
+ end
40
+ end
41
+
42
+ private_class_method def ensure_required_files(files, project_path)
43
+ required_files = [
44
+ File.join(project_path, "project.yml"),
45
+ File.join(project_path, "datasources.yml")
46
+ ]
47
+
48
+ required_files.each do |required_file|
49
+ if File.exist?(required_file) && !files.include?(required_file)
50
+ files << required_file
51
+ end
52
+ end
53
+
54
+ # Always include all test files
55
+ test_files = Dir.glob(File.join(project_path, "tests", "*.{yml,yaml}"))
56
+ test_files.each do |test_file|
57
+ files << test_file unless files.include?(test_file)
58
+ end
59
+
60
+ files
61
+ end
62
+
63
+ private_class_method def exclude_secrets(files)
64
+ files.reject do |file_path|
65
+ file_path.include?(".strata") || file_path.include?("/.strata")
66
+ end
67
+ end
68
+
69
+ private_class_method def process_imports(files, project_path)
70
+ inlined_files = {}
71
+ files.each do |file_path|
72
+ next unless file_path.end_with?(".yml", ".yaml")
73
+ next unless file_path.start_with?(project_path)
74
+
75
+ # Only process table files (tbl.*.yml) through YamlImportResolver
76
+ # Other files (datasources.yml, rel.*.yml, etc.) don't have imports/fields structure
77
+ filename = File.basename(file_path)
78
+ next unless filename.start_with?("tbl.")
79
+
80
+ begin
81
+ inlined_content = YamlImportResolver.resolve(file_path, project_path)
82
+ inlined_files[file_path] = inlined_content
83
+ rescue Strata::ImportError
84
+ # If import resolution fails, use original file
85
+ # Error will be caught during archive creation or audit
86
+ end
87
+ end
88
+
89
+ inlined_files
90
+ end
91
+
92
+ def build_archive(files, project_path, inlined_files = {})
93
+ archive_file = archive_path
94
+
95
+ File.open(archive_file, "wb") do |file|
96
+ Zlib::GzipWriter.open(file) do |gz|
97
+ Gem::Package::TarWriter.new(gz) do |tar|
98
+ files.each do |file_path|
99
+ add_file_to_tar(tar, file_path, project_path, inlined_files)
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ archive_file
106
+ end
107
+
108
+ private_class_method def add_file_to_tar(tar, file_path, project_path, inlined_files = {})
109
+ relative_path = file_path.sub("#{project_path}/", "")
110
+
111
+ if inlined_files[file_path]
112
+ content = YAML.dump(inlined_files[file_path])
113
+ size = content.bytesize
114
+ mode = 0o644
115
+
116
+ tar.add_file_simple(relative_path, mode, size) do |tar_file|
117
+ tar_file.write(content)
118
+ end
119
+ else
120
+ mode = File.stat(file_path).mode
121
+ size = File.size(file_path)
122
+
123
+ tar.add_file_simple(relative_path, mode, size) do |tar_file|
124
+ File.open(file_path, "rb") { |f| tar_file.write(f.read) }
125
+ end
126
+ end
127
+ end
128
+
129
+ private_class_method def archive_path
130
+ timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
131
+ random_suffix = SecureRandom.hex(8)
132
+ File.join(Dir.tmpdir, "deploy-#{timestamp}-#{random_suffix}.tar.gz")
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end