belt 0.0.7 → 0.1.1

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 (66) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +29 -1
  4. data/README.md +150 -51
  5. data/exe/belt +6 -0
  6. data/lib/belt/action_router.rb +7 -1
  7. data/lib/belt/cli/app_detection.rb +16 -0
  8. data/lib/belt/cli/bucket_security.rb +122 -0
  9. data/lib/belt/cli/env_resolver.rb +15 -0
  10. data/lib/belt/cli/environment_command.rb +77 -0
  11. data/lib/belt/cli/frontend_command.rb +85 -0
  12. data/lib/belt/cli/frontend_deploy_command.rb +125 -0
  13. data/lib/belt/cli/frontend_setup_command.rb +64 -0
  14. data/lib/belt/cli/generate_command.rb +206 -0
  15. data/lib/belt/cli/new_command.rb +126 -0
  16. data/lib/belt/cli/routes_command/route_inference.rb +100 -0
  17. data/lib/belt/cli/routes_command/schema_loader.rb +71 -0
  18. data/lib/belt/cli/routes_command.rb +307 -0
  19. data/lib/belt/cli/setup_command.rb +261 -0
  20. data/lib/belt/cli/tables_command.rb +138 -0
  21. data/lib/belt/cli/tasks_command.rb +110 -0
  22. data/lib/belt/cli/terraform_command.rb +77 -0
  23. data/lib/belt/cli/views_command.rb +134 -0
  24. data/lib/belt/cli.rb +117 -0
  25. data/lib/belt/lambda_handler.rb +16 -0
  26. data/lib/belt/root.rb +26 -0
  27. data/lib/belt/route_dsl.rb +605 -0
  28. data/lib/belt/table_inference.rb +71 -0
  29. data/lib/belt/version.rb +1 -1
  30. data/lib/belt.rb +1 -0
  31. data/lib/templates/environment/backend.tf.erb +8 -0
  32. data/lib/templates/environment/main.tf.erb +42 -0
  33. data/lib/templates/environment/terraform.tfvars.erb +1 -0
  34. data/lib/templates/environment/variables.tf.erb +16 -0
  35. data/lib/templates/frontend/react/index.html.erb +12 -0
  36. data/lib/templates/frontend/react/package.json.erb +20 -0
  37. data/lib/templates/frontend/react/src/App.jsx +14 -0
  38. data/lib/templates/frontend/react/src/index.css +10 -0
  39. data/lib/templates/frontend/react/src/lib/apiClient.js.erb +19 -0
  40. data/lib/templates/frontend/react/src/main.jsx +10 -0
  41. data/lib/templates/frontend/react/src/pages/Home.jsx.erb +10 -0
  42. data/lib/templates/frontend/react/vite.config.js +8 -0
  43. data/lib/templates/frontend_infra/frontend.tf.erb +159 -0
  44. data/lib/templates/generate/controller.rb.erb +59 -0
  45. data/lib/templates/generate/model.rb.erb +20 -0
  46. data/lib/templates/new_app/AGENTS.md.erb +130 -0
  47. data/lib/templates/new_app/Gemfile.erb +5 -0
  48. data/lib/templates/new_app/README.md.erb +25 -0
  49. data/lib/templates/new_app/Rakefile.erb +12 -0
  50. data/lib/templates/new_app/gitignore.erb +14 -0
  51. data/lib/templates/new_app/infrastructure/routes.tf.rb.erb +5 -0
  52. data/lib/templates/new_app/infrastructure/schema.tf.rb.erb +9 -0
  53. data/lib/templates/new_app/lambda/Gemfile.erb +7 -0
  54. data/lib/templates/new_app/lambda/api.rb.erb +22 -0
  55. data/lib/templates/new_app/lambda/controllers/application_controller.rb.erb +6 -0
  56. data/lib/templates/new_app/lambda/lib/routes/routes.rb.erb +11 -0
  57. data/lib/templates/new_app/lambda/models/application_record.rb.erb +6 -0
  58. data/lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb +23 -0
  59. data/lib/templates/views/Edit.jsx.erb +38 -0
  60. data/lib/templates/views/Form.jsx.erb +34 -0
  61. data/lib/templates/views/Index.jsx.erb +39 -0
  62. data/lib/templates/views/New.jsx.erb +26 -0
  63. data/lib/templates/views/Show.jsx.erb +46 -0
  64. data.tar.gz.sig +0 -0
  65. metadata +73 -3
  66. metadata.gz.sig +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14240a12050757c9936f1f57d941d98d23f79d0fc82b54f3c9d75217a5a00677
