belt 0.2.17 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +241 -0
  3. data/README.md +42 -3
  4. data/lib/belt/action_router.rb +34 -19
  5. data/lib/belt/cli/app_detection.rb +30 -3
  6. data/lib/belt/cli/auth_command.rb +395 -0
  7. data/lib/belt/cli/console_command.rb +17 -2
  8. data/lib/belt/cli/deploy_command.rb +51 -17
  9. data/lib/belt/cli/destroy_command.rb +62 -25
  10. data/lib/belt/cli/doctor_command.rb +166 -22
  11. data/lib/belt/cli/environment_command.rb +12 -0
  12. data/lib/belt/cli/explain_command.rb +112 -0
  13. data/lib/belt/cli/frontend_command.rb +31 -10
  14. data/lib/belt/cli/frontend_deploy_command.rb +110 -21
  15. data/lib/belt/cli/frontend_env_command.rb +56 -19
  16. data/lib/belt/cli/frontend_env_map.rb +30 -10
  17. data/lib/belt/cli/frontend_registry.rb +373 -0
  18. data/lib/belt/cli/frontend_setup_command.rb +71 -5
  19. data/lib/belt/cli/generate_command.rb +61 -42
  20. data/lib/belt/cli/index_command.rb +192 -0
  21. data/lib/belt/cli/lambda_config_command.rb +9 -3
  22. data/lib/belt/cli/routes_command/request_model_inference.rb +131 -0
  23. data/lib/belt/cli/routes_command/route_inference.rb +4 -3
  24. data/lib/belt/cli/routes_command.rb +31 -19
  25. data/lib/belt/cli/server_command.rb +28 -15
  26. data/lib/belt/cli/tables_command.rb +3 -1
  27. data/lib/belt/cli/terraform_command.rb +9 -0
  28. data/lib/belt/cli/views_command.rb +15 -15
  29. data/lib/belt/cli/zip_artifact_builder.rb +199 -0
  30. data/lib/belt/cli.rb +17 -9
  31. data/lib/belt/docs/backups.md +101 -0
  32. data/lib/belt/docs/console.md +65 -0
  33. data/lib/belt/docs/controllers.md +155 -0
  34. data/lib/belt/docs/deployment.md +166 -0
  35. data/lib/belt/docs/frontend.md +103 -0
  36. data/lib/belt/docs/generators.md +137 -0
  37. data/lib/belt/docs/lambda_handler.md +105 -0
  38. data/lib/belt/docs/models.md +160 -0
  39. data/lib/belt/docs/observability.md +94 -0
  40. data/lib/belt/docs/plugins.md +94 -0
  41. data/lib/belt/docs/routing.md +138 -0
  42. data/lib/belt/docs/structure.md +96 -0
  43. data/lib/belt/inflector.rb +8 -0
  44. data/lib/belt/route_dsl.rb +318 -100
  45. data/lib/belt/version.rb +1 -1
  46. data/lib/templates/frontend/react/env.yml.example +2 -2
  47. data/lib/templates/generate/auth/cognito.tf.erb +71 -0
  48. data/lib/templates/generate/auth/cognito_outputs.tf.erb +19 -0
  49. data/lib/templates/generate/auth/frontend/ConfirmEmail.jsx +55 -0
  50. data/lib/templates/generate/auth/frontend/Login.jsx +79 -0
  51. data/lib/templates/generate/auth/frontend/ProtectedRoute.jsx +9 -0
  52. data/lib/templates/generate/auth/frontend/SignUp.jsx +58 -0
  53. data/lib/templates/generate/auth/frontend/apiClient.js +34 -0
  54. data/lib/templates/generate/auth/frontend/auth.css +157 -0
  55. data/lib/templates/generate/auth/frontend/auth.js +134 -0
  56. data/lib/templates/generate/model.rb.erb +4 -6
  57. data/lib/templates/module/frontend.tf.erb +49 -43
  58. data/lib/templates/module/outputs.tf.erb +1 -1
  59. data/lib/templates/new_app/AGENTS.md.erb +170 -20
  60. data/lib/templates/new_app/config/lambda/api.yml.erb +1 -0
  61. data/lib/templates/new_app/config/routes.rb.erb +8 -3
  62. data/lib/templates/new_app/lambda/api.rb.erb +1 -1
  63. metadata +56 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e1813b72986b167b9449f438b18331cc2e9be46cffa58bd059c403f3c35703e
