belt 0.1.4 → 0.1.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d40f2f591f9daeb3ee7a04cebbb5399268bb181771ccaeddb036ca100159dcfd
4
- data.tar.gz: 77a31c0cf454675261c2103cfc66ba391d879a6fdd24b7f7df2a05fdf7a3313c
3
+ metadata.gz: 330331693d70f3a18ad04c47b4f0b07c69b6a75538291be891946e12c7863dfc
4
+ data.tar.gz: 643ed0cad19c1124581d86b7f816ce199d372be0415c03b53c8f9d5f990bc3a9
5
5
  SHA512:
6
- metadata.gz: b8e1a4a0bb6127fe6216f11f218b4ad9c835acf04b45b163c711ebec98dd9153016b8dc09136bfeeea1b34a6f83d45831e407aa5db831601872a5df1d303f0ad
7
- data.tar.gz: b8dc9e16e672b73b99afcae96d268c8ab8d7eac0fbd30b5a82bffcc6f34b6a4189b1c14af84b55d0d5d1458de088c91984a41ff9bd5e30b5264a9e24af3444f6
6
+ metadata.gz: ca9f5d87f6b8ab597106789e5a381aa3ba6f33b85cc2620552b8c9380ad5508d7f27d5ed34cea52b59858db91ab02653ee4a487044562f7c3c68739c3fa40779
7
+ data.tar.gz: 36cbc894c45482981d1c3ddd34f0b59dd7e5259dd5de975175af756c54488693e6b0396c44b81b396442149a70ff6067d8d40046cef749d7119d248f49d57990
@@ -155,6 +155,13 @@ module Belt
155
155
  end
156
156
  end
157
157
 
158
+ # Final fallback: gem-embedded controllers under Belt:: namespace
159
+ class_name = "#{controller_name.split(%r{[_/]}).map(&:capitalize).join}Controller"
160
+ belt_class_name = "Belt::#{class_name}"
161
+ if Belt.const_defined?(class_name, false)
162
+ return Belt.const_get(class_name, false)
163
+ end
164
+
158
165
  raise Belt::ActionNotFound, "Controller not found: #{controller_name}"
159
166
  end
160
167
 