4
- data.tar.gz: d8c545b345c138d8d84ca2dc170ea99ad8b5404310620cdf17c8092263a2f7fc
3
+ metadata.gz: 9236fac00b7efcb43f17c02fb347626f768cf7dd5d6c1d2162470176043cdfde
4
+ data.tar.gz: d7998c44bfcdfa42eb4d22acca3fabf62e0005753b355f4112bbdff108682527
5
5
  SHA512:
6
- metadata.gz: 6bf259163e8b4c50e4d544fc671f8ed5408b6996563392ff5301de7c5e26ac9098e149d0eeadae028fe455ef92e409e8c187130a6c12b2652673c77be7c6afae
7
- data.tar.gz: 93bd5dc850ad6bb17e771c9a12982b4b89f2df06b048404f0b66dec83d7cff74720ee2e0e9359b00fb85341834b0c774faf51539afbec5c9fcbf01815eb7cf7d
6
+ metadata.gz: 3ec40527e1d14fe89e8e5f3dc412e803354630126d40e9bed1a8c1dc32703ecd5a1fbffec1690ff0262ee2d1ece1895751441fc554f2cc38b86cb1d36e8b4ea8
7
+ data.tar.gz: 3eaff9879994c9d2b7c3eb6b454a833bc4ae433bd544ac0c8fa3578045de96263a2871915b60d8707eaed8b512e497c88125554b45068d597d19d612a6b87209
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1
4
+
5
+ ### `belt routes` CLI command
6
+
7
+ - Added `belt routes` — displays all routes defined in `infrastructure/routes.tf.rb`
8
+ - Concise table output with VERB, PATH, CONTROLLER#ACTION (shows GATEWAY/LAMBDA columns when multiple namespaces exist)
9
+ - JSON output via `--format json` includes routes array and optional schema models
10
+ - Filter routes with `--grep PATTERN` (case-insensitive, matches verb, path, gateway, lambda, controller, or action)
11
+ - Generate Ruby route manifest files with `--namespace NAMESPACE` (or `all` for every gateway/lambda)
12
+ - `--output-dir DIR` controls where generated files are written (warns if used without `--namespace`)
13
+ - Added `Belt.root` — project root detection by walking up to find `infrastructure/routes.tf.rb`, with fallback to `pwd`
14
+ - Default output directory for generated routes: `#{Belt.root}/lambda/lib/routes`
15
+
16
+ ### `belt tasks` CLI command
17
+
18
+ - Added `belt tasks` — lists available rake tasks from the project's Rakefile
19
+ - Filter tasks with `--grep PATTERN`
20
+ - Show all tasks (including undescribed) with `--all`
21
+ - Run rake tasks directly: `belt lambda:build_layer` invokes `bundle exec rake lambda:build_layer`
22
+
23
+ ### Other changes
24
+
25
+ - Added `Belt::RouteDSL` — full route DSL parser (resources, nested resources, scopes, mounts, schemas)
26
+ - Added `Belt::TableInference` — infers DynamoDB table access from Terraform definitions
27
+ - Renamed `TerraDispatch` references to `Belt` in templates and DSL entry points
28
+ - Removed `activeitem` dependency from generated Gemfile template
29
+ - Added Rakefile template to `belt new` scaffolding
30
+
3
31
  ## 0.0.7
4
32
 
