belt 0.1.9 → 0.1.11

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: c3c75f106f7a0f2ebf672294ee30e96f96745fb3ecbd142307c08b6b006933c8
4
- data.tar.gz: 470cefb769cfab7dbaf80e6f950eb62cf87fb5a6b297f3661cf147c26d494501
3
+ metadata.gz: 68099232d7da2991436a263de28721bfc4e15822116f11a42cd2433588293b59
4
+ data.tar.gz: ba091a54897cfd88b02e2ddf48750ca8a47ca3f4c298b7b4371be799e835550e
5
5
  SHA512:
6
- metadata.gz: e5c5ed3dfa729483fd54e28197c9bd2f47f3581bea5f0892a4ac44c02f55253809f4fe1c12167ca55a040bdebc97e545b462f08d2f42c23dd1304a4a9dfd7768
7
- data.tar.gz: be36355b315e467f411079880a4b3e14e94b56567cbf1892a95fff3f2b3c1c9a8bfe91e2e8618b405b1b06045ee1c06e999c12a6c281b9c490b8e8dc43d8f999
6
+ metadata.gz: 8e3d77c55c4297f2978486d9d0755ce45fda7f4a8886c372af0b211952502e5f5fde50c0d8881d4d5db39be4c3747a469825dfbd1b5d6f9be1120d441116600a
7
+ data.tar.gz: 7226f0ce500b1d19a7bccf60f9f087573998f3589ae7b41dceba7f35289a71e7e4ea3250269b96b18ef823acff06bbd67916f8d39c1ecb025ce97fbb8288019e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.11
6
+
7
+ ### Route DSL
8
+
9
+ - `resources` / `resource` honor `scope path:` (and nested path scopes).
10
+ Example: `scope path: "admin", auth: :cognito { resources :users }` → `/admin/users…` with Cognito auth.
11
+ - Nested `scope path:` segments stack (`a` then `b` → `a/b`) instead of replacing.
12
+ - `scope controller:` is applied to `resources` / `resource` the same way as verb routes.
13
+ - Controller inference for multi-segment resource paths joins segments (`/admin/users` → `admin/users`), matching nested-resource convention.
14
+
15
+ ## 0.1.10
16
+
17
+ ### Frontend env map
18
+
19
+ - Declarative map (`frontend/env.yml` or `.belt/frontend_env.yml`) maps process env names → terraform output names (framework-agnostic: `VITE_*`, `REACT_APP_*`, `NEXT_PUBLIC_*`, etc.)
20
+ - `belt deploy frontend <env>` injects mapped vars into `npm run build` process env
21
+ - `belt frontend env <env>` smart-merges mapped keys into `frontend/.env` (preserves unmapped keys/comments; missing TF outputs warn and do not clobber)
22
+ - No map → previous default: only `VITE_API_URL` ← `api_url`
23
+ - `belt server` uses the same map for local process env
24
+
5
25
  ## 0.1.4
6
26
 
7
27
  ### Bug fixes
@@ -4,6 +4,7 @@ require 'shellwords'
4
4
  require 'open3'
5
5
  require_relative 'app_detection'
6
6
  require_relative 'env_resolver'
7
+ require_relative 'frontend_env_map'
7
8
 
8
9
  module Belt
9
10
  module CLI
@@ -60,11 +61,18 @@ module Belt
60
61
  run!(*install_cmd, chdir: 'frontend')
61
62
 
62
63
  puts '🏗️ Building frontend...'
63
- api_url = fetch_api_url
64
- env = api_url ? { 'VITE_API_URL' => api_url } : {}
64
+ env = frontend_build_env
65
+ if env.any?
66
+ puts " Injecting env: #{env.keys.sort.join(', ')}"
67
+ end
65
68
  run!(env, 'npm', 'run', 'build', chdir: 'frontend')
66
69
  end
67
70
 
71
+ # Resolve build env from frontend/env.yml map (or default VITE_API_URL ← api_url).
72
+ def frontend_build_env
73
+ FrontendEnvMap.new(@env, env_dir: @env_dir).process_env
74
+ end
75
+
68
76
  def sync_to_s3
69
77
  bucket = fetch_bucket_name
70
78
  abort "Error: Could not determine S3 bucket. Run `belt apply #{@env}` first." unless bucket