Binary file
@@ -0,0 +1,112 @@
1
+ * { margin: 0; padding: 0; box-sizing: border-box; }
2
+ body {
3
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
4
+ background: #f8f9fa;
5
+ color: #2d3748;
6
+ min-height: 100vh;
7
+ }
8
+
9
+ /* Hero section — full width background image */
10
+ .hero {
11
+ position: relative;
12
+ width: 100%;
13
+ overflow: hidden;
14
+ }
15
+ .hero-bg {
16
+ width: 100%;
17
+ height: auto;
18
+ display: block;
19
+ }
20
+ .overlay {
21
+ position: absolute;
22
+ top: 35%;
23
+ left: 50%;
24
+ transform: translate(-50%, -50%);
25
+ padding: 2rem 3rem;
26
+ background: rgba(255, 255, 255, 0.88);
27
+ backdrop-filter: blur(6px);
28
+ -webkit-backdrop-filter: blur(6px);
29
+ border-radius: 12px;
30
+ text-align: center;
31
+ max-width: 600px;
32
+ width: 90%;
33
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
34
+ }
35
+ .overlay h1 {
36
+ font-size: 2.2rem;
37
+ font-weight: 700;
38
+ color: #2d3748;
39
+ margin-bottom: 0.4rem;
40
+ }
41
+ .overlay .subtitle {
42
+ font-size: 1.1rem;
43
+ color: #4a5568;
44
+ }
45
+
46
+ /* Content below the hero */
47
+ .container {
48
+ max-width: 700px;
49
+ margin: 0 auto;
50
+ padding: 2rem;
51
+ }
52
+ .stack-check {
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: center;
56
+ gap: 0.75rem;
57
+ margin-bottom: 2rem;
58
+ flex-wrap: wrap;
59
+ }
60
+ .check-item {
61
+ display: flex;
62
+ align-items: center;
63
+ gap: 0.4rem;
64
+ padding: 0.5rem 1rem;
65
+ border-radius: 8px;
66
+ font-weight: 500;
67
+ }
68
+ .check-pass {
69
+ background: rgba(39, 174, 96, 0.1);
70
+ border: 1px solid rgba(39, 174, 96, 0.3);
71
+ color: #1e8449;
72
+ }
73
+ .check-warn {
74
+ background: rgba(243, 156, 18, 0.1);
75
+ border: 1px solid rgba(243, 156, 18, 0.3);
76
+ color: #d68910;
77
+ }
78
+ .icon { font-size: 1.1rem; }
79
+ .arrow { color: #718096; font-size: 1.2rem; }
80
+ .info {
81
+ background: #ffffff;
82
+ border: 1px solid #e2e8f0;
83
+ border-radius: 12px;
84
+ padding: 1.5rem;
85
+ margin-bottom: 1.5rem;
86
+ }
87
+ .info p { margin-bottom: 0.5rem; color: #4a5568; }
88
+ .info p:last-child { margin-bottom: 0; }
89
+ .info strong { color: #2d3748; }
90
+ .info code {
91
+ background: rgba(99, 45, 145, 0.08);
92
+ padding: 0.15rem 0.4rem;
93
+ border-radius: 4px;
94
+ color: #632d91;
95
+ font-size: 0.85rem;
96
+ }
97
+ .next-steps {
98
+ background: #ffffff;
99
+ border: 1px solid #e2e8f0;
100
+ border-radius: 12px;
101
+ padding: 1.5rem;
102
+ }
103
+ .next-steps h2 { font-size: 1.1rem; margin-bottom: 1rem; color: #2d3748; }
104
+ .next-steps ol { padding-left: 1.5rem; }
105
+ .next-steps li { margin-bottom: 0.6rem; color: #4a5568; line-height: 1.5; }
106
+ .next-steps code {
107
+ background: rgba(99, 45, 145, 0.08);
108
+ padding: 0.15rem 0.4rem;
109
+ border-radius: 4px;
110
+ color: #632d91;
111
+ font-size: 0.85rem;
112
+ }
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'env_resolver'
4
+ require_relative 'terraform_command'
5
+
6
+ module Belt
7
+ module CLI
8
+ class DeployCommand
9
+ def self.run(args)
10
+ # Handle `belt deploy frontend <env>` as before
11
+ if args.first == 'frontend'
12
+ args.shift
13
+ Belt::CLI::FrontendDeployCommand.run(args)
14
+ return
15
+ end
16
+
17
+ auto_approve = false
18
+ filtered_args = []
19
+
20
+ args.each do |arg|
21
+ case arg
22
+ when '--auto', '--yes', '-y'
23
+ auto_approve = true
24
+ when '-h', '--help'
25
+ puts help_text
26
+ exit 0
27
+ else
28
+ filtered_args << arg
29
+ end
30
+ end
31
+
32
+ env = EnvResolver.resolve(filtered_args)
33
+
34
+ # Default to the first available environment when none specified
35
+ if env.nil?
36
+ available = TerraformCommand.list_environments
37
+ if available.any?
38
+ env = available.first
39
+ else
40
+ puts 'Usage: belt deploy [environment] [options]'
41
+ puts "\nDefaults to BELT_ENV or the first available environment."
42
+ puts "\nOptions:"
43
+ puts ' --auto, --yes, -y Skip confirmation prompt (auto-approve)'
44
+ puts ' -h, --help Show this help'
45
+ puts "\nExamples:"
46
+ puts ' belt deploy # Deploy dev (or BELT_ENV)'
47
+ puts ' belt deploy prod # Deploy to prod'
48
+ puts ' belt deploy dev --auto # Deploy without confirmation'
49
+ puts ' belt deploy frontend dev # Deploy frontend only'
50
+ puts "\nNo environments found. Run `belt generate environment dev` first."
51
+ exit 1
52
+ end
53
+ end
54
+
55
+ new(env, auto_approve: auto_approve, extra_args: filtered_args).run
56
+ end
57
+
58
+ def self.help_text
59
+ <<~HELP
60
+ Deploy your Belt application to AWS.
61
+
62
+ Usage: belt deploy [environment] [options]
63
+ belt deploy frontend <environment>
64
+
65
+ This runs the full deployment lifecycle:
66
+ 1. terraform init (initialize providers/modules)
67
+ 2. terraform plan (preview changes)
68
+ 3. Prompt for confirmation (unless --auto)
69
+ 4. terraform apply (deploy changes)
70
+
71
+ Options:
72
+ --auto, --yes, -y Skip confirmation prompt (auto-approve)
73
+ -h, --help Show this help
74
+
75
+ Environment:
76
+ Defaults to BELT_ENV if set, otherwise requires an argument.
77
+
78
+ Examples:
79
+ belt deploy # Deploy dev (or BELT_ENV)
80
+ belt deploy prod # Deploy to prod
81
+ belt deploy dev --auto # Deploy without confirmation (CI mode)
82
+ belt deploy frontend dev # Deploy frontend assets only
83
+ HELP
84
+ end
85
+
86
+ def initialize(env, auto_approve: false, extra_args: [])
87
+ @env = env
88
+ @auto_approve = auto_approve
89
+ @extra_args = extra_args
90
+ @infra_dir = TerraformCommand.find_infrastructure_dir
91
+ end
92
+
93
+ def run
94
+ validate!
95
+ env_dir = File.join(@infra_dir, @env)
96
+
97
+ puts "belt → deploying #{@env} (in #{env_dir}/)\n\n"
98
+
99
+ Dir.chdir(env_dir) do
100
+ run_init
101
+ run_plan
102
+ return unless confirm_apply
103
+ run_apply
104
+ end
105
+
106
+ puts "\n✅ Deployed #{@env} successfully!"
107
+ print_outputs(env_dir)
108
+ puts "\n Run `belt server` to view your app locally (auto-connects to the deployed API)."
109
+ end
110
+
111
+ private
112
+
113
+ def validate!
114
+ unless @infra_dir
115
+ abort "Error: No infrastructure/ directory found.\n" \
116
+ "Run `belt generate environment #{@env}` first."
117
+ end
118
+
119
+ env_dir = File.join(@infra_dir, @env)
120
+ return if Dir.exist?(env_dir)
121
+
122
+ abort "Error: Environment '#{@env}' not found at #{env_dir}/.\n\n" \
123
+ "Available environments:\n#{TerraformCommand.list_environments.map { |e| " #{e}" }.join("\n")}\n\n" \
124
+ "Create it with: belt generate environment #{@env}"
125
+ end
126
+
127
+ def run_init
128
+ puts '━━━ terraform init ━━━'
129
+ success = system('terraform', 'init')
130
+ abort "\n✗ terraform init failed" unless success
131
+ puts ''
132
+ end
133
+
134
+ def run_plan
135
+ puts '━━━ terraform plan ━━━'
136
+ success = system('terraform', 'plan', '-out=tfplan', *@extra_args)
137
+ abort "\n✗ terraform plan failed" unless success
138
+ puts ''
139
+ end
140
+
141
+ def confirm_apply
142
+ return true if @auto_approve
143
+
144
+ if @env == 'prod' || @env == 'production'
145
+ print "⚠️ You are about to deploy to \e[1;31m#{@env}\e[0m. Continue? [y/N] "
146
+ else
147
+ print "Apply these changes to #{@env}? [y/N] "
148
+ end
149
+
150
+ response = $stdin.gets
151
+ return true if response&.strip&.downcase&.start_with?('y')
152
+
153
+ puts 'Cancelled.'
154
+ cleanup_plan
155
+ false
156
+ end
157
+
158
+ def run_apply
159
+ puts '━━━ terraform apply ━━━'
160
+ success = system('terraform', 'apply', 'tfplan')
161
+ cleanup_plan
162
+ abort "\n✗ terraform apply failed" unless success
163
+ end
164
+
165
+ def cleanup_plan
166
+ File.delete('tfplan') if File.exist?('tfplan')
167
+ end
168
+
169
+ def print_outputs(env_dir)
170
+ Dir.chdir(env_dir) do
171
+ output = `terraform output 2>/dev/null`.strip
172
+ return if output.empty?
173
+
174
+ puts "\nOutputs:"
175
+ output.each_line do |line|
176
+ puts " #{line}"
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -26,9 +26,10 @@ module Belt
26
26
  new(env_name).generate
27
27
  end
28
28
 
29
- def initialize(env_name)
29
+ def initialize(env_name, quiet: false)
30
30
  @env_name = env_name.downcase.gsub(/[^a-z0-9_-]/, '')
31
31
  @app_name = detect_app_name
32
+ @quiet = quiet
32
33
  end
33
34
 
34
35
  def generate
@@ -49,11 +50,14 @@ module Belt
49
50
  end
50
51
 
51
52
  puts "\n✓ Environment '#{@env_name}' created!"
52
- puts "\nNext steps:"
53
- puts " cd #{dest_dir}"
54
- puts ' terraform init'
55
- puts ' terraform plan'
56
- puts ' terraform apply'
53
+
54
+ unless @quiet
55
+ puts "\nNext steps:"
56
+ puts " cd #{dest_dir}"
57
+ puts ' terraform init'
58
+ puts ' terraform plan'
59
+ puts ' terraform apply'
60
+ end
57
61
  end
58
62
 
59
63
  private
@@ -63,7 +67,8 @@ module Belt
63
67
  'main.tf.erb' => 'main.tf',
64
68
  'backend.tf.erb' => 'backend.tf',
65
69
  'variables.tf.erb' => 'variables.tf',
66
- 'terraform.tfvars.erb' => 'terraform.tfvars'
70
+ 'terraform.tfvars.erb' => 'terraform.tfvars',
71
+ 'outputs.tf.erb' => 'outputs.tf'
67
72
  }
68
73
  end
69
74
 
@@ -15,19 +15,79 @@ module Belt
15
15
 
16
16
  include AppDetection
17
17
 
18
+ FIELD_TYPES = %w[string text integer float boolean date datetime].freeze
19
+
20
+ GENERATOR_HELP = {
21
+ 'resource' => {
22
+ description: 'Generate a model, controller, routes, and schema for a REST resource.',
23
+ usage: 'belt generate resource <name> [field:type ...] [options]',
24
+ options: [
25
+ ['--skip-views', 'Skip generating frontend view pages']
26
+ ],
27
+ examples: [
28
+ ['belt g resource post title:string body:text', 'Post with title and body fields'],
29
+ ['belt g resource comment author:string body:text status:string', 'Comment with multiple fields'],
30
+ ['belt g resource task --skip-views', 'Task resource without frontend pages']
31
+ ],
32
+ notes: <<~NOTES
33
+ Creates:
34
+ lambda/models/<name>.rb Model with validations and fields
35
+ lambda/controllers/<app>/<names>_controller.rb RESTful controller (index, show, create, update, destroy)
36
+ infrastructure/routes.tf.rb Route entry added
37
+ infrastructure/schema.tf.rb DynamoDB table schema added
38
+ lambda/lib/routes/<app>_routes.rb Route manifest updated
39
+ NOTES
40
+ },
41
+ 'model' => {
42
+ description: 'Generate an ActiveItem model with validations and DynamoDB field definitions.',
43
+ usage: 'belt generate model <name> [field:type ...]',
44
+ options: [],
45
+ examples: [
46
+ ['belt g model user email:string name:string', 'User model with email and name'],
47
+ ['belt g model event title:string starts_at:datetime', 'Event model with datetime field']
48
+ ],
49
+ notes: <<~NOTES
50
+ Creates:
51
+ lambda/models/<name>.rb Model class inheriting from ActiveItem::Base
52
+ NOTES
53
+ },
54
+ 'controller' => {
55
+ description: 'Generate a controller with standard REST actions.',
56
+ usage: 'belt generate controller <name>',
57
+ options: [],
58
+ examples: [
59
+ ['belt g controller posts', 'Posts controller with CRUD actions'],
60
+ ['belt g controller admin/users', 'Namespaced controller']
61
+ ],
62
+ notes: <<~NOTES
63
+ Creates:
64
+ lambda/controllers/<app>/<name>_controller.rb Controller class
65
+ NOTES
66
+ }
67
+ }.freeze
68
+
18
69
  def self.run(args)
19
70
  generator = args.shift
20
71
 
21
- if generator.nil? || !GENERATORS.include?(generator)
22
- puts "Usage: belt generate <#{GENERATORS.join('|')}> <name> [field:type ...]"
23
- puts "\nExamples:"
24
- puts ' belt generate resource post title:string content:text status:string'
25
- puts ' belt generate model comment body:text author:string'
26
- puts ' belt generate controller comments'
27
- puts ' belt generate environment dev01'
72
+ # Top-level help: belt generate --help or belt generate (no args)
73
+ if generator.nil? || generator =~ /\A-/
74
+ print_generate_help
75
+ exit 0
76
+ end
77
+
78
+ unless GENERATORS.include?(generator)
79
+ puts "Unknown generator: '#{generator}'"
80
+ puts "Available generators: #{GENERATORS.join(', ')}"
81
+ puts "\nRun 'belt generate --help' for usage information."
28
82
  exit 1
29
83
  end
30
84
 
85
+ # Per-generator help: belt g resource --help
86
+ if args.include?('--help') || args.include?('-h')
87
+ print_generator_help(generator)
88
+ exit 0
89
+ end
90
+
31
91
  return Belt::CLI::EnvironmentCommand.run(args) if generator == 'environment'
32
92
 
33
93
  return Belt::CLI::FrontendCommand.run(args) if generator == 'frontend'
@@ -36,10 +96,12 @@ module Belt
36
96
 
37
97
  name = args.shift
38
98
  if name.nil? || name.empty?
39
- puts "Usage: belt generate #{generator} <name> [field:type ...]"
40
- exit 1
99
+ print_generator_help(generator)
100
+ exit 0
41
101
  end
42
102
 
103
+ validate_resource_name!(name, generator)
104
+
43
105
  skip_views = args.delete('--skip-views')
44
106
  fields = args.map { |arg| parse_field(arg) }
45
107
  new(generator, name, fields, skip_views: skip_views).generate
@@ -50,6 +112,99 @@ module Belt
50
112
  { name: name, type: type || 'string' }
51
113
  end
52
114
 
115
+ RESERVED_NAMES = %w[
116
+ help new edit create update destroy index show delete remove
117
+ application base controller model resource generate
118
+ belt setup environment frontend views routes
119
+ ].freeze
120
+
121
+ def self.validate_resource_name!(name, generator)
122
+ errors = []
123
+
124
+ errors << 'must start with a letter' unless name.match?(/\A[a-zA-Z]/)
125
+ errors << 'must be at least 2 characters' if name.length < 2
126
+ errors << 'must only contain letters, numbers, and underscores' unless name.match?(/\A[a-zA-Z][a-zA-Z0-9_]*\z/)
127
+ errors << 'must not start or end with an underscore' if name.match?(/\A_|_\z/)
128
+ errors << 'must not contain consecutive underscores' if name.include?('__')
129
+ errors << "is a reserved name" if RESERVED_NAMES.include?(name.downcase.chomp('s'))
130
+ errors << "is a reserved name" if RESERVED_NAMES.include?(name.downcase)
131
+
132
+ return if errors.empty?
133
+
134
+ puts "\n✗ Invalid #{generator} name '#{name}':"
135
+ errors.uniq.each { |e| puts " - #{e}" }
136
+ puts "\nName must start with a letter, be at least 2 characters, and contain only letters, numbers, and single underscores."
137
+ exit 1
138
+ end
139
+
140
+ def self.print_generate_help
141
+ puts <<~HELP
142
+ Usage: belt generate <generator> <name> [field:type ...] [options]
143
+ belt g <generator> <name> [field:type ...] [options]
144
+
145
+ Generators:
146
+ resource Generate model, controller, routes, and schema (full REST resource)
147
+ model Generate an ActiveItem model
148
+ controller Generate a controller
149
+ environment Create a new deployment environment
150
+ frontend Scaffold a frontend app (react, vue, svelte)
151
+ views Generate React pages for a resource
152
+
153
+ Field Types:
154
+ #{FIELD_TYPES.join(', ')}
155
+
156
+ Examples:
157
+ belt g resource post title:string body:text status:string
158
+ belt g model user email:string name:string
159
+ belt g controller comments
160
+ belt g environment staging
161
+ belt g frontend react
162
+ belt g views post title:string body:text
163
+
164
+ Run 'belt generate <generator> --help' for detailed help on a specific generator.
165
+ HELP
166
+ end
167
+
168
+ def self.print_generator_help(generator)
169
+ info = GENERATOR_HELP[generator]
170
+
171
+ unless info
172
+ puts "Usage: belt generate #{generator} <name>"
173
+ puts "\nRun 'belt generate #{generator} --help' is not yet documented."
174
+ puts "Try 'belt generate --help' for the full list of generators."
175
+ return
176
+ end
177
+
178
+ puts info[:description]
179
+ puts
180
+ puts "Usage: #{info[:usage]}"
181
+
182
+ if info[:options].any?
183
+ puts "\nOptions:"
184
+ info[:options].each do |flag, desc|
185
+ puts " %-20s %s" % [flag, desc]
186
+ end
187
+ end
188
+
189
+ puts "\nField Types:"
190
+ puts " #{FIELD_TYPES.join(', ')}"
191
+
192
+ puts "\nExamples:"
193
+ info[:examples].each do |cmd, desc|
194
+ puts " #{cmd}"
195
+ puts " #{desc}" if desc
196
+ end
197
+
198
+ puts "\n#{info[:notes]}" if info[:notes]
199
+
200
+ puts "Naming Rules:"
201
+ puts " - Must start with a letter"
202
+ puts " - At least 2 characters"
203
+ puts " - Letters, numbers, and single underscores only"
204
+ puts " - No leading/trailing/consecutive underscores"
205
+ puts " - Cannot use reserved names (help, new, create, destroy, etc.)"
206
+ end
207
+
53
208
  def initialize(generator, name, fields, skip_views: false)
54
209
  @generator = generator
55
210
  @name = name.downcase.gsub(/[^a-z0-9_]/, '_')