5
33
  - Fixed `discover_gem_paths` to use `Gem.loaded_specs` instead of `Gem::Specification.each` — the latter silently returns nothing on Lambda's vendored bundle layout, causing gem controllers/models to not be found
@@ -26,4 +54,4 @@
26
54
  - Added `BeltController::Base` with callbacks, strong params, CORS, error handling
27
55
  - Added `ActionController::Parameters` (strong params without Rails)
28
56
  - Added response helpers and CORS origin resolution
29
- - Bundled dependencies: activeitem, lambda_loadout, s3arch
57
+ - Bundled dependencies: activeitem, lambda_loadout
data/README.md CHANGED
@@ -9,7 +9,6 @@ Belt bundles everything you need to go from zero to production:
9
9
  - **Belt::ActionRouter** — request routing to controllers from route manifests
10
10
  - **ActiveItem** — DynamoDB ORM (queries, validations, associations, transactions)
11
11
  - **Lambda Loadout** — structured logging, CloudWatch metrics (EMF), error alerting
12
- - **S3arch** — full-text search via SQLite FTS5, stored on S3, queried from Lambda
13
12
 
14
13
  ## Installation
15
14
 
@@ -129,7 +128,7 @@ terraform {
129
128
  Define routes in `infrastructure/routes.tf.rb`:
130
129
 
131
130
  ```ruby
132
- TerraDispatch.routes.draw do
131
+ Belt.application.routes.draw do
133
132
  namespace :api do
134
133
  resources :posts, only: [:index, :show, :create]
135
134
  end
@@ -139,7 +138,7 @@ end
139
138
  Define tables in `infrastructure/schema.tf.rb`:
140
139
 
141
140
  ```ruby
142
- TerraDispatch.schema.define do
141
+ Belt.application.schema.define do
143
142
  model :post do
144
143
  partition_key :id, :string
145
144
  global_secondary_index :UserIndex, partition_key: :user_id
@@ -201,80 +200,180 @@ error_response("Not found", 404) # 404 JSON error
201
200
  html_response("<h1>Hello</h1>") # 200 HTML with CORS
202
201
  ```
203
202
 
204
- ## Holsters (Belt's Engines)
203
+ ## Controller Discovery
205
204
 
206
- Holsters are Belt's equivalent of Rails Engines. A holster lets a gem provide its own controllers, models, routes, and schema all discovered automatically via convention.
205
+ Belt discovers controllers from the app's namespace module first, then searches `Belt.all_controller_paths` which includes app-defined paths. No registration needed.
207
206
 
208
- ### Creating a Holster
207
+ ## Belt::Observability
209
208
 
210
- In your gem, subclass `Belt::Holster`:
209
+ Belt provides global `Belt::Observability::Logger` and `Belt::Observability::Metrics` facades that are set automatically by `Belt::LambdaHandler`. Access them from anywhere:
211
210
 
212
211
  ```ruby
213
- # lib/s3arch/holster.rb
214
- module S3arch
215
- class Holster < Belt::Holster
216
- end
217
- end
212
+ Belt::Observability::Logger.info("Something happened", user_id: "123")
213
+ Belt::Observability::Metrics.track_event("OrderCreated", model: "Order")
214
+ ```
215
+
216
+ ## Environment Variables
217
+
218
+ | Variable | Purpose |
219
+ |----------|---------|
220
+ | `ENVIRONMENT` | Controls verbose error responses (`dev*`, `local`, `test`) |
221
+ | `BELT_METRICS_NAMESPACE` | CloudWatch metrics namespace (default: `Belt`) |
222
+ | `ACTION` | Service name for logging (falls back to function name) |
223
+ | `ERROR_NOTIFICATION_TOPIC_ARN` | SNS topic for error alerts |
224
+ | `CORS_ALLOWED_ORIGINS` | Comma-separated origins (overrides domain vars) |
225
+ | `CUSTOMER_APP_DOMAIN` | Primary app domain for CORS |
226
+ | `OPS_APP_DOMAIN` | Internal tools domain for CORS |
227
+
228
+ ## CLI
229
+
230
+ Belt includes a command-line interface for project management.
231
+
232
+ ### `belt routes`
233
+
234
+ Display route definitions from your `infrastructure/routes.tf.rb`. This is the primary way to inspect what endpoints your app exposes.
235
+
236
+ ```bash
237
+ belt routes
218
238
  ```
219
239
 
220
- That's it. Belt discovers all `Holster` subclasses at boot. By convention, it expects:
240
+ Output (single namespace):
221
241
 
222
242
  ```
223
- your-gem/
224
- ├── infrastructure/
225
- │ ├── routes.tf.rb # Holster's route definitions
226
- │ └── schema.tf.rb # Holster's DynamoDB tables
227
- └── lambda/
228
- ├── controllers/ # Holster's controllers
229
- └── models/ # Holster's models
243
+ VERB PATH CONTROLLER#ACTION
244
+ ------------------------------------------------------------------
245
+ GET /posts posts#index
246
+ GET /posts/{post_id} posts#show
247
+ POST /posts posts#create
248
+ DELETE /posts/{post_id} posts#destroy
230
249
  ```
231
250
 
232
- No configuration needed if you follow the convention. Belt resolves paths relative to your gem's root (two directories up from the holster file).
251
+ When multiple namespaces (API Gateways) exist, GATEWAY and LAMBDA columns are added automatically:
233
252
 
234
- ### Customizing Paths
253
+ ```
254
+ VERB PATH GATEWAY LAMBDA CONTROLLER#ACTION
255
+ ---------------------------------------------------------------
256
+ GET /posts blog blog posts#index
257
+ POST /posts blog blog posts#create
258
+ GET /posts ops ops posts#index
259
+ POST /posts ops ops posts#create
260
+ ```
235
261
 
236
- If your gem uses a different layout, override any path:
262
+ #### Options
237
263
 
238
- ```ruby
239
- module MyGem
240
- class Holster < Belt::Holster
241
- self.gem_root = File.expand_path("..", __dir__)
242
- self.controllers_path = File.join(gem_root, "app", "controllers")
243
- end
244
- end
264
+ | Flag | Description |
265
+ |------|-------------|
266
+ | `-g, --grep PATTERN` | Filter routes matching pattern (case-insensitive, matches verb, path, gateway, lambda, controller, or action) |
267
+ | `-f, --format FORMAT` | Output format: `concise` (default) or `json` |
268
+ | `--namespace NAMESPACE` | Generate Ruby route files for NAMESPACE (or "all") |
269
+ | `--output-dir DIR` | Output directory for generated Ruby files (default: `lambda/lib/routes/`) |
270
+ | `--schema FILE` | Path to `schema.tf.rb` for model definitions (default: same directory as routes file) |
271
+ | `--tables-file FILE` | Path to Terraform file with `aws_dynamodb_table` resources for table inference |
272
+ | `-h, --help` | Show help |
273
+
274
+ #### Examples
275
+
276
+ ```bash
277
+ # Filter routes by pattern
278
+ belt routes -g posts
279
+
280
+ # JSON output (for tooling/CI)
281
+ belt routes -f json
282
+
283
+ # Generate Ruby route constant for the "api" namespace
284
+ belt routes --namespace api
285
+
286
+ # Generate to a custom directory
287
+ belt routes --namespace api --output-dir lib/routes
288
+
289
+ # Include schema models in JSON output
290
+ belt routes -f json --schema infrastructure/schema.tf.rb
291
+
292
+ # Infer DynamoDB table access from Terraform
293
+ belt routes -f json --tables-file infrastructure/main.tf
245
294
  ```
246
295
 
247
- ### How Belt Uses Holsters
296
+ #### JSON Output
297
+
298
+ With `--format json`, the output includes a `routes` array and optionally a `models` array (when a schema file is found):
299
+
300
+ ```json
301
+ {
302
+ "routes": [
303
+ {
304
+ "name": "posts",
305
+ "verb": "GET",
306
+ "path": "/posts",
307
+ "gateway": "api",
308
+ "lambda": "api",
309
+ "controller": "posts",
310
+ "action": "index",
311
+ "auth": "cognito",
312
+ "tables": ["posts"],
313
+ "request_model": "",
314
+ "response_model": ""
315
+ }
316
+ ],
317
+ "models": [
318
+ {
319
+ "name": "CreatePost",
320
+ "kind": "request",
321
+ "description": "Request model: CreatePost",
322
+ "properties": {
323
+ "title": { "type": "string" },
324
+ "body": { "type": "string" }
325
+ },
326
+ "required": ["title"]
327
+ }
328
+ ]
329
+ }
330
+ ```
248
331
 
249
- - **Controllers**: `Belt::ActionRouter` searches holster controller paths automatically
250
- - **Routes**: `Belt.all_routes_paths` collects all holster `routes.tf.rb` files for the Terraform provider
251
- - **Schema**: `Belt.all_schema_paths` collects all holster `schema.tf.rb` files for the Terraform provider
252
- - **Models**: `Belt.all_models_paths` collects all holster model directories
332
+ #### Ruby Output
253
333
 
254
- ## Controller Discovery
334
+ With `--namespace NAMESPACE`, Belt generates a frozen Ruby constant file at `lambda/lib/routes/<namespace>_routes.rb`:
255
335
 
256
- Belt discovers controllers from the app's namespace module first, then searches `Belt.all_controller_paths` — which includes both app-defined paths and holster-provided paths. No registration needed.
336
+ ```ruby
337
+ # frozen_string_literal: true
338
+
339
+ # Auto-generated by: belt routes --namespace api
340
+ # Do not edit manually
341
+
342
+ module Routes
343
+ API = [
344
+ {
345
+ verb: "GET",
346
+ path: "/posts",
347
+ gateway: "api",
348
+ lambda: "api",
349
+ controller: "posts",
350
+ action: "index",
351
+ auth: "cognito",
352
+ tables: ["posts"]
353
+ }
354
+ ].freeze
355
+ end
356
+ ```
257
357
 
258
- ## Belt::Observability
358
+ This is used by `Belt::ActionRouter` at runtime for request routing.
259
359
 
260
- Belt provides global `Belt::Observability::Logger` and `Belt::Observability::Metrics` facades that are set automatically by `Belt::LambdaHandler`. Access them from anywhere:
360
+ #### Route File Location
361
+
362
+ The command expects `infrastructure/routes.tf.rb` in the current working directory. Routes are defined using the same DSL as the Belt Terraform provider:
261
363
 
262
364
  ```ruby
263
- Belt::Observability::Logger.info("Something happened", user_id: "123")
264
- Belt::Observability::Metrics.track_event("OrderCreated", model: "Order")
365
+ Belt.application.routes.draw do
366
+ namespace :api do
367
+ resources :posts, only: [:index, :show, :create, :destroy]
368
+ resource :profile, only: [:show, :update]
369
+ get "health", action: :health
370
+ end
371
+ end
265
372
  ```
266
373
 
267
- ## Environment Variables
374
+ #### Table Inference
268
375
 
269
- | Variable | Purpose |
270
- |----------|---------|
271
- | `ENVIRONMENT` | Controls verbose error responses (`dev*`, `local`, `test`) |
272
- | `BELT_METRICS_NAMESPACE` | CloudWatch metrics namespace (default: `Belt`) |
273
- | `ACTION` | Service name for logging (falls back to function name) |
274
- | `ERROR_NOTIFICATION_TOPIC_ARN` | SNS topic for error alerts |
275
- | `CORS_ALLOWED_ORIGINS` | Comma-separated origins (overrides domain vars) |
276
- | `CUSTOMER_APP_DOMAIN` | Primary app domain for CORS |
277
- | `OPS_APP_DOMAIN` | Internal tools domain for CORS |
376
+ When `--tables-file` is provided, Belt parses `aws_dynamodb_table` resource blocks from your Terraform files and infers which tables each route accesses based on the resource name in the route path. Routes can also declare tables explicitly in the DSL via `tables: [:posts, :comments]`.
278
377
 
279
378
  ## License
280
379
 
data/exe/belt ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/belt/cli'
5
+
6
+ Belt::CLI.start(ARGV)
@@ -144,9 +144,15 @@ module Belt
144
144
  next unless File.exist?(full_path)
145
145
 
146
146
  require full_path
147
- # After requiring, try to find the constant
147
+ # After requiring, try to find the constant (top-level or namespaced)
148
148
  class_name = "#{controller_name.split(%r{[_/]}).map(&:capitalize).join}Controller"
149
149
  return Object.const_get(class_name) if Object.const_defined?(class_name)
150
+
151
+ # Try under namespace module (e.g., BrablogControllers::PostsController)
152
+ if Object.const_defined?(@namespace_module_name)
153
+ ns = Object.const_get(@namespace_module_name)
154
+ return ns.const_get(class_name) if ns.const_defined?(class_name)
155
+ end
150
156
  end
151
157
 
152
158
  raise Belt::ActionNotFound, "Controller not found: #{controller_name}"
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ module AppDetection
6
+ def detect_app_name
7
+ routes_file = 'infrastructure/routes.tf.rb'
8
+ if File.exist?(routes_file)
9
+ match = File.read(routes_file).match(/namespace :(\w+)/)
10
+ return match[1] if match
11
+ end
12
+ File.basename(Dir.pwd)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ module BucketSecurity
6
+ def audit_bucket_security(bucket)
7
+ {
8
+ versioning: check_versioning(bucket),
9
+ encryption: check_encryption(bucket),
10
+ public_access_block: check_public_access_block(bucket),
11
+ tls_policy: check_tls_policy(bucket)
12
+ }
13
+ end
14
+
15
+ def print_security_audit(audit)
16
+ puts "\nSecurity audit:"
17
+ puts " #{audit[:versioning] ? '✓' : '✗'} Versioning"
18
+ puts " #{audit[:encryption] ? '✓' : '✗'} Encryption (AES-256)"
19
+ puts " #{audit[:public_access_block] ? '✓' : '✗'} Public access block"
20
+ puts " #{audit[:tls_policy] ? '✓' : '✗'} TLS-only policy"
21
+ end
22
+
23
+ def check_versioning(bucket)
24
+ output = safe_capture('aws', 's3api', 'get-bucket-versioning', '--bucket', bucket, '--output', 'json')
25
+ return false unless output
26
+
27
+ data = JSON.parse(output)
28
+ data['Status'] == 'Enabled'
29
+ rescue JSON::ParserError
30
+ false
31
+ end
32
+
33
+ def check_encryption(bucket)
34
+ output = safe_capture('aws', 's3api', 'get-bucket-encryption', '--bucket', bucket, '--output', 'json')
35
+ return false unless output
36
+
37
+ data = JSON.parse(output)
38
+ rules = data.dig('ServerSideEncryptionConfiguration', 'Rules') || []
39
+ rules.any? { |r| r.dig('ApplyServerSideEncryptionByDefault', 'SSEAlgorithm') }
40
+ rescue JSON::ParserError
41
+ false
42
+ end
43
+
44
+ def check_public_access_block(bucket)
45
+ output = safe_capture('aws', 's3api', 'get-public-access-block', '--bucket', bucket, '--output', 'json')
46
+ return false unless output
47
+
48
+ data = JSON.parse(output)
49
+ config = data['PublicAccessBlockConfiguration'] || {}
50
+ config['BlockPublicAcls'] && config['IgnorePublicAcls'] &&
51
+ config['BlockPublicPolicy'] && config['RestrictPublicBuckets']
52
+ rescue JSON::ParserError
53
+ false
54
+ end
55
+
56
+ def check_tls_policy(bucket)
57
+ output = safe_capture('aws', 's3api', 'get-bucket-policy', '--bucket', bucket, '--output', 'json')
58
+ return false unless output
59
+
60
+ data = JSON.parse(output)
61
+ policy = JSON.parse(data['Policy'])
62
+ statements = policy['Statement'] || []
63
+ statements.any? do |s|
64
+ s['Effect'] == 'Deny' &&
65
+ s.dig('Condition', 'Bool', 'aws:SecureTransport') == 'false'
66
+ end
67
+ rescue JSON::ParserError, TypeError
68
+ false
69
+ end
70
+
71
+ def harden_bucket(bucket, audit)
72
+ unless audit[:versioning]
73
+ enable_versioning(bucket)
74
+ puts ' enable versioning'
75
+ end
76
+ unless audit[:encryption]
77
+ enable_encryption(bucket)
78
+ puts ' enable AES-256 encryption'
79
+ end
80
+ unless audit[:public_access_block]
81
+ block_public_access(bucket)
82
+ puts ' enable public access block'
83
+ end
84
+ return if audit[:tls_policy]
85
+
86
+ apply_tls_policy(bucket)
87
+ puts ' enable TLS-only bucket policy'
88
+ end
89
+
90
+ private
91
+
92
+ def enable_versioning(bucket)
93
+ run!('aws', 's3api', 'put-bucket-versioning', '--bucket', bucket,
94
+ '--versioning-configuration', 'Status=Enabled')
95
+ end
96
+
97
+ def enable_encryption(bucket)
98
+ config = '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"},"BucketKeyEnabled":true}]}'
99
+ run!('aws', 's3api', 'put-bucket-encryption', '--bucket', bucket,
100
+ '--server-side-encryption-configuration', config)
101
+ end
102
+
103
+ def block_public_access(bucket)
104
+ run!('aws', 's3api', 'put-public-access-block', '--bucket', bucket,
105
+ '--public-access-block-configuration',
106
+ 'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true')
107
+ end
108
+
109
+ def apply_tls_policy(bucket)
110
+ policy = {
111
+ Version: '2012-10-17',
112
+ Statement: [{
113
+ Sid: 'DenyInsecureConnections', Effect: 'Deny', Principal: '*', Action: 's3:*',
114
+ Resource: ["arn:aws:s3:::#{bucket}", "arn:aws:s3:::#{bucket}/*"],
115
+ Condition: { Bool: { 'aws:SecureTransport' => 'false' } }
116
+ }]
117
+ }
118
+ run!('aws', 's3api', 'put-bucket-policy', '--bucket', bucket, '--policy', JSON.generate(policy))
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ module EnvResolver
6
+ def self.resolve(args)
7
+ if args.first && !args.first.start_with?('-')
8
+ args.shift
9
+ else
10
+ ENV.fetch('BELT_ENV', nil)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'erb'
5
+ require_relative 'app_detection'
6
+
7
+ module Belt
8
+ module CLI
9
+ class EnvironmentCommand
10
+ TEMPLATE_DIR = File.expand_path('../../templates/environment', __dir__)
11
+
12
+ include AppDetection
13
+
14
+ def self.run(args)
15
+ env_name = args.shift
16
+
17
+ if env_name.nil? || env_name.empty?
18
+ puts 'Usage: belt generate environment <name>'
19
+ puts "\nExamples:"
20
+ puts ' belt generate environment dev01'
21
+ puts ' belt generate environment staging'
22
+ puts ' belt generate environment prod'
23
+ exit 1
24
+ end
25
+
26
+ new(env_name).generate
27
+ end
28
+
29
+ def initialize(env_name)
30
+ @env_name = env_name.downcase.gsub(/[^a-z0-9_-]/, '')
31
+ @app_name = detect_app_name
32
+ end
33
+
34
+ def generate
35
+ dest_dir = "infrastructure/#{@env_name}"
36
+
37
+ if Dir.exist?(dest_dir)
38
+ puts "Environment '#{@env_name}' already exists at #{dest_dir}/"
39
+ exit 1
40
+ end
41
+
42
+ puts "Creating environment: #{@env_name}"
43
+ FileUtils.mkdir_p(dest_dir)
44
+
45
+ templates.each do |template_name, dest_file|
46
+ dest_path = File.join(dest_dir, dest_file)
47
+ write_template(template_name, dest_path)
48
+ puts " create #{dest_path}"
49
+ end
50
+
51
+ 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'
57
+ end
58
+
59
+ private
60
+
61
+ def templates
62
+ {
63
+ 'main.tf.erb' => 'main.tf',
64
+ 'backend.tf.erb' => 'backend.tf',
65
+ 'variables.tf.erb' => 'variables.tf',
66
+ 'terraform.tfvars.erb' => 'terraform.tfvars'
67
+ }
68
+ end
69
+
70
+ def write_template(template_name, dest_path)
71
+ template_path = File.join(TEMPLATE_DIR, template_name)
72
+ content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
73
+ File.write(dest_path, content)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'erb'
5
+ require 'json'
6
+ require_relative 'app_detection'
7
+
8
+ module Belt
9
+ module CLI
10
+ class FrontendCommand
11
+ TEMPLATE_DIR = File.expand_path('../../templates/frontend', __dir__)
12
+ FRAMEWORKS = %w[react vue svelte].freeze
13
+
14
+ include AppDetection
15
+
16
+ def self.run(args)
17
+ framework = args.shift
18
+
19
+ if framework.nil? || !FRAMEWORKS.include?(framework)
20
+ puts "Usage: belt generate frontend <#{FRAMEWORKS.join('|')}>"
21
+ puts "\nScaffolds a frontend application with build tooling and API client."
22
+ puts "\nExamples:"
23
+ puts ' belt generate frontend react'
24
+ puts ' belt generate frontend vue'
25
+ exit 1
26
+ end
27
+
28
+ new(framework).generate
29
+ end
30
+
31
+ def initialize(framework)
32
+ @framework = framework
33
+ @app_name = detect_app_name
34
+ @module_name = @app_name.split(/[-_]/).map(&:capitalize).join
35
+ end
36
+
37
+ def generate
38
+ dest_dir = 'frontend'
39
+
40
+ if Dir.exist?(dest_dir) && !Dir.empty?(dest_dir)
41
+ puts "Directory 'frontend/' already exists and is not empty."
42
+ exit 1
43
+ end
44
+
45
+ puts "Creating #{@framework} frontend application..."
46
+ framework_dir = File.join(TEMPLATE_DIR, @framework)
47
+
48
+ unless Dir.exist?(framework_dir)
49
+ puts "✗ Template not found for '#{@framework}'. Available: #{FRAMEWORKS.join(', ')}"
50
+ exit 1
51
+ end
52
+
53
+ copy_template(framework_dir, dest_dir)
54
+
55
+ puts "\n✓ Frontend (#{@framework}) created in frontend/"
56
+ 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'
60
+ end
61
+
62
+ private
63
+
64
+ def copy_template(src_dir, dest_dir)
65
+ Dir.glob("#{src_dir}/**/*", File::FNM_DOTMATCH).each do |src|
66
+ next if File.directory?(src)
67
+ next if src.end_with?('/..') || src.end_with?('/.')
68
+
69
+ rel_path = src.sub("#{src_dir}/", '')
70
+ dest_path = File.join(dest_dir, rel_path.sub(/\.erb\z/, ''))
71
+
72
+ FileUtils.mkdir_p(File.dirname(dest_path))
73
+
74
+ if src.end_with?('.erb')
75
+ content = ERB.new(File.read(src), trim_mode: '-').result(binding)
76
+ File.write(dest_path, content)
77
+ else
78
+ FileUtils.cp(src, dest_path)
79
+ end
80
+ puts " create #{dest_path}"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end