@@ -94,24 +102,24 @@ module Belt
94
102
  puts '✅ CloudFront cache invalidated'
95
103
  end
96
104
 
97
- def fetch_api_url
98
- output, status = Open3.capture2('terraform', 'output', '-raw', 'api_url', chdir: @env_dir)
99
- status.success? && !output.strip.empty? ? output.strip : nil
100
- end
101
-
102
105
  def fetch_bucket_name
103
- output, status = Open3.capture2('terraform', 'output', '-raw', 'frontend_bucket_name', chdir: @env_dir)
104
- status.success? && !output.strip.empty? ? output.strip : nil
106
+ fetch_tf_output('frontend_bucket_name')
105
107
  end
106
108
 
107
109
  def fetch_distribution_id
108
- output, status = Open3.capture2('terraform', 'output', '-raw', 'frontend_distribution_id', chdir: @env_dir)
109
- status.success? && !output.strip.empty? ? output.strip : nil
110
+ fetch_tf_output('frontend_distribution_id')
110
111
  end
111
112
 
112
113
  def fetch_frontend_url
113
- output, status = Open3.capture2('terraform', 'output', '-raw', 'frontend_url', chdir: @env_dir)
114
- status.success? && !output.strip.empty? ? output.strip : nil
114
+ fetch_tf_output('frontend_url')
115
+ end
116
+
117
+ def fetch_tf_output(name)
118
+ output, status = Open3.capture2('terraform', 'output', '-raw', name, chdir: @env_dir)
119
+ return nil unless status.success?
120
+
121
+ value = output.strip
122
+ value.empty? || value == 'null' ? nil : value
115
123
  end
116
124
 
