belt 0.1.6 → 0.1.8

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: b09432f7426352033b67c6cc696bc90cddcae4b1facae08ce7b6fc08763b7eef
4
- data.tar.gz: bb4617d6514bef6636b5b2efcfd32eaee6ebbc66b09a7e7f83581340c0fe5714
3
+ metadata.gz: ebbefe3f44abda51ac10b219cda18678ca834d06d8eab39e31beb3e48e99b25a
4
+ data.tar.gz: 3233b91c7297982ef7f5037331ef003c3637ebc6dccf4321b4a5f758ed57f1d9
5
5
  SHA512:
6
- metadata.gz: 2c67896f36d6b09e394e19a73a92a6c8e31e9ec43071bf92c905f7d78ba260766d5b46aa2ee9d6c4d665c3313679557df527b866089bad240693fcdd1ce89e91
7
- data.tar.gz: 8cf8956ea9a6227237be1dccc0cfe15b96ac9e75c3ddae543873cb067fcc5b8648fa23a1a258ccd3470afb9097ba54a7bc2dedfadcc701294b6ff8d9449ac755
6
+ metadata.gz: af38bf5330f69577f37ab34a0bd7510b5d84aa14969efd8975ac6875ea85450aaa741beeb71bf3f51f627af11c0c5445806081156706cdfd4901fba4301e33ed
7
+ data.tar.gz: 2eade1680ebfe76e78ab38e38d273565cf34eb1f1789427d358706ac2c4c128a5f86448a5eb2a146161be8c912bf53f9229fceb0904719c9df5ef3444dc3481e
@@ -28,6 +28,12 @@ module Belt
28
28
  File.basename(Dir.pwd)
29
29
  end
30
30
 
31
+ # Detects existing environments by scanning infrastructure/ directories.
32
+ # Each subdirectory with a main.tf is considered an environment.
33
+ def detect_environments
34
+ Dir.glob('infrastructure/*/main.tf').map { |f| File.basename(File.dirname(f)) }.sort
35
+ end
36
+
31
37
  # S3 bucket names follow DNS rules — underscores are not allowed.
32
38
  # App names often use underscores (Ruby namespaces); convert for S3.
33
39
  def s3_safe_name(name)
@@ -127,6 +127,9 @@ module Belt
127
127
 
128
128
  puts "\n✅ Deployed #{@env} successfully!"
129
129
  print_outputs(env_dir)
130
+
131
+ deploy_frontend_if_exists
132
+
130
133
  puts "\n Run `belt server` to view your app locally (auto-connects to the deployed API)."
131
134
  end
132
135
 
@@ -479,6 +482,18 @@ module Belt
479
482
  File.delete('tfplan') if File.exist?('tfplan')
480
483
  end
481
484
 
485
+ def deploy_frontend_if_exists
486
+ frontend_dir = File.join(@project_root, 'frontend')
487
+ return unless Dir.exist?(frontend_dir) && File.exist?(File.join(frontend_dir, 'package.json'))
488
+
489
+ puts "\n━━━ frontend deploy ━━━"
490
+ require_relative 'frontend_deploy_command'
491
+ Belt::CLI::FrontendDeployCommand.new(@env).run
492
+ rescue StandardError => e
493
+ puts "\n ⚠ Frontend deploy failed: #{e.message}"
494
+ puts " Run `belt deploy frontend #{@env}` manually to retry."
495
+ end
496
+
482
497
  def print_outputs(env_dir)
483
498
  Dir.chdir(env_dir) do
