belt 0.3.2 → 0.3.3

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: fe786827b1e1dc99140a55faddd4573d80799ddbbd4e82d4533f6f5b3b1fa063
4
- data.tar.gz: 17de4ce04575bd50a45b99a677b01853537981993ef502c718cd74db84e5524c
3
+ metadata.gz: 2363120fdf3ee1746c38f73fc9807426a9b8a4b36d2fbcf0a8abe6b86a665197
4
+ data.tar.gz: 5c416967aa56c9fcd95cc6d9f9cead9de86b4e73c0b74dcee7476d9c29532212
5
5
  SHA512:
6
- metadata.gz: ce42cd482a549027a830297f0fb88987d152821b835950d3480193bc868ab77fa9f0378cb504b419bf372443eb81971e1ecc00c505747162daab502cace2d705
7
- data.tar.gz: 8bf51156214ee553a992c0821ed2a8e170fe4cba2503e78917e0a10244ea8f6de7e587cb3d8d0e5d1a63040ee2afc04de331dcdf7e5174d7fcefa3c9dd983746
6
+ metadata.gz: a81c85e8cab3d6fbd79b4fc71fec279d7d3bd4c31379264d509b55779da555999eb3ed2e0ead55bc6475ad4a43d7e25d1fda529ba4fd87748efc9e36cd9a9438
7
+ data.tar.gz: 2818d26460222cd45860d33a512fe06c0d5deb89436e7c4947e71c42d5270df0612c3fc6f72221f1bec3c304a93d03ad33a8fc9fc430394e379696d3384f42d3
@@ -31,8 +31,15 @@ module Belt
31
31
  route_info = find_route(method, match_path)
32
32
 
33
33
  unless route_info
34
- Belt::Observability::Logger.instance&.warn('Route not found', method: method, path: full_path)
35
- return error_response('Not found', 404, event)
34
+ # Fallback: serve the Belt welcome page for GET / when no explicit root route exists.
35
+ # This handles the case where API Gateway delivers the request (e.g. via a catch-all
36
+ # integration or proxy route) but the route manifest doesn't include GET /.
37
+ if method == 'GET' && root_path?(match_path)
38
+ route_info = { verb: 'GET', pattern: '/', segments: [], controller: 'welcome', action: 'show' }
39
+ else
40
+ Belt::Observability::Logger.instance&.warn('Route not found', method: method, path: full_path)
41
+ return error_response('Not found', 404, event)
42
+ end
36
43
  end
37
44
 
38
45
  path_params = extract_path_params(route_info[:pattern], match_path)
@@ -60,6 +67,10 @@ module Belt
60
67
 
61
68
  private
62
69
 
70
+ def root_path?(path)
71
+ path == '/' || path.empty?
72
+ end
73
+
63
74
  def strip_gateway_prefix(path)
64
75
  return '/' if path.nil?