117
125
  def run!(*args, **)
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'app_detection'
4
+ require_relative 'env_resolver'
5
+ require_relative 'frontend_env_map'
6
+
7
+ module Belt
8
+ module CLI
9
+ # belt frontend env <environment>
10
+ #
11
+ # Smart-merges terraform outputs into frontend/.env using the declarative map
12
+ # (frontend/env.yml or .belt/frontend_env.yml). Only mapped keys are written;
13
+ # custom local keys are preserved.
14
+ class FrontendEnvCommand
15
+ include AppDetection
16
+
17
+ def self.run(args)
18
+ subcommand = args.shift
19
+
20
+ case subcommand
21
+ when 'env'
22
+ run_env(args)
23
+ when nil, '-h', '--help', 'help'
24
+ puts usage
25
+ exit(subcommand.nil? ? 1 : 0)
26
+ else
27
+ puts "Unknown frontend subcommand: #{subcommand}\n\n#{usage}"
28
+ exit 1
29
+ end
30
+ end
31
+
32
+ def self.run_env(args)
33
+ env = EnvResolver.resolve(args)
34
+
35
+ if env.nil?
36
+ puts 'Usage: belt frontend env <environment>'
37
+ puts "\nWrites frontend/.env from terraform outputs using the env map."
38
+ puts 'You can also set BELT_ENV to skip the environment argument.'
39
+ puts "\nMap file (optional): frontend/env.yml or .belt/frontend_env.yml"
40
+ puts 'Default without map: VITE_API_URL ← api_url'
41
+ puts "\nExamples:"
42
+ puts ' belt frontend env dev'
43
+ puts ' BELT_ENV=dev belt frontend env'
44
+ exit 1
45
+ end
46
+
47
+ new(env).run
48
+ end
49
+
50
+ def self.usage
51
+ <<~USAGE
52
+ Frontend helpers.
53
+
54
+ Usage:
55
+ belt frontend env <environment> Write frontend/.env from terraform outputs
56
+ belt frontend --help
57
+
58
+ Env map (optional):
59
+ frontend/env.yml
60
+ .belt/frontend_env.yml
61
+
62
+ Example map:
63
+ VITE_API_URL: api_url
64
+ VITE_COGNITO_USER_POOL_ID: cognito_user_pool_id
65
+ VITE_COGNITO_CLIENT_ID: cognito_user_pool_client_id
66
+ VITE_AWS_REGION: cognito_region
67
+
68
+ Without a map, belt injects only VITE_API_URL from api_url.
69
+
70
+ Smart merge: only keys listed in the map are updated. Other .env keys
71
+ (local secrets, feature flags) are left alone. Missing terraform outputs
72
+ warn and do not overwrite existing values.
73
+ USAGE
74
+ end
75
+
76
+ def initialize(env)
77
+ @env = env
78
+ @app_name = detect_app_name
79
+ @env_dir = "infrastructure/#{@env}"
80
+ end
81
+
82
+ def run
83
+ unless Dir.exist?('frontend')
84
+ abort 'Error: No frontend/ directory found. Run `belt generate frontend react` first.'
85
+ end
86
+
87
+ unless Dir.exist?(@env_dir)
88
+ abort "Error: infrastructure/#{@env} not found. Run `belt generate environment #{@env}` first."
89
+ end
90
+
91
+ map = FrontendEnvMap.new(@env, env_dir: @env_dir)
92
+
93
+ if map.using_default_map?
94
+ puts '📋 No frontend/env.yml found — using default map (VITE_API_URL ← api_url)'
95
+ else
96
+ puts "📋 Loading env map from #{map.map_path}"
97
+ end
98
+
99
+ result = map.write_dotenv!
100
+ updated = result[:updated]
101
+
102
+ if updated.empty?
103
+ puts "⚠️ No values written to #{result[:path]} (terraform outputs missing?)"
104
+ else
105
+ puts "✅ Updated #{result[:path]} (#{updated.join(', ')})"
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'open3'
5
+ require 'yaml'
6
+
7
+ module Belt
8
+ module CLI
9
+ # Resolves frontend env vars from a declarative map (env name → terraform output).
10
+ #
11
+ # Map file (optional), first match wins:
12
+ # frontend/env.yml
13
+ # .belt/frontend_env.yml
14
+ #
15
+ # Example:
16
+ # VITE_API_URL: api_url
17
+ # VITE_COGNITO_USER_POOL_ID: cognito_user_pool_id
18
+ # REACT_APP_API_URL: api_url
19
+ #
20
+ # No map → default { "VITE_API_URL" => "api_url" } (backwards compatible).
21
+ class FrontendEnvMap
22
+ MAP_CANDIDATES = [
23
+ File.join('frontend', 'env.yml'),
24
+ File.join('.belt', 'frontend_env.yml')
25
+ ].freeze
26
+
27
+ DEFAULT_MAP = { 'VITE_API_URL' => 'api_url' }.freeze
28
+
29
+ DOTENV_PATH = File.join('frontend', '.env')
30
+
31
+ attr_reader :env_name, :env_dir, :map_path, :map
32
+
33
+ def initialize(env_name, env_dir: nil, map_path: nil)
34
+ @env_name = env_name
35
+ @env_dir = env_dir || File.join('infrastructure', env_name)
36
+ @map_path = map_path || self.class.find_map_path
37
+ @map = load_map
38
+ @tf_cache = {}
39
+ end
40
+
41
+ def self.find_map_path
42
+ MAP_CANDIDATES.find { |path| File.file?(path) }
43
+ end
44
+
45
+ # Hash of env_var => value suitable for process env (npm run build / vite).
46
+ # Skips keys whose terraform output is missing.
47
+ def process_env
48
+ resolved = {}
49
+ each_resolved do |env_key, value, _tf_output|
50
+ resolved[env_key] = value if value
51
+ end
52
+ resolved
53
+ end
54
+
55
+ # Smart-merge mapped keys into frontend/.env.
56
+ # - Only overwrites keys present in the map
57
+ # - Preserves unknown keys, comments, and blank lines
58
+ # - Missing TF output → warn, do not clobber an existing value
59
+ def write_dotenv!(path: DOTENV_PATH)
60
+ FileUtils.mkdir_p(File.dirname(path))
61
+
62
+ existing_lines = File.file?(path) ? File.readlines(path, chomp: true) : []
63
+ updates = {}
64
+ missing = []
65
+
66
+ each_resolved do |env_key, value, tf_output|
67
+ if value
68
+ updates[env_key] = value
69
+ else
70
+ missing << [env_key, tf_output]
71
+ end
72
+ end
73
+
74
+ missing.each do |env_key, tf_output|
75
+ puts "⚠️ terraform output '#{tf_output}' missing — leaving #{env_key} unchanged in #{path}"
76
+ end
77
+
78
+ written = {}
79
+ new_lines = existing_lines.map do |line|
80
+ key = dotenv_key(line)
81
+ if key && updates.key?(key)
82
+ written[key] = true
83
+ format_dotenv_line(key, updates[key])
84
+ else
85
+ line
86
+ end
87
+ end
88
+
89
+ updates.each do |key, value|
90
+ next if written[key]
91
+
92
+ new_lines << format_dotenv_line(key, value)
93
+ written[key] = true
94
+ end
95
+
96
+ content = "#{new_lines.join("\n")}\n"
97
+ File.write(path, content)
98
+
99
+ {
100
+ path: path,
101
+ updated: written.keys.sort,
102
+ missing: missing.map(&:first)
103
+ }
104
+ end
105
+
106
+ def using_default_map?
107
+ @map_path.nil?
108
+ end
109
+
110
+ private
111
+
112
+ def load_map
113
+ return DEFAULT_MAP.dup if @map_path.nil?
114
+
115
+ raw = YAML.safe_load(File.read(@map_path), aliases: false)
116
+ unless raw.is_a?(Hash) && raw.any?
117
+ abort "Error: frontend env map #{@map_path} must be a non-empty YAML mapping " \
118
+ '(e.g. VITE_API_URL: api_url).'
119
+ end
120
+
121
+ raw.each_with_object({}) do |(key, value), hash|
122
+ k = key.to_s.strip
123
+ v = value.to_s.strip
124
+ next if k.empty? || v.empty?
125
+
126
+ hash[k] = v
127
+ end.tap do |parsed|
128
+ abort "Error: frontend env map #{@map_path} has no valid entries." if parsed.empty?
129
+ end
130
+ rescue Psych::SyntaxError => e
131
+ abort "Error: invalid YAML in #{@map_path}: #{e.message}"
132
+ end
133
+
134
+ def each_resolved
135
+ map.each do |env_key, tf_output|
136
+ yield env_key, fetch_tf_output(tf_output), tf_output
137
+ end
138
+ end
139
+
140
+ def fetch_tf_output(name)
141
+ return @tf_cache[name] if @tf_cache.key?(name)
142
+
143
+ @tf_cache[name] = read_tf_output(name)
144
+ end
145
+
146
+ def read_tf_output(name)
147
+ return nil unless Dir.exist?(@env_dir)
148
+
149
+ output, status = Open3.capture2(
150
+ 'terraform', 'output', '-raw', name,
151
+ chdir: @env_dir,
152
+ err: File::NULL
153
+ )
154
+ return nil unless status.success?
155
+
156
+ value = output.strip
157
+ return nil if value.empty? || value == 'null'
158
+
159
+ value
160
+ rescue Errno::ENOENT
161
+ nil
162
+ end
163
+
164
+ # Match KEY=value lines; ignore comments and export prefixes.
165
+ def dotenv_key(line)
166
+ return nil if line.strip.empty? || line.lstrip.start_with?('#')
167
+
168
+ stripped = line.strip
169
+ stripped = stripped.sub(/\Aexport\s+/, '')
170
+ return nil unless stripped =~ /\A([A-Za-z_][A-Za-z0-9_]*)=/
171
+
172
+ Regexp.last_match(1)
173
+ end
174
+
175
+ def format_dotenv_line(key, value)
176
+ "#{key}=#{escape_dotenv_value(value)}"
177
+ end
178
+
179
+ def escape_dotenv_value(value)
180
+ str = value.to_s
181
+ return str if str.empty?
182
+ return str unless str.match?(/[\s\#"'\\$`]/)
183
+
184
+ %("#{str.gsub('\\', '\\\\').gsub('"', '\\"')}")
185
+ end
186
+ end
187
+ end
188
+ end
@@ -14,7 +14,11 @@ module Belt
14
14
  non_param = segments.reject { |s| s.start_with?(':', '{') }
15
15
  return gateway.name if non_param.empty?
16
16
 
17
- return non_param.map { |s| s.gsub('-', '_') }.join('/') if route.resource? && nested_resource?(segments)
17
+ # Nested resources (/posts/{id}/comments) and scoped resources (/admin/users):
18
+ # join non-param segments → posts/comments, admin/users
19
+ if route.resource? && non_param.length > 1
20
+ return non_param.map { |s| s.gsub('-', '_') }.join('/')
21
+ end
18
22
 
19
23
  # For non-resource routes with a single segment (e.g., post '/signup' in :onboarding),
20
24
  # the segment is the action name, not the controller. Use the gateway name as controller.
@@ -3,6 +3,7 @@
3
3
  require 'base64'
4
4
  require 'json'
5
5
  require_relative 'app_detection'
6
+ require_relative 'frontend_env_map'
6
7
  require_relative 'terraform_command'
7
8
 
8
9
  module Belt
@@ -50,13 +51,14 @@ module Belt
50
51
 
51
52
  Behavior:
52
53
  • If frontend/ exists → runs the frontend dev server (npm run dev)
53
- Automatically sets VITE_API_URL from terraform outputs if deployed.
54
+ Injects env from frontend/env.yml (or default VITE_API_URL) using
55
+ terraform outputs when available.
54
56
  • If no frontend → serves the welcome page via a local HTTP server
55
57
  After deploy, shows live API URL and deployment status.
56
58
 
57
59
  Note: The backend is serverless (AWS Lambda). Use `belt deploy` to deploy
58
- your backend to AWS. Local frontend development automatically points to
59
- your deployed API via VITE_API_URL.
60
+ your backend to AWS. Local frontend development reads the env map (or
61
+ frontend/.env via `belt frontend env <env>`).
60
62
 
61
63
  Examples:
62
64
  belt server # Start on port #{DEFAULT_PORT}
@@ -84,17 +86,22 @@ module Belt
84
86
 
85
87
  def run_frontend_dev_server
86
88
  puts "🚀 Starting frontend dev server on port #{@port}..."
87
- if @api_url
88
- puts " Backend API: #{@api_url}"
89
+ build_env = frontend_process_env
90
+ api_url = build_env['VITE_API_URL'] || build_env['REACT_APP_API_URL'] ||
91
+ build_env['NEXT_PUBLIC_API_URL'] || @api_url
92
+ if api_url
93
+ puts " Backend API: #{api_url}"
89
94
  else
90
95
  puts ' Backend is serverless — deploy with `belt deploy` to set up AWS resources.'
91
96
  end
97
+ if build_env.any?
98
+ puts " Env: #{build_env.keys.sort.join(', ')}"
99
+ end
92
100
  puts ''
93
101
 
94
102
  open_browser_later if @open_browser
95
103
 
96
- env = { 'PORT' => @port.to_s }
97
- env['VITE_API_URL'] = @api_url if @api_url
104
+ env = { 'PORT' => @port.to_s }.merge(build_env)
98
105
 
99
106
  # Prefer the dev script with the port flag for Vite-based setups
100
107
  Dir.chdir('frontend') do
@@ -102,6 +109,17 @@ module Belt
102
109
  end
103
110
  end
104
111
 
112
+ # Process env from the declarative map (or default VITE_API_URL), if an env is available.
113
+ def frontend_process_env
114
+ env_name = @deploy_env || ENV.fetch('BELT_ENV', nil) || TerraformCommand.list_environments.first
115
+ return {} unless env_name
116
+
117
+ FrontendEnvMap.new(env_name).process_env
118
+ rescue StandardError
119
+ # Fall back to legacy api_url detection if map resolution fails
120
+ @api_url ? { 'VITE_API_URL' => @api_url } : {}
121
+ end
122
+
105
123
  def run_welcome_server
106
124
  if @api_url
107
125
  puts "🚀 Serving Belt welcome page on http://localhost:#{@port}"
data/lib/belt/cli.rb CHANGED
@@ -9,6 +9,7 @@ require_relative 'cli/destroy_command'
9
9
  require_relative 'cli/frontend_command'
10
10
  require_relative 'cli/frontend_setup_command'
11
11
  require_relative 'cli/frontend_deploy_command'
12
+ require_relative 'cli/frontend_env_command'
12
13
  require_relative 'cli/views_command'
13
14
  require_relative 'cli/setup_command'
14
15
  require_relative 'cli/terraform_command'
@@ -29,10 +30,12 @@ module Belt
29
30
  %w[tasks --tasks -T] => Belt::CLI::TasksCommand,
30
31
  'setup' => Belt::CLI::SetupCommand,
31
32
  'deploy' => Belt::CLI::DeployCommand,
33
+ 'frontend' => Belt::CLI::FrontendEnvCommand,
32
34
  %w[server s] => Belt::CLI::ServerCommand,
33
35
  %w[version --version -v] => ->(_args) { puts "Belt #{Belt::VERSION}" }
34
36
  }.freeze
35
37
 
38
+
36
39
  COMMANDS = COMMANDS_DEFINITION.each_with_object({}) do |(keys, handler), hash|
37
40
  Array(keys).each { |key| hash[key] = handler }
38
41
  end.freeze
@@ -99,7 +102,9 @@ module Belt
99
102
  s Alias for server
100
103
  deploy [environment] Deploy to AWS (init → plan → apply)
101
104
  deploy frontend <env> Build and deploy frontend to AWS
105
+ frontend env <env> Write frontend/.env from terraform outputs
102
106
  routes [-g PATTERN] [-f json] Show route definitions
107
+
103
108
  console Start an interactive console (IRB)
104
109
  c Alias for console
105
110
  tasks [-g PATTERN] [-a] List available rake tasks
@@ -133,6 +138,7 @@ module Belt
133
138
  belt deploy # Deploy dev to AWS
134
139
  belt deploy prod --auto # Deploy prod without confirmation
135
140
  belt deploy frontend wups
141
+ belt frontend env wups # Smart-merge TF outputs into frontend/.env
136
142
  belt setup frontend wups
137
143
  belt apply wups
138
144
  belt tasks # list all rake tasks
@@ -174,20 +174,22 @@ module Belt
174
174
  resource_name = name.to_s
175
175
  singular = singularize(resource_name)
176
176
  param_name = options[:param] || "#{singular}_id"
177
+ base_path = resource_base_path(resource_name, options)
178
+ options = options.except(:path_prefix)
177
179
  options = auto_infer_tables(resource_name, options)
178
180
  resource_options = options.merge(route_type: :resources)
179
181
  actions = determine_actions(options)
180
182
 
181
- add_route(:get, "/#{resource_name}", resource_options) if actions.include?(:index)
182
- add_route(:post, "/#{resource_name}", resource_options) if actions.include?(:create)
183
- add_route(:get, "/#{resource_name}/{#{param_name}}", resource_options) if actions.include?(:show)
184
- add_route(:put, "/#{resource_name}/{#{param_name}}", resource_options) if actions.include?(:update)
185
- add_route(:delete, "/#{resource_name}/{#{param_name}}", resource_options) if actions.include?(:destroy)
183
+ add_route(:get, base_path, resource_options) if actions.include?(:index)
184
+ add_route(:post, base_path, resource_options) if actions.include?(:create)
185
+ add_route(:get, "#{base_path}/{#{param_name}}", resource_options) if actions.include?(:show)
186
+ add_route(:put, "#{base_path}/{#{param_name}}", resource_options) if actions.include?(:update)
187
+ add_route(:delete, "#{base_path}/{#{param_name}}", resource_options) if actions.include?(:destroy)
186
188
 
187
189
  return unless block_given?
188
190
 
189
- collection_prefix = "/#{resource_name}"
190
- member_prefix = "/#{resource_name}/{#{param_name}}"
191
+ collection_prefix = base_path
192
+ member_prefix = "#{base_path}/{#{param_name}}"
191
193
  resource_tables = Array(options[:tables] || [])
192
194
  inherited_tables = (@default_tables + resource_tables).uniq
193
195
  inherited_auth = options[:auth] || @default_auth
@@ -199,17 +201,25 @@ module Belt
199
201
 
200
202
  def resource(name, options = {})
201
203
  resource_name = name.to_s
204
+ base_path = resource_base_path(resource_name, options)
205
+ options = options.except(:path_prefix)
202
206
  actions = determine_actions(options, default: %i[show update destroy])
203
207
  resource_options = options.merge(route_type: :resource)
204
208
 
205
- add_route(:get, "/#{resource_name}", resource_options) if actions.include?(:show)
206
- add_route(:put, "/#{resource_name}", resource_options) if actions.include?(:update)
207
- add_route(:delete, "/#{resource_name}", resource_options) if actions.include?(:destroy)
208
- add_route(:post, "/#{resource_name}", resource_options) if actions.include?(:create)
209
+ add_route(:get, base_path, resource_options) if actions.include?(:show)
210
+ add_route(:put, base_path, resource_options) if actions.include?(:update)
211
+ add_route(:delete, base_path, resource_options) if actions.include?(:destroy)
212
+ add_route(:post, base_path, resource_options) if actions.include?(:create)
209
213
  end
210
214
 
211
215
  private
212
216
 
217
+ # Builds "/users" or "/admin/users" when path_prefix is set (from scope path:).
218
+ def resource_base_path(resource_name, options)
219
+ prefix = options[:path_prefix].to_s.gsub(%r{^/|/$}, '')
220
+ prefix.empty? ? "/#{resource_name}" : "/#{prefix}/#{resource_name}"
221
+ end
222
+
213
223
  def add_route(method, path, options = {})
214
224
  lambda_to_use = options[:lambda] || @current_lambda_context || @default_lambda
215
225
  route_tables = Array(options[:tables] || [])
@@ -308,7 +318,11 @@ module Belt
308
318
  previous_tables = @scope_tables
309
319
  previous_controller = @scope_controller
310
320
 
311
- @scope_prefix = options[:path] || @scope_prefix
321
+ # Nest path segments (Rails-style): scope path: "a" { scope path: "b" } → "a/b"
322
+ if options.key?(:path)
323
+ segment = options[:path].to_s.gsub(%r{^/|/$}, '')
324
+ @scope_prefix = @scope_prefix.to_s.empty? ? segment : "#{@scope_prefix}/#{segment}"
325
+ end
312
326
  @scope_module = options[:module] || @scope_module
313
327
  @scope_auth = options[:auth] || @scope_auth
314
328
  @scope_tables = (@scope_tables + Array(options[:tables] || [])).uniq
@@ -340,11 +354,13 @@ module Belt
340
354
 
341
355
  def resources(name, options = {}, &)
342
356
  options = apply_scope_options(options)
357
+ options = options.merge(path_prefix: @scope_prefix) unless @scope_prefix.to_s.empty?
343
358
  @gateway.resources(name, options, &)
344
359
  end
345
360
 
346
361
  def resource(name, options = {})
347
362
  options = apply_scope_options(options)
363
+ options = options.merge(path_prefix: @scope_prefix) unless @scope_prefix.to_s.empty?
348
364
  @gateway.resource(name, options)
349
365
  end
350
366
 
@@ -400,6 +416,7 @@ module Belt
400
416
  result = options.dup
401
417
  result[:auth] ||= @scope_auth if @scope_auth
402
418
  result[:lambda] ||= @scope_module if @scope_module
419
+ result[:controller] ||= @scope_controller if @scope_controller
403
420
  result[:tables] = (@scope_tables + Array(result[:tables] || [])).uniq if @scope_tables.any? || result[:tables]
404
421
  result
405
422
  end
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.1.9'
4
+ VERSION = '0.1.11'
5
5
  end
@@ -0,0 +1,13 @@
1
+ # Declarative frontend env map for belt.
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.
4
+ #
5
+ # belt deploy frontend <env> → injects these into npm run build
6
+ # belt frontend env <env> → smart-merges into frontend/.env for local dev
7
+ #
8
+ # Without this file, belt only sets VITE_API_URL from api_url.
9
+
10
+ VITE_API_URL: api_url
11
+ # VITE_COGNITO_USER_POOL_ID: cognito_user_pool_id
12
+ # VITE_COGNITO_CLIENT_ID: cognito_user_pool_client_id
13
+ # VITE_AWS_REGION: cognito_region
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: belt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.9
4
+ version: 0.1.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -78,6 +78,8 @@ files:
78
78
  - lib/belt/cli/environment_command.rb
79
79
  - lib/belt/cli/frontend_command.rb
80
80
  - lib/belt/cli/frontend_deploy_command.rb
81
+ - lib/belt/cli/frontend_env_command.rb
82
+ - lib/belt/cli/frontend_env_map.rb
81
83
  - lib/belt/cli/frontend_setup_command.rb
82
84
  - lib/belt/cli/generate_command.rb
83
85
  - lib/belt/cli/new_command.rb
@@ -110,6 +112,7 @@ files:
110
112
  - lib/templates/environment/outputs.tf.erb
111
113
  - lib/templates/environment/terraform.tfvars.erb
112
114
  - lib/templates/environment/variables.tf.erb
115
+ - lib/templates/frontend/react/env.yml.example
113
116
  - lib/templates/frontend/react/index.html.erb
114
117
  - lib/templates/frontend/react/package.json.erb
115
118
  - lib/templates/frontend/react/src/App.jsx