484
499
  output = `terraform output 2>/dev/null`.strip
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require_relative 'app_detection'
5
+ require_relative 'tables_command'
6
+ require_relative '../inflector'
7
+
8
+ module Belt
9
+ module CLI
10
+ class DestroyCommand
11
+ GENERATORS = %w[scaffold resource model controller environment frontend views].freeze
12
+
13
+ include AppDetection
14
+
15
+ def self.run(args)
16
+ generator = args.shift
17
+
18
+ if generator.nil? || generator =~ /\A-/
19
+ print_help
20
+ exit 0
21
+ end
22
+
23
+ unless GENERATORS.include?(generator)
24
+ puts "Unknown generator: '#{generator}'"
25
+ puts "Available: #{GENERATORS.uniq.join(', ')}"
26
+ puts "\nRun 'belt destroy --help' for usage information."
27
+ exit 1
28
+ end
29
+
30
+ # Normalize: resource is an alias for scaffold
31
+ generator = 'scaffold' if generator == 'resource'
32
+
33
+ case generator
34
+ when 'environment'
35
+ name = args.shift
36
+ if name.nil? || name.empty?
37
+ puts 'Usage: belt destroy environment <name>'
38
+ exit 1
39
+ end
40
+ new(generator, name, []).destroy
41
+ when 'frontend'
42
+ new(generator, nil, []).destroy
43
+ when 'views'
44
+ name = args.shift
45
+ if name.nil? || name.empty?
46
+ puts 'Usage: belt destroy views <name>'
47
+ exit 1
48
+ end
49
+ new(generator, name, args.map { |a| parse_field(a) }).destroy
50
+ else
51
+ name = args.shift
52
+ if name.nil? || name.empty?
53
+ puts "Usage: belt destroy #{generator} <name>"
54
+ exit 1
55
+ end
56
+ new(generator, name, args.map { |a| parse_field(a) }).destroy
57
+ end
58
+ end
59
+
60
+ def self.parse_field(arg)
61
+ name, type = arg.split(':', 2)
62
+ { name: name, type: type || 'string' }
63
+ end
64
+
65
+ def self.print_help
66
+ puts <<~HELP
67
+ Usage: belt destroy <generator> <name> [field:type ...]
68
+ belt d <generator> <name> [field:type ...]
69
+
70
+ Removes files and references created by `belt generate`.
71
+
72
+ Generators:
73
+ scaffold Remove model, controller, routes, schema, and views
74
+ resource Alias for scaffold
75
+ model Remove an ActiveItem model
76
+ controller Remove a controller
77
+ environment Remove a deployment environment directory
78
+ frontend Remove the frontend/ directory
79
+ views Remove React pages for a resource
80
+
81
+ Examples:
82
+ belt d scaffold post title:string body:text status:string
83
+ belt d model user
84
+ belt d controller comments
85
+ belt d environment staging
86
+ belt d frontend
87
+ belt d views post
88
+
89
+ ⚠ This is destructive. Files will be permanently deleted.
90
+ HELP
91
+ end
92
+
93
+ def initialize(generator, name, fields)
94
+ @generator = generator
95
+ @name = name&.downcase&.gsub(/[^a-z0-9_]/, '_')
96
+ @fields = fields
97
+ @app_name = detect_namespace
98
+ @singular_name = @name ? Belt::Inflector.singularize(@name) : nil
99
+ @resource_name = @singular_name ? Belt::Inflector.pluralize(@singular_name) : nil
100
+ @class_name = @singular_name ? Belt::Inflector.classify(@singular_name) : nil
101
+ @removed = []
102
+ @updated = []
103
+ end
104
+
105
+ def destroy
106
+ case @generator
107
+ when 'scaffold' then destroy_scaffold
108
+ when 'model' then destroy_model
109
+ when 'controller' then destroy_controller
110
+ when 'environment' then destroy_environment
111
+ when 'frontend' then destroy_frontend
112
+ when 'views' then destroy_views
113
+ end
114
+
115
+ print_summary
116
+ end
117
+
118
+ private
119
+
120
+ def destroy_scaffold
121
+ destroy_model
122
+ destroy_controller
123
+ remove_routes
124
+ remove_schema
125
+ sync_tables
126
+ destroy_views
127
+ puts "\n✓ Scaffold '#{@singular_name}' destroyed!"
128
+ end
129
+
130
+ def destroy_model
131
+ path = "lambda/models/#{@singular_name}.rb"
132
+ remove_file(path)
133
+ end
134
+
135
+ def destroy_controller
136
+ path = "lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
137
+ remove_file(path)
138
+ end
139
+
140
+ def destroy_environment
141
+ dir = "infrastructure/#{@name}"
142
+ if Dir.exist?(dir)
143
+ FileUtils.rm_rf(dir)
144
+ @removed << dir
145
+ puts " remove #{dir}/"
146
+ puts "\n✓ Environment '#{@name}' destroyed!"
147
+ else
148
+ puts "✗ Environment '#{@name}' not found at #{dir}/"
149
+ exit 1
150
+ end
151
+ end
152
+
153
+ def destroy_frontend
154
+ dir = 'frontend'
155
+ if Dir.exist?(dir)
156
+ FileUtils.rm_rf(dir)
157
+ @removed << dir
158
+ puts " remove #{dir}/"
159
+
160
+ # Remove frontend.tf and revert frontend_urls in main.tf for each environment
161
+ Dir.glob('infrastructure/*/frontend.tf').each do |frontend_tf|
162
+ FileUtils.rm_f(frontend_tf)
163
+ puts " remove #{frontend_tf}"
164
+
165
+ env_dir = File.dirname(frontend_tf)
166
+ main_tf = File.join(env_dir, 'main.tf')
167
+ revert_main_tf_frontend_urls(main_tf)
168
+ end
169
+
170
+ puts "\n✓ Frontend removed!"
171
+ else
172
+ puts '✗ No frontend/ directory found.'
173
+ exit 1
174
+ end
175
+ end
176
+
177
+ def revert_main_tf_frontend_urls(main_tf)
178
+ return unless File.exist?(main_tf)
179
+
180
+ content = File.read(main_tf)
181
+ return unless content.include?('aws_cloudfront_distribution.frontend.domain_name')
182
+
183
+ # Replace the multi-line concat block back to a simple conditional
184
+ replaced = content.sub(
185
+ /^\s*frontend_urls\s*=\s*concat\(\n(?:.*\n)*?\s*\)\s*\n/,
186
+ " frontend_urls = var.environment == \"prod\" ? [] : [\"http://localhost:3000\"]\n"
187
+ )
188
+
189
+ if replaced != content
190
+ File.write(main_tf, replaced)
191
+ puts " update #{main_tf} (reverted frontend_urls)"
192
+ end
193
+ end
194
+
195
+ def destroy_views
196
+ return unless @resource_name
197
+
198
+ pages_dir = "frontend/src/pages/#{@resource_name}"
199
+
200
+ if Dir.exist?(pages_dir)
201
+ FileUtils.rm_rf(pages_dir)
202
+ @removed << pages_dir
203
+ puts " remove #{pages_dir}/"
204
+ end
205
+
206
+ remove_view_routes
207
+ end
208
+
209
+ def remove_file(path)
210
+ if File.exist?(path)
211
+ File.delete(path)
212
+ @removed << path
213
+ puts " remove #{path}"
214
+
215
+ # Clean up empty parent directories
216
+ dir = File.dirname(path)
217
+ while dir != '.' && Dir.exist?(dir) && Dir.empty?(dir)
218
+ Dir.rmdir(dir)
219
+ puts " remove #{dir}/"
220
+ dir = File.dirname(dir)
221
+ end
222
+ else
223
+ puts " skip #{path} (not found)"
224
+ end
225
+ end
226
+
227
+ def remove_routes
228
+ routes_file = 'infrastructure/routes.tf.rb'
229
+ return unless File.exist?(routes_file)
230
+
231
+ content = File.read(routes_file)
232
+ original = content.dup
233
+
234
+ # Remove "resources :posts" or "resources :posts, tables: [...]" line
235
+ content.gsub!(/^\s*resources :#{@resource_name}.*\n/, '')
236
+
237
+ if content != original
238
+ File.write(routes_file, content)
239
+ @updated << routes_file
240
+ puts " update #{routes_file}"
241
+ end
242
+
243
+ # Update route manifest
244
+ remove_route_manifest_entries
245
+ end
246
+
247
+ def remove_route_manifest_entries
248
+ manifest_file = "lambda/lib/routes/#{@app_name}_routes.rb"
249
+ return unless File.exist?(manifest_file)
250
+
251
+ content = File.read(manifest_file)
252
+ original = content.dup
253
+
254
+ # Remove route entries for this controller
255
+ content.gsub!(/^\s*\{ verb: .+?controller: '#{@resource_name}'.+?\},?\n/, '')
256
+
257
+ # Clean up trailing commas and empty arrays
258
+ content.gsub!(/,(\s*\n\s*\]\.freeze)/, '\1')
259
+
260
+ if content != original
261
+ File.write(manifest_file, content)
262
+ @updated << manifest_file
263
+ puts " update #{manifest_file}"
264
+ end
265
+ end
266
+
267
+ def remove_schema
268
+ schema_file = 'infrastructure/schema.tf.rb'
269
+ return unless File.exist?(schema_file)
270
+
271
+ content = File.read(schema_file)
272
+ original = content.dup
273
+
274
+ # Remove the model block for this resource
275
+ content.gsub!(/\n?\s*model :#{@singular_name} do\n.*?\n\s*end\n?/m, "\n")
276
+
277
+ # Clean up excessive blank lines
278
+ content.gsub!(/\n{3,}/, "\n\n")
279
+
280
+ if content != original
281
+ File.write(schema_file, content)
282
+ @updated << schema_file
283
+ puts " update #{schema_file}"
284
+ end
285
+ end
286
+
287
+ def sync_tables
288
+ Belt::CLI::TablesCommand.sync_all_environments
289
+ end
290
+
291
+ def remove_view_routes
292
+ app_jsx = 'frontend/src/App.jsx'
293
+ return unless File.exist?(app_jsx)
294
+
295
+ content = File.read(app_jsx)
296
+ original = content.dup
297
+
298
+ # Remove import lines for this resource's pages
299
+ content.gsub!(/^import #{@class_name}\w* from '.\/pages\/#{@resource_name}\/.*'\n/, '')
300
+
301
+ # Remove Route elements for this resource
302
+ content.gsub!(/^\s*<Route path="\/#{@resource_name}.*?\/>\n/, '')
303
+
304
+ if content != original
305
+ File.write(app_jsx, content)
306
+ @updated << app_jsx
307
+ puts " update #{app_jsx}"
308
+ end
309
+ end
310
+
311
+ def print_summary
312
+ return if @removed.empty? && @updated.empty?
313
+
314
+ puts "\nSummary:"
315
+ puts " Removed: #{@removed.length} file(s)/dir(s)" unless @removed.empty?
316
+ puts " Updated: #{@updated.length} file(s)" unless @updated.empty?
317
+ end
318
+ end
319
+ end
320
+ end
@@ -51,6 +51,8 @@ module Belt
51
51
 
52
52
  puts "\n✓ Environment '#{@env_name}' created!"
53
53
 
54
+ setup_frontend_if_exists
55
+
54
56
  unless @quiet
55
57
  puts "\nNext steps:"
56
58
  puts " cd #{dest_dir}"
@@ -62,6 +64,15 @@ module Belt
62
64
 
63
65
  private
64
66
 
67
+ def setup_frontend_if_exists
68
+ return unless Dir.exist?('frontend') && !Dir.empty?('frontend')
69
+
70
+ puts "\n Frontend detected — generating frontend infrastructure..."
71
+ require_relative 'frontend_setup_command'
72
+ FrontendSetupCommand.new(@env_name, quiet: true).run
73
+ puts " create infrastructure/#{@env_name}/frontend.tf"
74
+ end
75
+
65
76
  def templates
66
77
  {
67
78
  'main.tf.erb' => 'main.tf',
@@ -53,14 +53,46 @@ module Belt
53
53
  copy_template(framework_dir, dest_dir)
54
54
 
55
55
  puts "\n✓ Frontend (#{@framework}) created in frontend/"
56
+
57
+ install_dependencies(dest_dir)
58
+ setup_frontend_infra_for_existing_environments
59
+
56
60
  puts "\nNext steps:"
57
- puts ' cd frontend && npm install && npm run dev'
58
- puts ' belt setup frontend <env> # Generate CloudFront + S3 infrastructure'
59
- puts ' belt deploy frontend <env> # Build and deploy to AWS'
61
+ puts ' belt server # Start local dev server'
62
+ puts ' belt deploy # Deploy everything to AWS'
60
63
  end
61
64
 
62
65
  private
63
66
 
67
+ def install_dependencies(dest_dir)
68
+ puts "\n Installing npm dependencies..."
69
+ success = system('npm', 'install', '--prefix', dest_dir, '--loglevel', 'error')
70
+ if success
71
+ puts " ✓ Dependencies installed"
72
+ else
73
+ puts " ⚠ npm install failed — run `cd #{dest_dir} && npm install` manually"
74
+ end
75
+ end
76
+
77
+ def setup_frontend_infra_for_existing_environments
78
+ environments = detect_environments
79
+ return if environments.empty?
80
+
81
+ puts "\n Setting up frontend infrastructure for existing environments..."
82
+ require_relative 'frontend_setup_command'
83
+
84
+ environments.each do |env_name|
85
+ frontend_tf = "infrastructure/#{env_name}/frontend.tf"
86
+ if File.exist?(frontend_tf)
87
+ puts " skip #{frontend_tf} (already exists)"
88
+ next
89
+ end
90
+
91
+ FrontendSetupCommand.new(env_name, quiet: true).run
92
+ puts " create #{frontend_tf}"
93
+ end
94
+ end
95
+
64
96
  def copy_template(src_dir, dest_dir)
65
97
  Dir.glob("#{src_dir}/**/*", File::FNM_DOTMATCH).each do |src|
66
98
  next if File.directory?(src)
@@ -29,15 +29,19 @@ module Belt
29
29
  new(env).run
30
30
  end
31
31
 
32
- def initialize(env)
32
+ def initialize(env, quiet: false)
33
33
  @env = env
34
34
  @app_name = detect_app_name
35
35
  @env_dir = "infrastructure/#{@env}"
36
+ @quiet = quiet
36
37
  end
37
38
 
38
39
  def run
39
40
  validate!
40
41
  generate_frontend_tf
42
+ update_main_tf_frontend_urls
43
+ return if @quiet
44
+
41
45
  puts "\n✓ Frontend infrastructure generated for '#{@env}'!"
42
46
  puts "\nRun `belt apply #{@env}` to create the S3 bucket and CloudFront distribution."
43
47
  puts "Then `belt deploy frontend #{@env}` to build and deploy."
@@ -57,7 +61,36 @@ module Belt
57
61
  template_path = File.join(TEMPLATE_DIR, 'frontend.tf.erb')
58
62
  content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
59
63
  File.write(dest, content)
60
- puts " create #{dest}"
64
+ puts " create #{dest}" unless @quiet
65
+ end
66
+
67
+ def update_main_tf_frontend_urls
68
+ main_tf = File.join(@env_dir, 'main.tf')
69
+ return unless File.exist?(main_tf)
70
+
71
+ content = File.read(main_tf)
72
+ cloudfront_url = '"https://${aws_cloudfront_distribution.frontend.domain_name}"'
73
+
74
+ # Already patched — skip
75
+ return if content.include?('aws_cloudfront_distribution.frontend.domain_name')
76
+
77
+ # Match any frontend_urls line (hardcoded list or conditional expression)
78
+ new_frontend_urls = <<~HCL.chomp
79
+ frontend_urls = concat(
80
+ [#{cloudfront_url}],
81
+ var.environment == "prod" ? [] : ["http://localhost:3000"]
82
+ )
83
+ HCL
84
+
85
+ replaced = content.sub(
86
+ /^(\s*)frontend_urls\s*=\s*.+$/,
87
+ new_frontend_urls
88
+ )
89
+
90
+ if replaced != content
91
+ File.write(main_tf, replaced)
92
+ puts " update #{main_tf} (added CloudFront URL to frontend_urls)" unless @quiet
93
+ end
61
94
  end
62
95
  end
63
96
  end