belt 0.3.4 → 0.3.6

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.

Potentially problematic release.


This version of belt might be problematic. Click here for more details.

@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ class RoutesCommand
6
+ # Infers request_model and response_model from contracts using naming conventions.
7
+ #
8
+ # Request model convention cascade (first match wins):
9
+ # 1. :<verb>_<gateway>_<singular_resource> (e.g. :create_customer_item)
10
+ # 2. :<verb>_<singular_resource> (e.g. :create_item)
11
+ #
12
+ # Response model convention:
13
+ # Singular of the resource name matches a `model` in contracts.
14
+ # e.g. `resources :items` → looks for `model :item` in contracts.rb
15
+ #
16
+ # Only request_model inference applies to body-accepting verbs (POST/PUT/PATCH).
17
+ # Response model inference applies to all verbs on resource routes.
18
+ # Explicit values always win — inference only fills in blanks.
19
+ module RequestModelInference
20
+ private
21
+
22
+ # Applies convention-based request_model inference to all routes.
23
+ # Requires the set of known contract names from contracts.rb.
24
+ def infer_request_models!(routes, contracts_file)
25
+ contract_names = load_contract_names(contracts_file)
26
+ return if contract_names[:request].empty? && contract_names[:response].empty?
27
+
28
+ routes.each do |route|
29
+ infer_request_model_for!(route, contract_names[:request])
30
+ infer_response_model_for!(route, contract_names[:response])
31
+ end
32
+ end
33
+
34
+ def load_contract_names(contracts_file)
35
+ empty_result = { request: Set.new, response: Set.new }
36
+ return empty_result unless contracts_file && File.exist?(contracts_file)
37
+
38
+ # Reset application state for clean contract loading
39
+ Belt.instance_variable_set(:@application, nil)
40
+ begin
41
+ eval(File.read(contracts_file), binding, contracts_file) # rubocop:disable Security/Eval
42
+ rescue StandardError => e
43
+ warn "Warning: Failed to load contracts for inference: #{e.message}"
44
+ return empty_result
45
+ end
46
+
47
+ schema = Belt.application.schema.to_h
48
+ request_names = Set.new
49
+ response_names = Set.new
50
+ (schema[:request_models] || {}).each_key { |name| request_names << name.to_s }
51
+ (schema[:response_models] || {}).each_key { |name| response_names << name.to_s }
52
+ { request: request_names, response: response_names }
53
+ end
54
+
55
+ def infer_request_model_for!(route, request_contracts)
56
+ return if request_contracts.empty?
57
+ return unless route[:request_model].to_s.empty?
58
+ return unless body_accepting_verb?(route[:verb])
59
+
60
+ inferred = infer_request_model_for_route(route, request_contracts)
61
+ route[:request_model] = inferred if inferred
62
+ end
63
+
64
+ def infer_response_model_for!(route, response_contracts)
65
+ return if response_contracts.empty?
66
+ return unless route[:response_model].to_s.empty?
67
+
68
+ inferred = infer_response_model_for_route(route, response_contracts)
69
+ route[:response_model] = inferred if inferred
70
+ end
71
+
72
+ def infer_request_model_for_route(route, contract_names)
73
+ verb_prefix = infer_verb_prefix(route[:verb], route[:action])
74
+ return nil unless verb_prefix
75
+
76
+ resource_name = extract_singular_resource(route)
77
+ return nil unless resource_name
78
+
79
+ gateway = route[:gateway]
80
+
81
+ # Cascade: gateway-scoped first, then generic
82
+ candidates = [
83
+ "#{verb_prefix}_#{gateway}_#{resource_name}",
84
+ "#{verb_prefix}_#{resource_name}"
85
+ ]
86
+
87
+ candidates.find { |candidate| contract_names.include?(candidate) }
88
+ end
89
+
90
+ def infer_response_model_for_route(route, response_contracts)
91
+ resource_name = extract_singular_resource(route)
92
+ return nil unless resource_name
93
+
94
+ # Convention: singular resource name matches a response model
95
+ return resource_name if response_contracts.include?(resource_name)
96
+
97
+ nil
98
+ end
99
+
100
+ def infer_verb_prefix(verb, action)
101
+ # Map HTTP verb + action to the contract naming prefix
102
+ case action
103
+ when 'create' then 'create'
104
+ when 'update' then 'update'
105
+ else
106
+ # For non-standard actions on body verbs, don't infer
107
+ case verb
108
+ when 'POST' then 'create'
109
+ when 'PUT', 'PATCH' then 'update'
110
+ end
111
+ end
112
+ end
113
+
114
+ def extract_singular_resource(route)
115
+ # Extract the resource name from the path
116
+ segments = route[:path].split('/').reject(&:empty?)
117
+ # Find the last non-param segment that looks like a resource
118
+ resource_segments = segments.reject { |s| s.start_with?('{', ':') }
119
+ return nil if resource_segments.empty?
120
+
121
+ resource = resource_segments.last
122
+ Belt::Inflector.singularize(resource.gsub('-', '_'))
123
+ end
124
+
125
+ def body_accepting_verb?(verb)
126
+ %w[POST PUT PATCH].include?(verb)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -7,12 +7,14 @@ require_relative '../route_dsl'
7
7
  require_relative '../table_inference'