65
76
 
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ class ExplainCommand
6
+ DOCS_DIR = File.expand_path('../docs', __dir__)
7
+
8
+ TOPICS = Dir.glob(File.join(DOCS_DIR, '*.md')).map do |path|
9
+ File.basename(path, '.md')
10
+ end.sort.freeze
11
+
12
+ ALIASES = {
13
+ 'routes' => 'routing',
14
+ 'route' => 'routing',
15
+ 'router' => 'routing',
16
+ 'controller' => 'controllers',
17
+ 'model' => 'models',
18
+ 'activeitem' => 'models',
19
+ 'dynamodb' => 'models',
20
+ 'deploy' => 'deployment',
21
+ 'deploying' => 'deployment',
22
+ 'terraform' => 'deployment',
23
+ 'generate' => 'generators',
24
+ 'generator' => 'generators',
25
+ 'scaffold' => 'generators',
26
+ 'handler' => 'lambda_handler',
27
+ 'lambda' => 'lambda_handler',
28
+ 'entry_point' => 'lambda_handler',
29
+ 'entrypoint' => 'lambda_handler',
30
+ 'project' => 'structure',
31
+ 'layout' => 'structure',
32
+ 'directory' => 'structure',
33
+ 'logs' => 'observability',
34
+ 'logging' => 'observability',
35
+ 'metrics' => 'observability',
36
+ 'backup' => 'backups',
37
+ 'plugin' => 'plugins',
38
+ 'irb' => 'console',
39
+ 'repl' => 'console'
40
+ }.freeze
41
+
42
+ def self.run(args)
43
+ if args.empty? || args.include?('--help') || args.include?('-h')
44
+ puts usage
45
+ return
46
+ end
47
+
48
+ topic = resolve_topic(args.first)
49
+
50
+ if topic.nil?
51
+ puts "Unknown topic: #{args.first}\n\n"
52
+ puts 'Available topics:'
53
+ TOPICS.each { |t| puts " #{t}" }
54
+ puts "\nRun `belt explain <topic>` for details."
55
+ exit 1
56
+ end
57
+
58
+ display_topic(topic)
59
+ end
60
+
61
+ def self.resolve_topic(input)
62
+ normalized = input.downcase.gsub('-', '_')
63
+ return normalized if TOPICS.include?(normalized)
64
+ return ALIASES[normalized] if ALIASES[normalized]
65
+
66
+ # Fuzzy match: find topics that start with or contain the input
67
+ match = TOPICS.find { |t| t.start_with?(normalized) }
68
+ match || TOPICS.find { |t| t.include?(normalized) }
69
+ end
70
+
71
+ def self.display_topic(topic)
72
+ path = File.join(DOCS_DIR, "#{topic}.md")
73
+ content = File.read(path)
74
+ puts content
75
+ end
76
+
77
+ def self.usage
78
+ topic_list = TOPICS.map { |t| " #{t}" }.join("\n")
79
+
80
+ <<~USAGE
81
+ Usage: belt explain <topic>
82
+
83
+ Display documentation for a Belt concept or feature.
84
+
85
+ Available topics:
86
+ #{topic_list}
87
+
88
+ Aliases:
89
+ routes, route, router → routing
90
+ controller → controllers
91
+ model, activeitem → models
92
+ deploy, terraform → deployment
93
+ generate, scaffold → generators
94
+ handler, lambda → lambda_handler
95
+ project, layout → structure
96
+ logs, logging, metrics → observability
97
+ backup → backups
98
+ plugin → plugins
99
+ irb, repl → console
100
+
101
+ Examples:
102
+ belt explain routing
103
+ belt explain controllers
104
+ belt explain deploy
105
+ belt explain models
106
+ USAGE
107
+ end
108
+ end
109
+ end
110
+ end
@@ -114,10 +114,34 @@ module Belt
114
114
  gateway.routes.each do |route|
115
115
  routes << build_route_hash(route, gateway)
116
116
  end
117
+ inject_welcome_route!(routes, gateway) unless root_route?(gateway)
117
118
  end
118
119
  routes.sort_by { |r| route_specificity(r[:path], r[:verb]) }
119
120
  end
120
121
 
122
+ # Ensure every gateway has a GET / route so the API Gateway resource is created.
123
+ # Without this, visiting the root URL returns a 404 at the gateway level before
124
+ # the request ever reaches Lambda.
125
+ def root_route?(gateway)
126
+ gateway.routes.any? { |r| r.method == 'GET' && normalize_path(r.path) == '/' }
127
+ end
128
+
129
+ def inject_welcome_route!(routes, gateway)
130
+ routes << {
131
+ name: 'root',
132
+ verb: 'GET',
133
+ path: '/',
134
+ gateway: gateway.name,
135
+ lambda: gateway.default_lambda.to_s,
136
+ controller: 'welcome',
137
+ action: 'show',
138
+ auth: 'none',
139
+ tables: [],
140
+ request_model: '',
141
+ response_model: ''
142
+ }
143
+ end
144
+
121
145
  def build_route_hash(route, gateway)