4
- data.tar.gz: 26c3aae3c049bbebff471d6a8a514114a26f8b2d0fdf4671cb154ae9a056798f
3
+ metadata.gz: 491ae37769b6a21cfd1823301d425d0af0b3f1e3c19952b909febebc7f2c3b32
4
+ data.tar.gz: a2427f68a34d38be0cf3af42ac33a8d3c3d190f36505c54ef531219cd0c3fb66
5
5
  SHA512:
6
- metadata.gz: 23bbf9977c0cd765c7a1630e7a74ed480db6d62ee07c22e95e6a491a3a9c04d824b8463b0ef63ae4c4b343e13e96ff26c9edb5c86c8005fce7f0a167b6894c64
7
- data.tar.gz: e7f625fe93dcb125289530e2a3bdb9dee8c37938eeea41bc9154a31aca6b47675a6336e39c986f3a801719f4cf22052bad40968ff6e1b9a82936140f74432f4a
6
+ metadata.gz: 895fb4f7238141ba4752cdfc9200c44b2ef870bbc61585a436d3a9e36f39c689e8429d97ad4cf0101d92cbea06b3771864c1d31e4892dae54448a2f0e8ec138b
7
+ data.tar.gz: 4b2fee884c2f7ad9ba78bd7a46dacf75d1d5c8aa5da43d151ab7d03889aa740dd60a12a1fce5c18467f7c3bbb54aacb886a55efdcc58a997ed6367148ea37f15
data/CHANGELOG.md CHANGED
@@ -1,5 +1,246 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.15
4
+
5
+ ### Bug Fix
6
+
7
+ - **`belt generate auth` NEW_PASSWORD_REQUIRED challenge sent `email` even when
8
+ the user already had it**: The 0.3.14 fix always passed `{ email }` to
9
+ `completeNewPasswordChallenge`. That fixed pools where `email` was missing, but
10
+ broke the common case where the user already has `email` set (e.g. created via
11
+ `admin-create-user` with an email) — Cognito rejects that with a 400
12
+ `NotAuthorizedException: Cannot modify an already provided email`. The template
13
+ now reads the `requiredAttributes` list Cognito provides in the
14
+ `newPasswordRequired` callback and supplies `email` **only when Cognito actually
15
+ requires it**. Users with email already set complete the challenge without
16
+ re-sending it; users missing email still get it supplied. Handles both cases.
17
+
18
+ ## 0.3.14
19
+
20
+ ### Bug Fix
21
+
22
+ - **`belt generate auth` NEW_PASSWORD_REQUIRED challenge dropped the email
23
+ attribute**: The generated `auth.js` `completeNewPassword` passed an empty
24
+ attributes object (`{}`) to `completeNewPasswordChallenge`. On user pools that
25
+ require `email`, admin-created users completing the `NEW_PASSWORD_REQUIRED`
26
+ challenge (which don't have `email` set yet) got a 400 `Invalid attributes
27
+ given, email is missing`. The template now supplies `{ email }` (the username
28
+ is the email) so the challenge completes. `Login.jsx` already passed the email
29
+ in, so no change was needed there.
30
+
31
+ ## 0.3.13
32
+
33
+ ### Security Fix
34
+
35
+ - **`belt generate auth` sent passwords in plaintext**: The generated `auth.js`
36
+ used the raw `@aws-sdk/client-cognito-identity-provider` with
37
+ `AuthFlow: 'USER_PASSWORD_AUTH'`, which puts the user's cleartext `PASSWORD` in
38
+ the `InitiateAuth` request payload (visible in devtools, proxies, and any request
39
+ logging). The template now uses `amazon-cognito-identity-js`, which authenticates
40
+ via SRP (Secure Remote Password) — the password never leaves the browser, only the
41
+ `SRP_A` proof is transmitted. `signIn` and the `NEW_PASSWORD_REQUIRED` challenge now
42
+ run over SRP; `signUp`/`confirmSignUp` are unchanged in behavior but route through the
43
+ same library.
44
+ - The generator now installs `amazon-cognito-identity-js` instead of
45
+ `@aws-sdk/client-cognito-identity-provider`.
46
+ - SRP requires the User Pool ID client-side, so `auth.js` now reads
47
+ `VITE_COGNITO_USER_POOL_ID` (already emitted by the auth generator's terraform
48
+ outputs and present in `env.yml.example`).
49
+
50
+ ## 0.3.12
51
+
52
+ ### Bug Fix
53
+
54
+ - **`env.yml.example` Cognito client ID typo**: The example env map referenced
55
+ `cognito_user_pool_client_id` as the terraform output name for
56
+ `VITE_COGNITO_CLIENT_ID`, but the correct output name is `cognito_client_id`.
57
+ Users following the tutorial would uncomment the line and get a missing-output
58
+ warning on deploy. Fixed in the template, CLI help text, and docs.
59
+
60
+ ## 0.3.11
61
+
62
+ ### Bug Fix
63
+
64
+ - **`belt deploy` skips route generation on first deploy**: The `generate_routes`
65
+ method would silently bail if `lambda/lib/routes/` didn't already exist on disk.
66
+ Since this directory is gitignored (it's a generated build artifact), a fresh clone
67
+ or first deploy would package the Lambda without route manifests, causing a
68
+ `LoadError` at cold start. Now creates the directory with `mkdir_p` before running
69
+ route generation.
70
+
71
+ ## 0.3.10
72
+
73
+ ### Bug Fix
74
+
75
+ - **`api_url` output missing base path for custom domains**: When a custom domain is
76
+ configured (e.g. `api.dev.example.com`), the `api_url` terraform output was missing
77
+ the gateway's base path segment. Conveyor-belt maps each gateway to a base path
78
+ (gateway "api" → path `/api`), so the correct URL is
79
+ `https://api.dev.example.com/api` not `https://api.dev.example.com`. The frontend
80
+ would get the wrong `VITE_API_URL` and hit a 403 on the custom domain root.
81
+
82
+ ## 0.3.9
83
+
84
+ ### Bug Fix
85
+
86
+ - **`belt setup frontend` with underscored app names**: The 0.3.8 fix placed
87
+ `local.s3_safe_name` in `dns.tf` but `belt setup frontend` only regenerates
88
+ `frontend.tf`, causing an "undeclared local value" error on existing apps.
89
+ The bucket name now uses inline `replace(var.app_name, "_", "-")` directly in
90
+ `frontend.tf`, making it fully self-contained with no cross-file dependency.
91
+
92
+ ## 0.3.8
93
+
94
+ ### Bug Fix
95
+
96
+ - **S3 bucket naming with underscored app names**: The module template now sanitizes
97
+ `var.app_name` for S3 bucket names by replacing underscores with hyphens. Apps named
98
+ with underscores (e.g. `feature_parity`) previously generated invalid bucket names
99
+ since S3 follows DNS naming rules which prohibit underscores.
100
+
101
+ ## 0.3.7
102
+
103
+ ### Bug Fix
104
+
105
+ - **App name detection in worktrees**: `detect_app_name` now checks
106
+ `infrastructure/*/variables.tf` for `variable "app_name" { default = "..." }`
107
+ before falling back to the directory name. Fixes incorrect Lambda function
108
+ lookups (e.g. `belt logs`) when running from a git worktree whose directory
109
+ name doesn't match the actual app name.
110
+
111
+ ## 0.3.6
112
+
113
+ ### Enhancement
114
+
115
+ - **Per-action request_model**: `request_model` on `resources` now accepts a Hash
116
+ to specify different validation schemas per action:
117
+ ```ruby
118
+ resources :items, request_model: { create: :create_item, update: :update_item }
119
+ ```
120
+ Symbol style (uniform for all body verbs) still works unchanged.
121
+
122
+ - **Convention-based request_model inference**: When no explicit `request_model` is
123
+ set on a resource route, `belt routes` auto-discovers matching contracts from
124
+ `contracts.rb` using a naming cascade:
125
+ 1. `:<verb>_<gateway>_<singular_resource>` (e.g. `:create_customer_item`)
126
+ 2. `:<verb>_<singular_resource>` (e.g. `:create_item`)
127
+
128
+ Only applies to body-accepting verbs (POST/PUT/PATCH). Explicit `request_model:`
129
+ always takes priority. No matching contract = no validation (same as before).
130
+
131
+ - **Convention-based response_model inference**: When no explicit `response_model` is
132
+ set on a resource route, `belt routes` auto-discovers a matching response model
133
+ from `contracts.rb` using the singular resource name.
134
+ e.g. `resources :items` → looks for `model :item` → auto-wires `response_model: :item`.
135
+ Applies to all verbs. Explicit `response_model:` always takes priority.
136
+
137
+ ## 0.3.5
138
+
139
+ ### Enhancement
140
+
141
+ - **Multiple frontends**: Frontend directory is no longer hardcoded to `frontend/`.
142
+ Declare named SPAs in `config/frontends.yml` (path, dist dir, terraform output names).
143
+ `belt generate frontend react --name ops --path ops-app` scaffolds an additional app.
144
+ Generators, `belt server`, `belt frontend env`, and `belt deploy frontend` accept
145
+ `--frontend NAME`. `belt deploy frontend <env>` deploys all configured frontends;
146
+ pass `--frontend` to target one. Existing single-`frontend/` apps keep working
147
+ with no config file.
148
+ - **Sidecar Lambda zips**: `belt deploy` / `belt plan` / `belt apply` build zip
149
+ artifacts that Terraform `filebase64sha256` needs at plan time (e.g. Stowzilla's
150
+ Node `image-processor`). Node packages are installed in Docker on linux/amd64
151
+ so native addons match Lambda. Missing zips are built automatically; unchanged
152
+ sources skip the rebuild.
153
+
154
+ ### Bug fix
155
+
156
+ - **Frontend CloudFront invalidation**: `belt deploy frontend` no longer prints
157
+ terraform's "output not found" error when a named frontend sets
158
+ `cloudfront_domain_output` instead of a distribution-id output. Missing
159
+ terraform outputs are read with stderr silenced.
160
+
161
+ ## 0.3.4
162
+
163
+ ### Enhancement
164
+
165
+ - **Flexible config file extensions**: Lambda config files (`config/lambda/*.yml`) and frontend env maps (`frontend/env.yml`, `.belt/frontend_env.yml`) now also accept `.yaml` as a file extension. (#1124)
166
+
167
+ ## 0.3.2
168
+
169
+ ### Bug fix
170
+
171
+ - **Table name normalization**: Route JSON output now uses hyphens instead of underscores in inferred table names, matching the ActiveItem convention (e.g., `user-sessions` instead of `user_sessions`). (#51)
172
+
173
+ ## 0.3.1
174
+
175
+ Version bump only — tagged release for internal tracking.
176
+
177
+ ## 0.3.0
178
+
179
+ Version bump only — no code changes from 0.2.21.
180
+
181
+ **Why 0.3.0 instead of another patch?** The route DSL changes in 0.2.21 (`gateway`/`function`/`namespace`/`scope`) were a minor-version level change (new features, new keywords). RubyGems doesn't allow republishing the same version, so we're bumping to 0.3.0 to correct the semver trajectory.
182
+
183
+ If you're on 0.2.21, you already have the DSL changes. This release is purely for version hygiene.
184
+
185
+ ## 0.2.21
186
+
187
+ ### Route DSL: `gateway`, `function`, `namespace`, and `scope` keywords
188
+
189
+ The route DSL now uses terminology that matches AWS infrastructure:
190
+
191
+ - **`gateway`** — Creates an API Gateway with a default Lambda function of the same name (replaces top-level `namespace`)
192
+ - **`function`** — Targets enclosed routes to a different Lambda function
193
+ - **`namespace`** — Rails-like path + module prefix (e.g., `/admin/users` with `admin/users` controller). Does NOT change Lambda target.
194
+ - **`scope`** — Flexible grouping with `path:`, `module:`, `auth:`, `tables:` options. Does NOT change Lambda target.
195
+
196
+ ```ruby
197
+ Belt.application.routes.draw do
198
+ gateway :api, auth: :cognito do
199
+ resources :posts # → lambda: "api", path: /posts
200
+
201
+ function :onboarding do
202
+ resources :steps # → lambda: "onboarding", path: /steps
203
+ end
204
+
205
+ namespace :admin do
206
+ resources :users # → lambda: "api", path: /admin/users, controller: "admin/users"
207
+ end
208
+
209
+ scope path: 'v2', module: 'v2' do
210
+ resources :widgets # → lambda: "api", path: /v2/widgets, controller: "v2/widgets"
211
+ end
212
+ end
213
+ end
214
+ ```
215
+
216
+ **ActionRouter** now accepts `gateway:` keyword (preferred):
217
+
218
+ ```ruby
219
+ ROUTER = Belt::ActionRouter.new(routes: Routes::API, gateway: 'api')
220
+ ```
221
+
222
+ **100% backward compatible:** `namespace :api do` at top level still works (aliased to `gateway`), and `ActionRouter.new(namespace: 'api')` still works.
223
+
224
+ ## 0.2.18
225
+
226
+ ### New generator: `belt generate auth`
227
+
228
+ Scaffolds Cognito user pool infrastructure for authentication. Creates the user pool, client, and wires `cognito_user_pool_arns` into the conveyor-belt resource automatically.
229
+
230
+ ```bash
231
+ belt g auth # Single pool: "main"
232
+ belt g auth web mobile # Multiple pools (e.g., web + mobile clients)
233
+ belt g auth web android ios # Three pools
234
+ belt destroy auth # Remove generated files
235
+ ```
236
+
237
+ What it generates:
238
+ - `infrastructure/modules/app/cognito.tf` — User pool + client with sensible defaults (password policy, email verification, deletion protection in prod)
239
+ - `infrastructure/modules/app/cognito_outputs.tf` — Pool ID, ARN, and client ID outputs
240
+ - Patches `main.tf` to pass `cognito_user_pool_arns` to the conveyor-belt resource
241
+
242
+ Multiple pools are supported out of the box — each gets a suffixed name (e.g., `myapp-prod-web`, `myapp-prod-mobile`) and its own set of outputs.
243
+
3
244
  ## 0.2.12
4
245
 
5
246
  ### Rename: `routes.tf.rb` → `routes.rb`, `schema.tf.rb` → `contracts.rb`
data/README.md CHANGED
@@ -105,7 +105,7 @@ require "belt"
105
105
 
106
106
  include Belt::LambdaHandler
107
107
 
108
- ROUTER = Belt::ActionRouter.new(routes: Routes::API, namespace: "api")
108
+ ROUTER = Belt::ActionRouter.new(routes: Routes::API, gateway: "api")
109
109
 
110
110
  def execute(path:, body:, event:)
111
111
  ROUTER.route(event: event, body: body)
@@ -140,7 +140,7 @@ Define routes in `infrastructure/routes.tf.rb`:
140
140
 
141
141
  ```ruby
142
142
  Belt.application.routes.draw do
143
- namespace :api do
143
+ gateway :api do
144
144
  resources :posts, only: [:index, :show, :create]
145
145
  end
146
146
  end
@@ -170,6 +170,45 @@ The provider will:
170
170
  - Generate IAM policies for DynamoDB table access
171
171
  - Set up CloudWatch log groups
172
172
 
173
+ ### Route DSL Keywords
174
+
175
+ The routes DSL has four keywords that map to infrastructure and code organization:
176
+
177
+ | Keyword | Purpose | Affects Lambda? |
178
+ |---------|---------|-----------------|
179
+ | `gateway` | Creates an API Gateway + default Lambda | Yes — sets the default Lambda for all routes inside |
180
+ | `function` | Routes to a different Lambda | Yes — overrides the gateway's default |
181
+ | `namespace` | Adds path prefix + controller module | No — Rails-like code organization only |
182
+ | `scope` | Flexible path/module/auth grouping | No — grouping and shared options only |
183
+
184
+ **Example combining all four:**
185
+
186
+ ```ruby
187
+ Belt.application.routes.draw do
188
+ gateway :api, auth: :cognito do
189
+ resources :posts # → lambda: api, path: /posts, controller: posts
190
+
191
+ namespace :admin do
192
+ resources :users # → lambda: api, path: /admin/users, controller: admin/users
193
+ end
194
+
195
+ function :worker do
196
+ resources :jobs # → lambda: worker, path: /jobs, controller: jobs
197
+
198
+ namespace :internal do
199
+ resources :tasks # → lambda: worker, path: /internal/tasks, controller: internal/tasks
200
+ end
201
+ end
202
+
203
+ scope path: 'v2', module: 'legacy' do
204
+ resources :widgets # → lambda: api, path: /v2/widgets, controller: legacy/widgets
205
+ end
206
+ end
207
+ end
208
+ ```
209
+
210
+ **Key point:** `namespace` and `scope` are purely organizational — they affect URL paths and controller module resolution but never change which Lambda handles the request. Use `function` when you need routes to go to a different Lambda.
211
+
173
212
  ## BeltController Features
174
213
 
175
214
  ### Callbacks
@@ -423,7 +462,7 @@ The command expects `infrastructure/routes.tf.rb` in the current working directo
423
462
 
424
463
  ```ruby
425
464
  Belt.application.routes.draw do
426
- namespace :api do
465
+ gateway :api do
427
466
  resources :posts, only: [:index, :show, :create, :destroy]
428
467
  resource :profile, only: [:show, :update]
429
468
  get "health", action: :health
@@ -7,28 +7,39 @@ module Belt
7
7
  # Routes incoming requests to controllers based on a route manifest.
8
8
  #
9
9
  # Usage:
10
- # ROUTER = Belt::ActionRouter.new(routes: MY_ROUTES, namespace: "api")
10
+ # ROUTER = Belt::ActionRouter.new(routes: MY_ROUTES, gateway: "api")
11
11
  # response = ROUTER.route(event: event, body: body)
12
12
  #
13
13
  class ActionRouter
14
14
  class RouteNotFound < StandardError; end
15
15
 
16
- def initialize(routes:, namespace:)
17
- @namespace = namespace.to_s
18
- @namespace_module_name = "#{@namespace.split('_').map(&:capitalize).join}Controllers"
16
+ def initialize(routes:, gateway: nil, namespace: nil)
17
+ # Accept `gateway:` (preferred) or legacy `namespace:` keyword
18
+ gw = gateway || namespace
19
+ raise ArgumentError, 'Belt::ActionRouter requires a gateway: (or legacy namespace:) argument' unless gw
20
+
21
+ @gateway = gw.to_s
22
+ @gateway_module_name = "#{@gateway.split('_').map(&:capitalize).join}Controllers"
19
23
  @routes = build_route_table(routes)
20
24
  end
21
25
 
22
26
  def route(event:, body:)
23
27
  method = event['httpMethod']
24
28
  full_path = event['path']
25
- match_path = strip_namespace_prefix(full_path)
29
+ match_path = strip_gateway_prefix(full_path)
26
30
 
27
31
  route_info = find_route(method, match_path)
28
32
 
29
33
  unless route_info
30
- Belt::Observability::Logger.instance&.warn('Route not found', method: method, path: full_path)
31
- 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
32
43
  end
33
44
 
34
45
  path_params = extract_path_params(route_info[:pattern], match_path)
@@ -56,10 +67,14 @@ module Belt
56
67
 
57
68
  private
58
69
 
59
- def strip_namespace_prefix(path)
70
+ def root_path?(path)
71
+ path == '/' || path.empty?
72
+ end
73
+
74
+ def strip_gateway_prefix(path)
60
75
  return '/' if path.nil?
61
76
 
62
- prefix = "/#{@namespace}"
77
+ prefix = "/#{@gateway}"
63
78
  if path.start_with?(prefix)
64
79
  stripped = path.sub(prefix, '')
65
80
  stripped.empty? ? '/' : stripped
@@ -127,15 +142,15 @@ module Belt
127
142
  return false if name.start_with?('/') || name.end_with?('/')
128
143
  return false if name.include?('\\')
129
144
 
130
- # Allow only lowercase letters, digits, underscores, and a single forward slash for nesting
145
+ # Allow only lowercase letters, digits, underscores, and forward slashes for nesting
131
146
  name.match?(%r{\A[a-z][a-z0-9_]*(/[a-z][a-z0-9_]*)?\z})
132
147
  end
133
148
 
134
149
  def resolve_controller(controller_name)
135
- # Try namespace module first (app's own controllers)
150
+ # Try gateway module first (app's own controllers)
136
151
  begin
137
- namespace_module = Object.const_get(@namespace_module_name)
138
- return resolve_from_module(namespace_module, controller_name)
152
+ gateway_module = Object.const_get(@gateway_module_name)
153
+ return resolve_from_module(gateway_module, controller_name)
139
154
  rescue NameError
140
155
  # Fall through to controller_paths lookup
141
156
  end
@@ -144,13 +159,13 @@ module Belt
144
159
  resolve_from_paths(controller_name)
145
160
  end
146
161
 
147
- def resolve_from_module(namespace_module, controller_name)
162
+ def resolve_from_module(gateway_module, controller_name)
148
163
  if controller_name.include?('/')
149
164
  parts = controller_name.split('/')
150
- parent = namespace_module.const_get(parts[0].split('_').map(&:capitalize).join)
165
+ parent = gateway_module.const_get(parts[0].split('_').map(&:capitalize).join)
151
166
  parent.const_get("#{parts[1].split('_').map(&:capitalize).join}Controller")
152
167
  else
153
- namespace_module.const_get("#{controller_name.split('_').map(&:capitalize).join}Controller")
168
+ gateway_module.const_get("#{controller_name.split('_').map(&:capitalize).join}Controller")
154
169
  end
155
170
  end
156
171
 
@@ -176,9 +191,9 @@ module Belt
176
191
  class_name = "#{controller_name.split(%r{[_/]}).map(&:capitalize).join}Controller"
177
192
  return Object.const_get(class_name) if Object.const_defined?(class_name)
178
193
 
179
- # Try under namespace module (e.g., BrablogControllers::PostsController)
180
- if Object.const_defined?(@namespace_module_name)
181
- ns = Object.const_get(@namespace_module_name)
194
+ # Try under gateway module (e.g., ApiControllers::PostsController)
195
+ if Object.const_defined?(@gateway_module_name)
196
+ ns = Object.const_get(@gateway_module_name)
182
197
  return ns.const_get(class_name) if ns.const_defined?(class_name)
183
198
  end
184
199
  end
@@ -3,19 +3,21 @@
3
3
  module Belt
4
4
  module CLI
5
5
  module AppDetection
6
- # Detects the primary namespace from the route definitions.
6
+ # Detects the primary gateway from the route definitions.
7
7
  # Used by generators to determine controller directory and route file naming.
8
8
  def detect_namespace
9
9
  routes_file = find_routes_file_path
10
10
  if routes_file && File.exist?(routes_file)
11
- match = File.read(routes_file).match(/namespace :(\w+)/)
11
+ content = File.read(routes_file)
12
+ # Prefer `gateway :name` but fall back to legacy `namespace :name` at top-level
13
+ match = content.match(/^\s*gateway :(\w+)/) || content.match(/^\s*namespace :(\w+)/)
12
14
  return match[1] if match
13
15
  end
14
16
  File.basename(Dir.pwd)
15
17
  end
16
18
 
17
19
  # Detects the application name.
18
- # Prefers terraform.tfvars app_name, then falls back to directory name.
20
+ # Priority: terraform.tfvars app_name > variables.tf default > directory name.
19
21
  def detect_app_name
20
22
  # Check any environment tfvars for the app_name
21
23
  Dir.glob('infrastructure/*/terraform.tfvars').each do |f|
@@ -24,6 +26,10 @@ module Belt
24
26
  return match[1] if match
25
27
  end
26
28
 
29
+ # Check variables.tf for a default app_name value
30
+ name = detect_app_name_from_variables_tf
31
+ return name if name
32
+
27
33
  # Fall back to directory name
28
34
  File.basename(Dir.pwd)
29
35
  end
@@ -65,6 +71,27 @@ module Belt
65
71
  def find_schema_file_path
66
72
  find_contracts_file_path
67
73
  end
74
+
75
+ private
76
+
77
+ # Parses `variable "app_name" { default = "..." }` from any environment's variables.tf.
78
+ # This handles the common pattern where app_name is a Terraform variable with a default
79
+ # rather than being set directly in terraform.tfvars.
80
+ def detect_app_name_from_variables_tf
81
+ Dir.glob('infrastructure/*/variables.tf').each do |f|
82
+ content = File.read(f)
83
+ # Match the app_name variable block and extract its default value
84
+ next unless content.match?(/variable\s+"app_name"/)
85
+
86
+ # Extract default from within the variable block
87
+ block_match = content.match(/variable\s+"app_name"\s*\{([^}]*)\}/m)
88
+ next unless block_match
89
+
90
+ default_match = block_match[1].match(/default\s*=\s*"([^"]+)"/)
91
+ return default_match[1] if default_match
92
+ end
93
+ nil
94
+ end
68
95
  end
69
96
  end
70
97
  end