8
8
  require_relative 'routes_command/schema_loader'
9
9
  require_relative 'routes_command/route_inference'
10
+ require_relative 'routes_command/request_model_inference'
10
11
 
11
12
  module Belt
12
13
  module CLI
13
14
  class RoutesCommand
14
15
  include SchemaLoader
15
16
  include RouteInference
17
+ include RequestModelInference
16
18
 
17
19
  def self.run(args)
18
20
  new(args).run
@@ -33,6 +35,11 @@ module Belt
33
35
  dsl = load_routes(routes_file)
34
36
  @table_inference = TableInference.new(@options[:tables_file])
35
37
  routes = collect_routes(dsl)
38
+
39
+ # Convention-based request_model inference from contracts
40
+ contracts_file = resolve_contracts_file(routes_file)
41
+ infer_request_models!(routes, contracts_file) if contracts_file
42
+
36
43
  routes = apply_grep(routes) if @options[:grep]
37
44
 
38
45
  warn 'Warning: --output-dir has no effect without --namespace' if @options[:output_dir] && !@options[:namespace]
@@ -4,6 +4,7 @@ require 'base64'
4
4
  require 'json'
5
5
  require_relative 'app_detection'
6
6
  require_relative 'frontend_env_map'
7
+ require_relative 'frontend_registry'
7
8
  require_relative 'terraform_command'
8
9
 
9
10
  module Belt
@@ -16,6 +17,7 @@ module Belt
16
17
  def self.run(args)
17
18
  port = DEFAULT_PORT
18
19
  open_browser = false
20
+ frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
19
21
 
20
22
  i = 0
21
23
  while i < args.length
@@ -34,7 +36,7 @@ module Belt
34
36
  i += 1
35
37
  end
36
38
 
37
- new(port: port, open_browser: open_browser).run
39
+ new(port: port, open_browser: open_browser, frontend_name: frontend_name).run
38
40
  end
39
41
 
40
42
  def self.help_text
@@ -45,37 +47,41 @@ module Belt
45
47
  belt s [options]
46
48
 
47
49
  Options:
48
- -p, --port PORT Port to serve on (default: #{DEFAULT_PORT})
49
- -o, --open Open browser after starting
50
- -h, --help Show this help
50
+ -p, --port PORT Port to serve on (default: #{DEFAULT_PORT})
51
+ --frontend NAME Which frontend to start (when several exist)
52
+ -o, --open Open browser after starting
53
+ -h, --help Show this help
51
54
 
52
55
  Behavior:
53
- • If frontend/ exists → runs the frontend dev server (npm run dev)
54
- Injects env from frontend/env.yml (or default VITE_API_URL) using
56
+ • If a frontend exists → runs its dev server (npx vite)
57
+ Injects env from <frontend>/env.yml (or default VITE_API_URL) using
55
58
  terraform outputs when available.
56
59
  • If no frontend → serves the welcome page via a local HTTP server
57
60
  After deploy, shows live API URL and deployment status.
58
61
 
59
62
  Note: The backend is serverless (AWS Lambda). Use `belt deploy` to deploy
60
63
  your backend to AWS. Local frontend development reads the env map (or
61
- frontend/.env via `belt frontend env <env>`).
64
+ <frontend>/.env via `belt frontend env <env>`).
62
65
 
63
66
  Examples:
64
- belt server # Start on port #{DEFAULT_PORT}
65
- belt s -p 4000 # Start on port 4000
66
- belt s --open # Start and open browser
67
+ belt server # Start on port #{DEFAULT_PORT}
68
+ belt s -p 4000 # Start on port 4000
69
+ belt s --frontend ops # Start the ops frontend
70
+ belt s --open # Start and open browser
67
71
  HELP
68
72
  end
69
73
 
70
- def initialize(port:, open_browser: false)
74
+ def initialize(port:, open_browser: false, frontend_name: nil)
71
75
  @port = port
72
76
  @open_browser = open_browser
77
+ @frontend_name = frontend_name
73
78
  @app_name = detect_app_name
74
79
  @api_url = detect_api_url
80
+ @frontend = resolve_frontend
75
81
  end
76
82
 
77
83
  def run
78
- if Dir.exist?('frontend') && File.exist?('frontend/package.json')
84
+ if @frontend&.exists?
79
85
  run_frontend_dev_server
80
86
  else
81
87
  run_welcome_server
@@ -84,8 +90,15 @@ module Belt
84
90
 
85
91
  private
86
92
 
93
+ def resolve_frontend
94
+ registry = FrontendRegistry.new
95
+ return nil if registry.empty? && @frontend_name.nil?
96
+
97
+ registry.resolve!(@frontend_name)
98
+ end
99
+
87
100
  def run_frontend_dev_server
88
- puts "🚀 Starting frontend dev server on port #{@port}..."
101
+ puts "🚀 Starting #{@frontend.label} dev server on port #{@port}..."
89
102
  build_env = frontend_process_env
90
103
  api_url = build_env['VITE_API_URL'] || build_env['REACT_APP_API_URL'] ||
91
104
  build_env['NEXT_PUBLIC_API_URL'] || @api_url
@@ -102,7 +115,7 @@ module Belt
102
115
  env = { 'PORT' => @port.to_s }.merge(build_env)
103
116
 
104
117
  # Prefer the dev script with the port flag for Vite-based setups
105
- Dir.chdir('frontend') do
118
+ Dir.chdir(@frontend.path) do
106
119
  exec(env, 'npx', 'vite', '--port', @port.to_s)
107
120
  end
108
121
  end
@@ -112,7 +125,7 @@ module Belt
112
125
  env_name = @deploy_env || ENV.fetch('BELT_ENV', nil) || TerraformCommand.list_environments.first
113
126
  return {} unless env_name
114
127
 
115
- FrontendEnvMap.new(env_name).process_env
128
+ FrontendEnvMap.new(env_name, frontend_path: @frontend.path).process_env
116
129
  rescue StandardError
117
130
  # Fall back to legacy api_url detection if map resolution fails
118
131
  @api_url ? { 'VITE_API_URL' => @api_url } : {}
@@ -55,6 +55,7 @@ module Belt
55
55
  def run
56
56
  validate!
57
57
  apply_env_config!
58
+ build_zip_artifacts! if %w[plan apply].include?(@action)
58
59
  env_dir = File.join(@infra_dir, @env)
59
60
  args = ['terraform', @action, *@extra_args]
60
61
  puts "belt → #{args.join(' ')} (in #{env_dir}/)"
@@ -69,6 +70,14 @@ module Belt
69
70
  puts " 🔑 Using AWS profile: #{env_config.aws_profile}" if env_config.aws_profile?
70
71
  end
71
72
 
73
+ def build_zip_artifacts!
74
+ require_relative 'zip_artifact_builder'
75
+ ZipArtifactBuilder.build!(
76
+ project_root: File.expand_path('..', @infra_dir),
77
+ infra_dir: @infra_dir
78
+ )
79
+ end
80
+
72
81
  def validate!
73
82
  unless @infra_dir
74
83
  abort "Error: No infrastructure/ directory found. Run `belt generate environment #{@env}` first."
@@ -3,6 +3,7 @@
3
3
  require 'fileutils'
4
4
  require 'erb'
5
5
  require_relative '../inflector'
6
+ require_relative 'frontend_registry'
6
7
 
7
8
  module Belt
8
9
  module CLI
@@ -11,16 +12,19 @@ module Belt
11
12
 
12
13
  def self.run(args)
13
14
  force = args.delete('--force') || args.delete('-f')
15
+ frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
14
16
 
15
17
  name = args.shift
16
18
  if name.nil? || name.empty?
17
19
  puts 'Usage: belt generate views <resource> [field:type ...] [options]'
18
20
  puts "\nGenerates React pages for all REST actions (index, show, new, edit)."
19
21
  puts "\nOptions:"
20
- puts ' --force, -f Overwrite existing files without prompting'
22
+ puts ' --force, -f Overwrite existing files without prompting'
23
+ puts ' --frontend NAME Target frontend when several exist'
21
24
  puts "\nExamples:"
22
25
  puts ' belt generate views post title:string content:text status:string'
23
26
  puts ' belt generate views comment body:text author:string'
27
+ puts ' belt generate views bag --frontend ops'
24
28
  exit 1
25
29
  end
26
30
 
@@ -32,7 +36,8 @@ module Belt
32
36
  # If no fields provided, try to read from contracts.rb
33
37
  fields = read_schema_fields(name) if fields.empty?
34
38
 
35
- new(name, fields, force: force).generate
39
+ frontend = FrontendRegistry.new.resolve!(frontend_name)
40
+ new(name, fields, force: force, frontend: frontend).generate
36
41
  end
37
42
 
38
43
  def self.read_schema_fields(name)
@@ -75,7 +80,7 @@ module Belt
75
80
  end
76
81
  end
77
82
 
78
- def initialize(name, fields, force: false, quiet: false)
83
+ def initialize(name, fields, force: false, quiet: false, frontend: nil)
79
84
  @name = name.downcase.gsub(/[^a-z0-9_]/, '_')
80
85
  @fields = fields
81
86
  @force = force
@@ -84,15 +89,16 @@ module Belt
84
89
  @singular_name = Belt::Inflector.singularize(@name)
85
90
  @resource_name = Belt::Inflector.pluralize(@singular_name)
86
91
  @class_name = Belt::Inflector.classify(@singular_name)
92
+ @frontend = frontend || FrontendRegistry.new.resolve!
87
93
  end
88
94
 
89
95
  def generate
90
- unless Dir.exist?('frontend/src')
91
- puts '✗ No frontend/ directory found. Run `belt generate frontend react` first.'
96
+ unless Dir.exist?(@frontend.src_dir)
97
+ puts "✗ No #{@frontend.src_dir}/ directory found. Run `belt generate frontend react` first."
92
98
  exit 1
93
99
  end
94
100
 
95
- pages_dir = "frontend/src/pages/#{@resource_name}"
101
+ pages_dir = "#{@frontend.src_dir}/pages/#{@resource_name}"
96
102
  @plural_class_name = Belt::Inflector.camelize(@resource_name)
97
103
  FileUtils.mkdir_p(pages_dir)
98
104
 
@@ -170,7 +176,7 @@ module Belt
170
176
  end
171
177
 
172
178
  def inject_routes
173
- app_jsx = 'frontend/src/App.jsx'
179
+ app_jsx = @frontend.app_jsx
174
180
  return unless File.exist?(app_jsx)
175
181
 
176
182
  content = File.read(app_jsx)
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'open3'
6
+
7
+ module Belt
8
+ module CLI
9
+ # Builds zip files that Terraform `filebase64sha256(...)` / `filename = "...zip"`
10
+ # references before plan/apply. Conveyor Belt packages Ruby lambdas itself;
11
+ # sidecar functions (Node image processors, Cognito triggers, …) are plain
12
+ # `aws_lambda_function` resources that expect a zip on disk at plan time.
13
+ #
14
+ # Node packages (`package.json`) are installed in Docker on linux/amd64 so
15
+ # native addons like `sharp` match Lambda. Plain JS directories are zipped
16
+ # as-is. Existing zips are reused when a source hash still matches.
17
+ class ZipArtifactBuilder
18
+ NODE_DOCKER_IMAGE = 'public.ecr.aws/lambda/nodejs:20-x86_64'
19
+ ZIP_REF = /(?:filebase64sha256\(\s*|filename\s*=\s*)["']([^"']+\.zip)["']/
20
+
21
+ def self.build!(project_root: Dir.pwd, infra_dir: 'infrastructure')
22
+ new(project_root: project_root, infra_dir: infra_dir).build!
23
+ end
24
+
25
+ def initialize(project_root:, infra_dir:)
26
+ @project_root = File.expand_path(project_root)
27
+ @infra_dir = File.expand_path(infra_dir, @project_root)
28
+ end
29
+
30
+ Artifact = Struct.new(:zip_path, :source_dir, keyword_init: true)
31
+
32
+ def build!
33
+ artifacts = discover_artifacts
34
+ return if artifacts.empty?
35
+
36
+ artifacts.each { |artifact| ensure_zip!(artifact) }
37
+ end
38
+
39
+ private
40
+
41
+ def discover_artifacts
42
+ return [] unless Dir.exist?(@infra_dir)
43
+
44
+ zips = []
45
+ tf_files.each do |tf_file|
46
+ File.read(tf_file).scan(ZIP_REF).flatten.each do |raw_path|
47
+ zip_path = resolve_zip_path(tf_file, raw_path)
48
+ next unless zip_path
49
+
50
+ zips << zip_path
51
+ end
52
+ end
53
+
54
+ zips.uniq.filter_map do |zip_path|
55
+ source_dir = File.dirname(zip_path)
56
+ next unless Dir.exist?(source_dir)
57
+
58
+ Artifact.new(zip_path: zip_path, source_dir: source_dir)
59
+ end
60
+ end
61
+
62
+ def tf_files
63
+ Dir.glob(File.join(@infra_dir, '**/*.tf')).reject { |path| path.include?('/.terraform/') }
64
+ end
65
+
66
+ # Skip interpolations other than ${path.module} — we can't resolve those
67
+ # without running terraform.
68
+ def resolve_zip_path(tf_file, raw_path)
69
+ return if raw_path.match?(/\$\{(?!path\.module\})/)
70
+
71
+ tf_dir = File.dirname(File.expand_path(tf_file, @project_root))
72
+ expanded = raw_path.gsub('${path.module}', tf_dir)
73
+ File.expand_path(expanded)
74
+ end
75
+
76
+ def ensure_zip!(artifact)
77
+ label = relative_to_root(artifact.zip_path)
78
+ zip_exists = File.file?(artifact.zip_path)
79
+ hashed = File.file?(hash_file_for(artifact))
80
+
81
+ if zip_exists && (!hashed || hash_unchanged?(artifact))
82
+ puts " ♻️ #{label} unchanged — skipping rebuild" if hashed
83
+ return
84
+ end
85
+
86
+ puts " 📦 Building #{label}..."
87
+ if node_package?(artifact.source_dir)
88
+ build_node_zip!(artifact)
89
+ else
90
+ build_js_zip!(artifact)
91
+ end
92
+ write_hash!(artifact)
93
+ puts " ✅ #{label} ready"
94
+ end
95
+
96
+ def node_package?(dir)
97
+ File.file?(File.join(dir, 'package.json'))
98
+ end
99
+
100
+ def build_node_zip!(artifact)
101
+ ensure_docker!
102
+ install_node_modules!(artifact.source_dir)
103
+ zip_contents!(artifact, node_zip_entries(artifact.source_dir))
104
+ end
105
+
106
+ def build_js_zip!(artifact)
107
+ entries = js_zip_entries(artifact.source_dir)
108
+ abort "✗ #{relative_to_root(artifact.source_dir)} has no .js/.mjs files to zip" if entries.empty?
109
+
110
+ zip_contents!(artifact, entries)
111
+ end
112
+
113
+ def node_zip_entries(dir)
114
+ entries = js_zip_entries(dir)
115
+ entries << 'package.json' if File.file?(File.join(dir, 'package.json'))
116
+ entries << 'node_modules' if Dir.exist?(File.join(dir, 'node_modules'))
117
+ entries.uniq
118
+ end
119
+
120
+ def js_zip_entries(dir)
121
+ Dir.children(dir).grep(/\.(mjs|js|cjs)\z/).sort
122
+ end
123
+
124
+ def install_node_modules!(source_dir)
125
+ lockfile = File.join(source_dir, 'package-lock.json')
126
+ install = File.file?(lockfile) ? 'npm ci --omit=dev' : 'npm install --omit=dev'
127
+ uid = Process.uid
128
+ gid = Process.gid
129
+
130
+ docker_cmd = [
131
+ 'docker', 'run', '--rm',
132
+ '--platform', 'linux/amd64',
133
+ '--entrypoint', '',
134
+ '-v', "#{source_dir}:/var/task",
135
+ '-w', '/var/task',
136
+ NODE_DOCKER_IMAGE,
137
+ '/bin/bash', '-c',
138
+ "rm -rf node_modules && #{install} && chown -R #{uid}:#{gid} ."
139
+ ]
140
+
141
+ output, status = Open3.capture2e(*docker_cmd)
142
+ return if status.success?
143
+
144
+ puts output
145
+ abort "\n✗ Node Lambda build failed for #{relative_to_root(source_dir)}. " \
146
+ 'Is Docker running?'
147
+ end
148
+
149
+ def zip_contents!(artifact, entries)
150
+ FileUtils.rm_f(artifact.zip_path)
151
+ zip_name = File.basename(artifact.zip_path)
152
+
153
+ Dir.chdir(artifact.source_dir) do
154
+ output, status = Open3.capture2e('zip', '-qr', zip_name, *entries)
155
+ unless status.success?
156
+ puts output
157
+ abort "\n✗ zip failed for #{relative_to_root(artifact.zip_path)}"
158
+ end
159
+ end
160
+ end
161
+
162
+ def ensure_docker!
163
+ _, status = Open3.capture2e('docker', 'info')
164
+ return if status.success?
165
+
166
+ abort "✗ Docker is not running. It's required to build Node Lambda zips for linux/amd64."
167
+ end
168
+
169
+ def hash_unchanged?(artifact)
170
+ hash_file = hash_file_for(artifact)
171
+ return false unless File.file?(hash_file)
172
+
173
+ File.read(hash_file).strip == source_hash(artifact.source_dir)
174
+ end
175
+
176
+ def write_hash!(artifact)
177
+ File.write(hash_file_for(artifact), "#{source_hash(artifact.source_dir)}\n")
178
+ end
179
+
180
+ def hash_file_for(artifact)
181
+ File.join(artifact.source_dir, ".#{File.basename(artifact.source_dir)}-hash")
182
+ end
183
+
184
+ def source_hash(dir)
185
+ files = Dir.children(dir).select do |name|
186
+ name.match?(/\.(mjs|js|cjs)\z/) || name == 'package.json' || name == 'package-lock.json'
187
+ end.sort
188
+
189
+ digest = Digest::SHA256.new
190
+ files.each { |name| digest.update(File.binread(File.join(dir, name))) }
191
+ digest.hexdigest
192
+ end
193
+
194
+ def relative_to_root(path)
195
+ path.sub(%r{\A#{Regexp.escape(@project_root)}/?}, '')
196
+ end
197
+ end
198
+ end
199
+ end
data/lib/belt/cli.rb CHANGED
@@ -100,18 +100,19 @@ module Belt
100
100
  Commands:
101
101
  new <app_name> [--frontend react] Create a new Belt application
102
102
  generate <scaffold|model|controller> <name> Generate components
103
- generate frontend <react|vue|svelte> Scaffold a frontend app
104
- generate views <resource> [fields...] Generate React pages for REST actions
103
+ generate frontend <react|vue|svelte> Scaffold a frontend app [--name --path]
104
+ generate views <resource> [fields...] Generate React pages [--frontend NAME]
105
105
  generate environment <name> Create a new environment
106
106
  destroy <scaffold|model|controller> <name> Remove generated components
107
- destroy frontend Remove the frontend/ directory
107
+ destroy frontend [--frontend NAME] Remove a frontend directory
108
108
  destroy views <resource> Remove React pages for a resource
109
109
  destroy environment <name> Remove an environment directory
110
- server Start local dev server (frontend)
110
+ server [--frontend NAME] Start local dev server (frontend)
111
111
  s Alias for server
112
112
  deploy [environment] Deploy to AWS (init → plan → apply)
113
- deploy frontend <env> Build and deploy frontend to AWS
114
- frontend env <env> Write frontend/.env from terraform outputs
113
+ deploy frontend <env> [--frontend NAME] Build and deploy frontend(s) to AWS
114
+ frontend env <env> [--frontend NAME] Write <frontend>/.env from terraform outputs
115
+ frontend list List configured frontends
115
116
  routes [-g PATTERN] [-f json] Show route definitions
116
117
  contracts [-g PATTERN] [-f json] Show API request/response contracts
117
118
  lambda-config [-e ENV] [-f json|terraform] Show merged lambda configuration
@@ -123,7 +124,7 @@ module Belt
123
124
  -T [-g PATTERN] [-a] Alias for tasks
124
125
  setup state Create/select S3 state bucket
125
126
  setup tables <env> Generate DynamoDB tables from schema
126
- setup frontend <env> Generate S3 + CloudFront infrastructure
127
+ setup frontend [--name NAME] Generate S3 + CloudFront infrastructure
127
128
  doctor Check system dependencies and AWS config
128
129
  plugin new <name> Scaffold a new Belt plugin gem
129
130
  explain <topic> Explain a Belt concept (routing, models, …)
@@ -150,11 +151,14 @@ module Belt
150
151
  belt generate scaffold post title:string content:text status:string
151
152
  belt destroy scaffold post
152
153
  belt generate frontend react
154
+ belt generate frontend react --name ops --path ops-app
153
155
  belt server # Start local frontend server
156
+ belt server --frontend ops
154
157
  belt deploy # Deploy dev to AWS
155
158
  belt deploy prod --auto # Deploy prod without confirmation
156
159
  belt deploy frontend wups
157
- belt frontend env wups # Smart-merge TF outputs into frontend/.env
160
+ belt deploy frontend wups --frontend ops
161
+ belt frontend env wups # Smart-merge TF outputs into <frontend>/.env
158
162
  belt setup frontend wups
159
163
  belt apply wups
160
164
  belt tasks # list all rake tasks
@@ -85,6 +85,25 @@ belt routes --namespace api
85
85
 
86
86
  This is typically done automatically by `belt deploy`.
87
87
 
88
+ ## Sidecar Lambda Zips
89
+
90
+ Conveyor Belt packages Ruby lambdas. Some apps also have standalone
91
+ `aws_lambda_function` resources (Node image processors, Cognito triggers)
92
+ whose Terraform uses `filename` + `filebase64sha256` pointing at a zip on disk.
93
+
94
+ `belt deploy`, `belt plan`, and `belt apply` scan `infrastructure/**/*.tf` for
95
+ those zip paths and build any that are missing (or whose source hash changed):
96
+
97
+ - Directory with `package.json` — `npm ci` in Docker (`linux/amd64`, Lambda
98
+ Node image) so native addons like `sharp` match Lambda, then zip
99
+ - Plain JS directory — zip the `.js` / `.mjs` files
100
+
101
+ The zip lives next to the source (`image-processor/image-processor.zip`). This
102
+ is the same pre-terraform step Stowzilla's `scripts/deploy.sh` does for the
103
+ image processor. Existing zips with no hash file are left alone.
104
+
105
+ Docker must be running for Node packages.
106
+
88
107
  ## Terraform Commands
89
108
 
90
109
  Belt wraps Terraform with environment awareness:
@@ -130,10 +149,16 @@ belt deploy prod # explicit arg wins
130
149
  For apps with a frontend:
131
150
 
132
151
  ```bash
133
- belt deploy frontend <env> # build + deploy to S3/CloudFront
134
- belt frontend env <env> # generate .env from Terraform outputs
152
+ belt deploy frontend <env> # build + deploy all frontends
153
+ belt deploy frontend <env> --frontend ops # deploy one named frontend
154
+ belt frontend env <env> # generate .env from Terraform outputs
155
+ belt frontend list # show configured frontends
135
156
  ```
136
157
 
158
+ The directory is `frontend/` by default. Multiple SPAs are declared in
159
+ `config/frontends.yml` — see `belt explain frontend`. Full `belt deploy <env>`
160
+ deploys every configured frontend after terraform apply.
161
+
137
162
  ## See Also
138
163
 
139
164
  - `belt explain routing` — how routes map to infrastructure