122
146
  hash = {
123
147
  name: extract_route_name(route.path),
data/lib/belt/cli.rb CHANGED
@@ -26,6 +26,7 @@ require_relative 'cli/console_command'
26
26
  require_relative 'cli/logs_command'
27
27
  require_relative 'cli/doctor_command'
28
28
  require_relative 'cli/plugin_command'
29
+ require_relative 'cli/explain_command'
29
30
 
30
31
  module Belt
31
32
  module CLI
@@ -42,6 +43,7 @@ module Belt
42
43
  'setup' => Belt::CLI::SetupCommand,
43
44
  'doctor' => Belt::CLI::DoctorCommand,
44
45
  'plugin' => Belt::CLI::PluginCommand,
46
+ 'explain' => Belt::CLI::ExplainCommand,
45
47
  'deploy' => Belt::CLI::DeployCommand,
46
48
  'frontend' => Belt::CLI::FrontendEnvCommand,
47
49
  %w[server s] => Belt::CLI::ServerCommand,
@@ -55,7 +57,7 @@ module Belt
55
57
  TERRAFORM_ACTIONS = Belt::CLI::TerraformCommand::ACTIONS
56
58
 
57
59
  # Commands that can run without being inside a Belt project
58
- STANDALONE_COMMANDS = %w[new version --version -v doctor].freeze
60
+ STANDALONE_COMMANDS = %w[new version --version -v doctor explain].freeze
59
61
 
60
62
  def self.start(args)
61
63
  command = args.shift
@@ -124,6 +126,7 @@ module Belt
124
126
  setup frontend <env> Generate S3 + CloudFront infrastructure
125
127
  doctor Check system dependencies and AWS config
126
128
  plugin new <name> Scaffold a new Belt plugin gem
129
+ explain <topic> Explain a Belt concept (routing, models, …)
127
130
  init [environment] <env> terraform init for environment
128
131
  plan [environment] <env> terraform plan for environment
129
132
  apply [environment] <env> terraform apply for environment
@@ -0,0 +1,101 @@
1
+ # Backups
2
+
3
+ Belt integrates automated pre-deploy backups into the deploy lifecycle.
4
+ When configured, `belt deploy` creates recovery points before applying changes.
5
+
6
+ ## Quick Setup
7
+
8
+ Create `infrastructure/<env>/belt.rb`:
9
+
10
+ ```ruby
11
+ Belt.configure do |config|
12
+ config.backups do
13
+ dynamodb :all
14
+ retention snapshots: 90
15
+ end
16
+ end
17
+ ```
18
+
19
+ Then deploy normally — backups run automatically:
20
+
21
+ ```bash
22
+ belt deploy prod
23
+ ```
24
+
25
+ ## Simple Mode
26
+
27
+ For DynamoDB-only backups with defaults (all tables, 90-day retention):
28
+
29
+ ```ruby
30
+ Belt.configure do |config|
31
+ config.backups = true
32
+ end
33
+ ```
34
+
35
+ ## Full Configuration
36
+
37
+ ```ruby
38
+ Belt.configure do |config|
39
+ config.backups do
40
+ dynamodb :all # All tables: PITR check + on-demand snapshot
41
+ dynamodb :posts, :users # Or specific tables only
42
+ cognito :users, :pool_config # Export user list + pool settings to S3
43
+ s3 :legal_documents # Sync bucket to backup bucket
44
+ retention snapshots: 90, cognito: 10, s3: 10
45
+ end
46
+ end
47
+ ```
48
+
49
+ ## Backup Types
50
+
51
+ | Type | What It Does | Default Retention |
52
+ |------|-------------|-------------------|
53
+ | `dynamodb :all` | PITR verification + on-demand snapshot per table | 90 days |
54
+ | `dynamodb :table1, :table2` | Same, specific tables only | 90 days |
55
+ | `cognito :users` | Paginated user export → JSON in backup bucket | 10 copies |
56
+ | `cognito :pool_config` | Pool configuration export → JSON | 10 copies |
57
+ | `s3 :bucket_name` | Full sync to backup bucket | 10 copies |
58
+
59
+ ## CLI Flags
60
+
61
+ ```bash
62
+ belt deploy prod # normal deploy (runs backups first)
63
+ belt deploy prod --skip-backup # skip backup phase
64
+ belt deploy prod --backup-only # just create recovery point, don't deploy
65
+ ```
66
+
67
+ ## How It Works
68
+
69
+ 1. Creates backup bucket `<app-name>-backups-<env>` on first run (versioned, public access blocked)
70
+ 2. Reads table names from `terraform output`
71
+ 3. Verifies PITR is enabled on each DynamoDB table
72
+ 4. Creates on-demand backup named `<table>-<timestamp>`
73
+ 5. For Cognito/S3: exports to backup bucket under timestamped prefixes
74
+ 6. Cleans up expired snapshots/copies beyond retention
75
+
76
+ ## First Deploy
77
+
78
+ On a brand-new environment with no prior deploys, there are no Terraform outputs
79
+ to read table names from. Belt warns and skips the backup phase gracefully.
80
+ After the first successful deploy, backups run normally.
81
+
82
+ ## DynamoDB Protection Defaults
83
+
84
+ All Belt-generated DynamoDB tables include:
85
+ - **PITR** (Point-in-Time Recovery) — enabled by default (35 days continuous)
86
+ - **Deletion protection** — enabled in prod, disabled in dev
87
+
88
+ ## Skipping Backups in Dev
89
+
90
+ Don't create `infrastructure/dev01/belt.rb`, or omit the backups block:
91
+
92
+ ```ruby
93
+ Belt.configure do |config|
94
+ # No backups block = no backups during deploy
95
+ end
96
+ ```
97
+
98
+ ## See Also
99
+
100
+ - `belt explain deployment` — the full deploy lifecycle
101
+ - `belt deploy --help` — all deploy options
@@ -0,0 +1,65 @@
1
+ # Console
2
+
3
+ `belt console` (alias: `belt c`) starts an interactive Ruby session with your
4
+ application fully loaded — models, configuration, AWS clients, everything.
5
+
6
+ ## Basic Usage
7
+
8
+ ```bash
9
+ belt console # uses BELT_ENV or defaults to 'dev'
10
+ belt c prod # specify environment
11
+ belt c dev01 # any environment name
12
+ ```
13
+
14
+ ## What Gets Loaded
15
+
16
+ 1. `lambda/config/environment.rb` — your app's boot file (AWS setup, models, libs)
17
+ 2. IRB starts with `reload!` available
18
+ 3. `.irbrc` from project root (optional console customization)
19
+
20
+ ## Runner Mode
21
+
22
+ Execute a command and exit (useful for scripts/CI):
23
+
24
+ ```bash
25
+ belt c dev01 --run "Customer.first"
26
+ belt c prod --run "Post.count"
27
+ belt c dev01 --run "User.where(status: 'active', index: 'StatusIndex').count"
28
+ ```
29
+
30
+ ## Production Safety
31
+
32
+ When the environment is `prod`, Belt shows a confirmation prompt before
33
+ starting the console. This prevents accidentally running destructive commands
34
+ against production data.
35
+
36
+ ## Common Tasks
37
+
38
+ ```ruby
39
+ # Find a record
40
+ post = Post.find("post-id-123")
41
+
42
+ # Query with index
43
+ users = User.where(status: "active", index: "StatusIndex")
44
+
45
+ # Create a record
46
+ Post.create!(title: "Test", body: "Hello", user_id: "u-123")
47
+
48
+ # Count records
49
+ Order.count
50
+
51
+ # Reload code changes
52
+ reload!
53
+ ```
54
+
55
+ ## Environment Resolution
56
+
57
+ Priority order:
58
+ 1. Explicit argument: `belt c prod`
59
+ 2. `BELT_ENV` environment variable
60
+ 3. Default: `dev`
61
+
62
+ ## See Also
63
+
64
+ - `belt explain models` — ActiveItem query methods
65
+ - `belt explain structure` — where environment.rb lives
@@ -0,0 +1,155 @@
1
+ # Controllers
2
+
3
+ Belt controllers inherit from `BeltController::Base` and handle HTTP requests
4
+ dispatched by `Belt::ActionRouter`. They provide callbacks, strong parameters,
5
+ response helpers, and error handling — similar to Rails ActionController.
6
+
7
+ ## Basic Controller
8
+
9
+ ```ruby
10
+ module MyApp
11
+ class PostsController < ApplicationController
12
+ def index
13
+ @posts = Post.all
14
+ end
15
+
16
+ def show
17
+ @post = Post.find(params["id"])
18
+ end
19
+
20
+ def create
21
+ attrs = params.require(:post).permit(:title, :body).to_h
22
+ @post = Post.create!(attrs.merge(user_id: current_user_id))
23
+ response_status :created
24
+ end
25
+
26
+ def destroy
27
+ Post.find(params["id"]).destroy
28
+ head :no_content
29
+ end
30
+ end
31
+ end
32
+ ```
33
+
34
+ ## Response Behavior
35
+
36
+ ### Implicit Responses (default: JSON)
37
+
38
+ When an action sets instance variables and returns without calling a response
39
+ helper, Belt auto-serializes assigns into a JSON response:
40
+
41
+ ```ruby
42
+ def index
43
+ @posts = Post.all # → { "posts": [...] }
44
+ end
45
+
46
+ def show
47
+ @post = Post.find(params["id"]) # → { "post": {...} }
48
+ end
49
+ ```
50
+
51
+ ### Explicit Response Helpers
52
+
53
+ ```ruby
54
+ success_response({ id: "123", name: "Example" }) # 200 JSON
55
+ success_response({ id: "123" }, :created) # 201 JSON
56
+ error_response("Not found", :not_found) # 404 JSON
57
+ error_response("Nope", :unprocessable_entity) # 422 JSON
58
+ html_response("<h1>Hello</h1>") # 200 HTML
59
+ head :no_content # 204 empty
60
+ head :created # 201 empty
61
+ ```
62
+
63
+ ### Non-200 with Implicit Assigns
64
+
65
+ ```ruby
66
+ def create
67
+ @post = Post.create!(...)
68
+ response_status :created # → 201 + { "post": {...} }
69
+ end
70
+ ```
71
+
72
+ ### Default Format
73
+
74
+ ```ruby
75
+ # Global (in config/environment.rb)
76
+ Belt.configure do |config|
77
+ config.default_format = :json # or :html
78
+ end
79
+
80
+ # Per-controller
81
+ class PagesController < ApplicationController
82
+ self.default_format = :html
83
+ end
84
+ ```
85
+
86
+ - `:json` — assigns become JSON body
87
+ - `:html` — renders `views/<controller>/<action>.html.erb`
88
+
89
+ ## Callbacks
90
+
91
+ ```ruby
92
+ class ApplicationController < BeltController::Base
93
+ before_action :authenticate!
94
+ before_action :require_admin!, except: [:health]
95
+ skip_before_action :authenticate!, only: [:health]
96
+ end
97
+ ```
98
+
99
+ Callbacks run in definition order. `before_action` can halt the request by
100
+ calling a response helper (e.g., `error_response`).
101
+
102
+ ## Strong Parameters
103
+
104
+ ```ruby
105
+ params.require(:user).permit(:name, :email, address: [:street, :city])
106
+ ```
107
+
108
+ - `params` — merged hash of path parameters + parsed JSON body
109
+ - `require(:key)` — raises if key missing
110
+ - `permit(:field1, :field2)` — whitelists allowed fields
111
+ - Nested: `permit(:name, address: [:street, :city])`
112
+
113
+ ## Error Handling
114
+
115
+ ```ruby
116
+ class ApplicationController < BeltController::Base
117
+ rescue_from ActiveItem::RecordNotFound, with: :not_found
118
+ rescue_from MyCustomError, with: :handle_custom
119
+
120
+ private
121
+
122
+ def not_found(exception, _context = {})
123
+ error_response(exception.message, :not_found)
124
+ end
125
+
126
+ def handle_custom(exception, _context = {})
127
+ error_response(exception.message, :unprocessable_entity)
128
+ end
129
+ end
130
+ ```
131
+
132
+ ## Controller Discovery
133
+
134
+ Belt resolves controllers by:
135
+ 1. Checking the app's namespace module (e.g., `MyApp::PostsController`)
136
+ 2. Searching `Belt.all_controller_paths`
137
+
138
+ No manual registration required. Controllers are auto-discovered from the
139
+ `lambda/controllers/` directory.
140
+
141
+ ## CORS
142
+
143
+ CORS headers are handled automatically by `Belt::LambdaHandler`. Controllers
144
+ don't need to set them manually. Configure allowed origins via environment
145
+ variables:
146
+
147
+ - `CORS_ALLOWED_ORIGINS` — comma-separated origins
148
+ - `CUSTOMER_APP_DOMAIN` — primary app domain
149
+ - `OPS_APP_DOMAIN` — internal tools domain
150
+
151
+ ## See Also
152
+
153
+ - `belt explain routing` — how requests reach controllers
154
+ - `belt explain models` — ActiveItem ORM
155
+ - `belt explain parameters` — strong parameters in detail
@@ -0,0 +1,141 @@
1
+ # Deployment
2
+
3
+ Belt deploys serverless applications to AWS using Terraform. The CLI wraps
4
+ `terraform init/plan/apply` with conventions for environment management,
5
+ Lambda packaging, and pre-deploy backups.
6
+
7
+ ## Quick Deploy
8
+
9
+ ```bash
10
+ belt deploy <env> # init → plan → apply (interactive)
11
+ belt deploy prod --auto # skip confirmation prompt
12
+ belt deploy prod --skip-backup # skip pre-deploy backup
13
+ belt deploy --backup-only # create recovery point without deploying
14
+ ```
15
+
16
+ ## First-Time Setup
17
+
18
+ ```bash
19
+ # 1. Create S3 bucket for Terraform state
20
+ belt setup state
21
+
22
+ # 2. Scaffold an environment
23
+ belt generate environment dev01
24
+
25
+ # 3. Generate DynamoDB table definitions from schema
26
+ belt setup tables dev01
27
+
28
+ # 4. Initialize and deploy
29
+ belt deploy dev01
30
+ ```
31
+
32
+ ## Environment Structure
33
+
34
+ Each environment lives in `infrastructure/<env>/`:
35
+
36
+ ```
37
+ infrastructure/
38
+ ├── modules/
39
+ │ └── main/ # Shared Terraform module
40
+ ├── dev01/
41
+ │ ├── main.tf # Module reference + provider
42
+ │ ├── variables.tf # Variable declarations
43
+ │ ├── terraform.tfvars # Environment-specific values
44
+ │ ├── backend.tf # S3 state backend
45
+ │ └── outputs.tf # Exported values
46
+ └── prod/
47
+ └── ...
48
+ ```
49
+
50
+ ## What Gets Deployed
51
+
52
+ The Conveyor Belt Terraform provider reads your Ruby DSL and creates:
53
+
54
+ 1. **API Gateway** — HTTP API with routes matching your DSL
55
+ 2. **Lambda functions** — packaged Ruby code (one per `gateway`/`function` block)
56
+ 3. **IAM roles** — least-privilege policies for DynamoDB table access
57
+ 4. **CloudWatch logs** — log groups for each Lambda
58
+ 5. **DynamoDB tables** — from your schema definition
59
+ 6. **Custom domain** — if configured (Route53 + ACM certificate)
60
+
61
+ ## Lambda Packaging
62
+
63
+ Belt packages the `lambda/` directory plus vendored gems. For `path:` gems
64
+ (local development), Belt materializes them into `vendor/cache` automatically.
65
+
66
+ The Lambda entry point is specified in `config/lambda/<name>.yml`:
67
+
68
+ ```yaml
69
+ handler: lambda/<name>.lambda_handler
70
+ runtime: ruby3.3
71
+ timeout: 30
72
+ memory: 256
73
+ environment:
74
+ ENVIRONMENT: ${var.environment}
75
+ ```
76
+
77
+ ## Route Manifests
78
+
79
+ Before deploying, generate the route manifest used at runtime:
80
+
81
+ ```bash
82
+ belt routes --namespace api
83
+ # → writes lambda/lib/routes/api_routes.rb
84
+ ```
85
+
86
+ This is typically done automatically by `belt deploy`.
87
+
88
+ ## Terraform Commands
89
+
90
+ Belt wraps Terraform with environment awareness:
91
+
92
+ ```bash
93
+ belt init <env> # terraform init with correct backend
94
+ belt plan <env> # terraform plan
95
+ belt apply <env> # terraform apply
96
+ belt destroy <env> # terraform destroy (careful!)
97
+ belt output <env> # terraform output
98
+ ```
99
+
100
+ Or use `belt deploy <env>` which runs init → plan → apply in sequence.
101
+
102
+ ## Pre-Deploy Backups
103
+
104
+ Configure in `infrastructure/<env>/belt.rb`:
105
+
106
+ ```ruby
107
+ Belt.configure do |config|
108
+ config.backups do
109
+ dynamodb :all
110
+ retention snapshots: 90
111
+ end
112
+ end
113
+ ```
114
+
115
+ Backups run automatically before each deploy (DynamoDB snapshots, Cognito
116
+ exports, S3 syncs). See `belt explain backups` for full documentation.
117
+
118
+ ## Environment Variables
119
+
120
+ Set `BELT_ENV` to avoid typing the environment every time:
121
+
122
+ ```bash
123
+ export BELT_ENV=dev01
124
+ belt deploy # uses BELT_ENV
125
+ belt deploy prod # explicit arg wins
126
+ ```
127
+
128
+ ## Frontend Deployment
129
+
130
+ For apps with a frontend:
131
+
132
+ ```bash
133
+ belt deploy frontend <env> # build + deploy to S3/CloudFront
134
+ belt frontend env <env> # generate .env from Terraform outputs
135
+ ```
136
+
137
+ ## See Also
138
+
139
+ - `belt explain routing` — how routes map to infrastructure
140
+ - `belt explain backups` — pre-deploy backup configuration
141
+ - `belt doctor` — check system dependencies before deploying