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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "pathname"
7
+ require_relative "git"
8
+
9
+ module Strata
10
+ module CLI
11
+ module Utils
12
+ # Manages imported files and tracks external imports in strata.lock.
13
+ module ImportManager
14
+ module_function
15
+
16
+ LOCK_FILE = "strata.lock"
17
+
18
+ def validate_import_path(import_path)
19
+ if Pathname.new(import_path).absolute?
20
+ raise Strata::InvalidImportPathError, "Import paths must be relative, not absolute: #{import_path}"
21
+ end
22
+ end
23
+
24
+ def track_external_import(import_path, resolved_path, project_path)
25
+ return resolved_path unless external_import?(resolved_path, project_path)
26
+
27
+ lock_data = load_lock_file(project_path)
28
+ current_hash = file_hash(resolved_path)
29
+
30
+ # Find existing entry by path (as specified in YAML)
31
+ existing = find_existing_import(import_path, lock_data)
32
+
33
+ if existing && existing["hash"] == current_hash
34
+ # Hash unchanged, no update needed
35
+ return resolved_path
36
+ end
37
+
38
+ # Update or add entry
39
+ entry = {
40
+ "path" => import_path,
41
+ "source" => resolved_path,
42
+ "hash" => current_hash,
43
+ "imported_at" => Time.now.utc.iso8601
44
+ }
45
+
46
+ lock_data["imports"] ||= []
47
+ lock_data["imports"].reject! { |e| e["path"] == import_path }
48
+ lock_data["imports"] << entry
49
+
50
+ save_lock_file(project_path, lock_data)
51
+
52
+ resolved_path
53
+ end
54
+
55
+ def resolve_with_fallback(import_path, base_dir, project_path)
56
+ # Validate path is relative
57
+ validate_import_path(import_path)
58
+
59
+ resolved_path = File.expand_path(import_path, base_dir)
60
+
61
+ unless File.exist?(resolved_path)
62
+ raise Strata::MissingImportError, "Import file not found: #{import_path}"
63
+ end
64
+
65
+ # Track external imports in lock file
66
+ track_external_import(import_path, resolved_path, project_path)
67
+
68
+ resolved_path
69
+ end
70
+
71
+ def check_changed_imports(project_path)
72
+ lock_data = load_lock_file(project_path)
73
+ return [] unless lock_data["imports"]
74
+
75
+ changed = []
76
+ lock_data["imports"].each do |entry|
77
+ next unless entry["source"] && File.exist?(entry["source"])
78
+
79
+ current_hash = file_hash(entry["source"])
80
+ if current_hash != entry["hash"]
81
+ changed << {
82
+ path: entry["path"],
83
+ source: entry["source"],
84
+ old_hash: entry["hash"],
85
+ new_hash: current_hash
86
+ }
87
+ end
88
+ end
89
+
90
+ changed
91
+ end
92
+
93
+ def refresh_external_imports(project_path)
94
+ lock_data = load_lock_file(project_path)
95
+ return [] unless lock_data["imports"]
96
+
97
+ refreshed = []
98
+ lock_data["imports"].each do |entry|
99
+ next unless entry["source"] && File.exist?(entry["source"])
100
+
101
+ current_hash = file_hash(entry["source"])
102
+ if current_hash != entry["hash"]
103
+ entry["hash"] = current_hash
104
+ entry["imported_at"] = Time.now.utc.iso8601
105
+ refreshed << {
106
+ path: entry["path"],
107
+ source: entry["source"],
108
+ hash: current_hash
109
+ }
110
+ end
111
+ end
112
+
113
+ save_lock_file(project_path, lock_data) if refreshed.any?
114
+
115
+ refreshed
116
+ end
117
+
118
+ def generate_import_commit_hash(project_path)
119
+ lock_data = load_lock_file(project_path)
120
+ return nil unless lock_data["imports"]&.any?
121
+
122
+ # Create a hash from all import hashes combined
123
+ import_hashes = lock_data["imports"].map { |e| "#{e["path"]}:#{e["hash"]}" }.sort.join("|")
124
+ Digest::SHA256.hexdigest(import_hashes)[0..15]
125
+ end
126
+
127
+ private_class_method def external_import?(resolved_path, project_path)
128
+ project_abs = File.expand_path(project_path)
129
+ import_abs = File.expand_path(resolved_path)
130
+ !import_abs.start_with?(project_abs)
131
+ end
132
+
133
+ private_class_method def load_lock_file(project_path)
134
+ default_lock_data = {"imports" => []}
135
+ lock_path = File.join(project_path, LOCK_FILE)
136
+ return default_lock_data unless File.exist?(lock_path)
137
+
138
+ YAML.safe_load_file(lock_path, permitted_classes: [Date, Time], aliases: true) || default_lock_data
139
+ rescue => e
140
+ warn "Failed to load lock file: #{e.message}" if ENV["DEBUG"]
141
+ default_lock_data
142
+ end
143
+
144
+ private_class_method def save_lock_file(project_path, lock_data)
145
+ lock_path = File.join(project_path, LOCK_FILE)
146
+ begin
147
+ File.write(lock_path, YAML.dump(lock_data))
148
+
149
+ # Automatically commit the lock file to git if it changed
150
+ git_dir = File.join(project_path, ".git")
151
+ if File.directory?(git_dir)
152
+ relative_lock_path = LOCK_FILE
153
+ commit_message = "[Strata-CLI] Update strata.lock with external import changes"
154
+ Git.commit_file(relative_lock_path, commit_message, project_path)
155
+ end
156
+ rescue Errno::EACCES => e
157
+ raise Strata::CommandError, "Permission denied writing to #{lock_path}: #{e.message}"
158
+ rescue Errno::ENOSPC => e
159
+ raise Strata::CommandError, "Disk full: #{e.message}"
160
+ rescue => e
161
+ raise Strata::CommandError, "Failed to write lock file #{lock_path}: #{e.message}"
162
+ end
163
+ end
164
+
165
+ private_class_method def file_hash(file_path)
166
+ return nil unless File.exist?(file_path)
167
+
168
+ Digest::SHA256.file(file_path).hexdigest
169
+ end
170
+
171
+ private_class_method def find_existing_import(import_path, lock_data)
172
+ return nil unless lock_data["imports"]
173
+
174
+ lock_data["imports"].find { |entry| entry["path"] == import_path }
175
+ end
176
+
177
+ # Public accessors for testing/debugging
178
+ def load_lock_file_public(project_path)
179
+ load_lock_file(project_path)
180
+ end
181
+ module_function :load_lock_file_public
182
+
183
+ def file_hash_public(file_path)
184
+ file_hash(file_path)
185
+ end
186
+ module_function :file_hash_public
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../helpers/color_helper"
4
+ require_relative "../terminal"
5
+
6
+ module Strata
7
+ module CLI
8
+ module Utils
9
+ class TestReporter
10
+ include Terminal
11
+
12
+ def initialize(color_helper: ColorHelper)
13
+ @color_helper = color_helper
14
+ end
15
+
16
+ def display(test_results, skip_opening_divider: false)
17
+ return unless test_results.is_a?(Hash) && test_results["tests"].is_a?(Array)
18
+
19
+ display_summary(test_results, skip_opening_divider: skip_opening_divider)
20
+ display_failures(test_results) if has_failures?(test_results)
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :color_helper
26
+
27
+ def say(message, color_helper_result = nil)
28
+ if color_helper_result
29
+ if color_helper_result.is_a?(Array)
30
+ colored_message = ColorHelper.pastel.decorate(message, *color_helper_result)
31
+ $stdout.puts(colored_message)
32
+ elsif color_helper_result.is_a?(Symbol)
33
+ theme = ColorHelper::THEME[color_helper_result]
34
+ if theme
35
+ styles = Array(theme)
36
+ colored_message = ColorHelper.pastel.decorate(message, *styles)
37
+ $stdout.puts(colored_message)
38
+ else
39
+ begin
40
+ colored_message = ColorHelper.pastel.send(color_helper_result, message)
41
+ $stdout.puts(colored_message)
42
+ rescue NoMethodError
43
+ $stdout.puts(message)
44
+ end
45
+ end
46
+ else
47
+ $stdout.puts(message)
48
+ end
49
+ else
50
+ $stdout.puts(message)
51
+ end
52
+ end
53
+
54
+ def display_summary(test_results, skip_opening_divider: false)
55
+ tests = test_results["tests"] || []
56
+ total = tests.length
57
+ passed = tests.count { |t| t["status"] == "passed" }
58
+ failed = tests.count { |t| t["status"] == "failed" }
59
+ errors = tests.count { |t| t["status"] == "error" }
60
+
61
+ say "\n" + "=" * 60, :border unless skip_opening_divider
62
+ say "\n Tests ran: #{total} total, #{passed} passed", :info
63
+ say " #{failed} failed, #{errors} error#{"s" if errors != 1}" if failed > 0 || errors > 0
64
+ say "\n" + "=" * 60, :border
65
+ end
66
+
67
+ def display_failures(test_results)
68
+ tests = test_results["tests"] || []
69
+ failed_tests = tests.select { |t| t["status"] == "failed" || t["status"] == "error" }
70
+
71
+ return if failed_tests.empty?
72
+
73
+ say "\n Failed Tests:\n", :error
74
+
75
+ failed_tests.each do |test|
76
+ display_test_failure(test)
77
+ end
78
+ end
79
+
80
+ def display_test_failure(test)
81
+ name = test["name"] || "Unknown test"
82
+ status = test["status"]
83
+
84
+ say "\n ✖ #{name}", :error
85
+
86
+ if status == "error"
87
+ say " Error: #{test["error"]}", :error
88
+ return
89
+ end
90
+
91
+ # Display assertion failure details
92
+ if test["assertion_type"]
93
+ say " Assertion failed: #{test["assertion_type"]}", :error
94
+ end
95
+
96
+ if test["message"]
97
+ say " #{test["message"]}", :error
98
+ end
99
+
100
+ # Show expected vs generated SQL for debugging
101
+ if test["expected_sql"]
102
+ say " Expected: #{truncate(test["expected_sql"], 100)}", :error
103
+ end
104
+
105
+ if test["pattern"]
106
+ say " Pattern: #{test["pattern"]}", :error
107
+ end
108
+
109
+ if test["generated_sql"]
110
+ say " Generated: #{truncate(test["generated_sql"], 200)}", :error
111
+ end
112
+
113
+ if test["failing_values"] && test["failing_values"].is_a?(Array) && test["failing_values"].any?
114
+ sample_values = test["failing_values"].first(5)
115
+ say " Sample failing values: #{sample_values.inspect}", :error
116
+ end
117
+ end
118
+
119
+ def truncate(str, max_length)
120
+ return str if str.nil? || str.length <= max_length
121
+ str[0, max_length] + "..."
122
+ end
123
+
124
+ def has_failures?(test_results)
125
+ tests = test_results["tests"] || []
126
+ tests.any? { |t| t["status"] == "failed" || t["status"] == "error" }
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+ require_relative "import_manager"
6
+
7
+ module Strata
8
+ module CLI
9
+ module Utils
10
+ # Resolves YAML imports and merges fields with granular override logic.
11
+ module YamlImportResolver
12
+ module_function
13
+
14
+ def resolve(file_path, project_path, visited = Set.new)
15
+ return {} unless File.exist?(file_path)
16
+
17
+ absolute_path = File.expand_path(file_path)
18
+ raise Strata::CircularImportError, "Circular import detected: #{format_cycle(visited, absolute_path)}" if visited.include?(absolute_path)
19
+
20
+ visited.add(absolute_path)
21
+ content = YAML.safe_load_file(file_path, permitted_classes: [Date, Time], aliases: true)
22
+ return {} unless content.is_a?(Hash)
23
+
24
+ resolved_content = content.dup
25
+ resolved_content.delete("imports")
26
+
27
+ if content["imports"]&.is_a?(Array)
28
+ merged_fields = resolve_imports(content["imports"], file_path, project_path, visited)
29
+ resolved_content["fields"] = merge_fields(merged_fields, content["fields"] || [])
30
+ else
31
+ resolved_content["fields"] = content["fields"] || []
32
+ end
33
+
34
+ resolved_content
35
+ end
36
+
37
+ private_class_method def resolve_imports(imports, base_file_path, project_path, visited)
38
+ base_dir = File.dirname(base_file_path)
39
+ all_imported_fields = []
40
+
41
+ imports.each do |import_path|
42
+ ImportManager.validate_import_path(import_path)
43
+
44
+ resolved_path = ImportManager.resolve_with_fallback(import_path, base_dir, project_path)
45
+ imported_content = resolve(resolved_path, project_path, visited.dup)
46
+ all_imported_fields.concat(imported_content["fields"] || []) if imported_content.is_a?(Hash)
47
+ end
48
+
49
+ all_imported_fields
50
+ end
51
+
52
+ private_class_method def merge_fields(imported_fields, local_fields)
53
+ field_map = {}
54
+ imported_fields.each do |field|
55
+ next unless field.is_a?(Hash) && field["name"]
56
+
57
+ field_map[field["name"]] = field.dup
58
+ end
59
+
60
+ local_fields.each do |field|
61
+ next unless field.is_a?(Hash) && field["name"]
62
+
63
+ field_name = field["name"]
64
+
65
+ if field_map[field_name]
66
+ merged_field = field.dup
67
+ field_map[field_name].each do |key, value|
68
+ next if key == "name" # name is always from local
69
+
70
+ if !merged_field.key?(key) || (merged_field[key].nil? || merged_field[key] == "")
71
+ merged_field[key] = value
72
+ end
73
+ end
74
+
75
+ field_map[field_name] = merged_field
76
+ else
77
+ field_map[field_name] = field.dup
78
+ end
79
+ end
80
+
81
+ field_map.values.sort_by { |f| f["name"] || "" }
82
+ end
83
+
84
+ private_class_method def format_cycle(visited, current)
85
+ cycle = visited.to_a + [current]
86
+ cycle.map { |path| File.basename(path) }.join(" -> ")
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,39 @@
1
+ module Strata
2
+ module CLI
3
+ module Utils
4
+ module_function
5
+
6
+ # Convert a given string to a Strata compliant
7
+ # slug or url friendly ID.
8
+ #
9
+ # @param str [String]
10
+ def url_safe_str(str)
11
+ str.downcase.strip.gsub(/\s+/, "-").gsub(/[^\w-]/, "")
12
+ end
13
+
14
+ def exit_error_if_not_strata!
15
+ return if CLI.config.strata_project?
16
+
17
+ Thor::Shell::Color.new.say_error "ERROR: This is not a valid strata project", :red
18
+ exit 1
19
+ end
20
+
21
+ # Match a YAML block given a key.
22
+ # It should match a block like so:
23
+ #
24
+ # key:
25
+ # attribute: value
26
+ # attr_2: val
27
+ #
28
+ # another_key: another_val
29
+ #
30
+ # This should match just the 'key' block
31
+ # above.
32
+ #
33
+ # @param key [String]
34
+ def yaml_block_matcher(key)
35
+ /^#{key}:.*?(?=^\S|\z)/m
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Strata
4
+ module CLI
5
+ VERSION = "0.1.0.beta"
6
+ end
7
+ end
data/lib/strata/cli.rb ADDED
@@ -0,0 +1,36 @@
1
+ require "thor"
2
+ require "dwh"
3
+ require_relative "cli/version"
4
+ require_relative "cli/configuration"
5
+ require_relative "cli/main"
6
+ require_relative "cli/utils"
7
+
8
+ module Strata
9
+ class StrataError < StandardError; end
10
+
11
+ class ConfigError < StrataError; end
12
+
13
+ class CommandError < StrataError; end
14
+
15
+ class ImportError < CommandError; end
16
+
17
+ class CircularImportError < ImportError; end
18
+
19
+ class MissingImportError < ImportError; end
20
+
21
+ class InvalidImportPathError < ImportError; end
22
+
23
+ module CLI
24
+ @configuration = Configuration.new
25
+ def self.config
26
+ @configuration
27
+ end
28
+
29
+ def self.start(args)
30
+ Main.start(args)
31
+ rescue TTY::Reader::InputInterrupt
32
+ warn "\nCancelled."
33
+ exit(1)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,6 @@
1
+ module Strata
2
+ module Cli
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end