belt 0.3.3 → 0.3.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.

Potentially problematic release.


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

@@ -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
@@ -0,0 +1,103 @@
1
+ # Frontends
2
+
3
+ Belt can host one or more JavaScript SPAs (React, Vue, or Svelte) next to the
4
+ Lambda API. A typical app has a single `frontend/` directory. Some apps — Stowzilla
5
+ is the example that drove this — have several independent SPAs (customer, ops,
6
+ partners) that share the same models and API.
7
+
8
+ ## Single frontend (default)
9
+
10
+ ```bash
11
+ belt new blog --frontend react
12
+ # or, in an existing app:
13
+ belt generate frontend react
14
+ ```
15
+
16
+ That creates `frontend/`, S3 + CloudFront infrastructure, and wires CORS so the
17
+ SPA can call the API. No config file is required.
18
+
19
+ ```bash
20
+ belt server # local Vite dev server
21
+ belt frontend env dev01 # write frontend/.env from terraform outputs
22
+ belt deploy frontend dev01 # npm ci → build → S3 sync → CloudFront invalidation
23
+ ```
24
+
25
+ ## Multiple frontends
26
+
27
+ Declare them in `config/frontends.yml` (or `.belt/frontends.yml`):
28
+
29
+ ```yaml
30
+ frontends:
31
+ customer:
32
+ path: app
33
+ dist: build
34
+ default: true
35
+ bucket_output: web_app_bucket_name
36
+ url_output: web_app_url
37
+ cloudfront_domain_output: web_app_cloudfront_domain
38
+ ops:
39
+ path: ops-app
40
+ dist: build
41
+ bucket_output: ops_app_bucket_name
42
+ url_output: ops_app_url
43
+ cloudfront_domain_output: ops_app_cloudfront_domain
44
+ ```
45
+
46
+ `path` is the directory. `dist` is the build output (`dist` by default; belt
47
+ also auto-detects `build/` after `npm run build`). Terraform output names default
48
+ to `frontend_*` for the `frontend` app and `{name}_frontend_*` for others — override
49
+ them when existing infrastructure uses different names.
50
+
51
+ If terraform exports a CloudFront **domain** instead of a distribution ID, set
52
+ `cloudfront_domain_output` and skip `distribution_output`. Belt looks the ID up
53
+ via AWS and will not probe `{name}_frontend_distribution_id`.
54
+
55
+ ```bash
56
+ belt frontend list
57
+ belt generate frontend react --name ops --path ops-app
58
+ belt generate views bag --frontend ops
59
+ belt generate scaffold order --frontend customer
60
+ belt server --frontend ops
61
+ belt frontend env dev01 --frontend customer
62
+ belt deploy frontend dev01 # all configured frontends
63
+ belt deploy frontend dev01 --frontend ops # just ops
64
+ ```
65
+
66
+ If several frontends exist and you omit `--frontend`, generators and `belt server`
67
+ use the one marked `default: true`. If none is default, they ask you to pick.
68
+
69
+ `belt deploy frontend <env>` with no `--frontend` deploys every frontend that
70
+ has a `package.json`. Full `belt deploy <env>` does the same after terraform apply.
71
+
72
+ ## Env maps
73
+
74
+ Each frontend can have its own `env.yml` mapping process env names to terraform
75
+ outputs:
76
+
77
+ ```yaml
78
+ # app/env.yml
79
+ VITE_API_URL: api_url
80
+ VITE_COGNITO_USER_POOL_ID: cognito_user_pool_id
81
+ VITE_COGNITO_CLIENT_ID: cognito_user_pool_client_id
82
+ ```
83
+
84
+ The default `frontend/` directory also accepts `.belt/frontend_env.yml` as a
85
+ fallback. See the existing env-map behavior: only mapped keys are written into
86
+ `.env`; missing terraform outputs warn and do not clobber local values.
87
+
88
+ ## Infrastructure
89
+
90
+ `belt setup frontend` (and `belt generate frontend`) writes S3 + CloudFront into
91
+ `infrastructure/modules/app/frontend.tf`. Additional named frontends get
92
+ `{name}_frontend.tf` with unique resource names and outputs
93
+ (`ops_frontend_bucket_name`, …). Extra frontends get a CloudFront URL only —
94
+ custom DNS stays on the default frontend unless you add records yourself.
95
+
96
+ CORS: each CloudFront domain is added to `frontend_urls` on the conveyor-belt
97
+ resource so SPA → API calls work.
98
+
99
+ ## See Also
100
+
101
+ - `belt explain deployment` — how frontend deploy fits into `belt deploy`
102
+ - `belt explain generators` — `belt generate frontend` / views
103
+ - `belt explain structure` — where frontend directories live
@@ -51,8 +51,12 @@ Adds a frontend framework to the project:
51
51
  belt generate frontend react
52
52
  belt generate frontend vue
53
53
  belt generate frontend svelte
54
+ belt generate frontend react --name ops --path ops-app
54
55
  ```
55
56
 
57
+ `--name` and `--path` scaffold an additional SPA and register it in
58
+ `config/frontends.yml`. See `belt explain frontend`.
59
+
56
60
  ### Views
57
61
 
58
62
  Generates React pages for a resource's REST actions:
@@ -60,8 +64,12 @@ Generates React pages for a resource's REST actions:
60
64
  ```bash
61
65
  belt generate views post title:string body:text status:string
62
66
  belt g views comment body:text author:string
67
+ belt g views bag --frontend ops
63
68
  ```
64
69
 
70
+ When the app has multiple frontends, pass `--frontend <name>` (or mark one
71
+ `default: true` in `config/frontends.yml`).
72
+
65
73
  ### Auth
66
74
 
67
75
  Sets up Cognito authentication:
@@ -94,6 +102,7 @@ belt destroy model comment
94
102
  belt destroy controller admin/users
95
103
  belt destroy environment staging
96
104
  belt destroy frontend
105
+ belt destroy frontend --frontend ops
97
106
  belt destroy views post
98
107
  ```
99
108
 
@@ -43,6 +43,7 @@ my-app/
43
43
  │ └── prod/
44
44
  │ └── ...
45
45
  ├── frontend/ # Optional frontend (React/Vue/Svelte)
46
+ ├── config/frontends.yml # Optional: multiple named frontends
46
47
  ├── Gemfile # Project-level dependencies (CLI, dev tools)
47
48
  ├── Rakefile # Rake tasks
48
49
  ├── AGENTS.md # AI agent guide
@@ -85,6 +86,8 @@ Terraform resources.
85
86
  `lambda/Gemfile` is what gets packaged into the Lambda.
86
87
  - **Config over code**: Lambda configuration (timeout, memory, env vars) goes in
87
88
  YAML files, not hardcoded in Terraform.
89
+ - **Frontends**: Default directory is `frontend/`. Multiple SPAs (customer + ops,
90
+ etc.) are declared in `config/frontends.yml`. See `belt explain frontend`.
88
91
 
89
92
  ## See Also
90
93
 
data/lib/belt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.3.3'
4
+ VERSION = '0.3.5'
5
5
  end
@@ -1,6 +1,6 @@
1
1
  # Declarative frontend env map for belt.
2
2
  # Left: env var your build tool reads. Right: terraform output name.
3
- # Copy to frontend/env.yml (or .belt/frontend_env.yml) and adjust.
3
+ # Copy to <frontend>/env.yml (or .belt/frontend_env.yml for the default frontend/) and adjust.
4
4
  #
5
5
  # belt deploy frontend <env> → injects these into npm run build
6
6
  # belt frontend env <env> → smart-merges into frontend/.env for local dev