belt 0.1.4 → 0.1.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.
- checksums.yaml +4 -4
- data/lib/belt/action_router.rb +7 -0
- data/lib/belt/assets/belt-default.png +0 -0
- data/lib/belt/assets/welcome.css +112 -0
- data/lib/belt/cli/deploy_command.rb +182 -0
- data/lib/belt/cli/environment_command.rb +12 -7
- data/lib/belt/cli/generate_command.rb +164 -9
- data/lib/belt/cli/new_command.rb +164 -21
- data/lib/belt/cli/server_command.rb +302 -0
- data/lib/belt/cli/setup_command.rb +40 -6
- data/lib/belt/cli.rb +12 -11
- data/lib/belt/controllers/welcome_controller.rb +102 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt.rb +1 -0
- data/lib/templates/environment/main.tf.erb +1 -0
- data/lib/templates/environment/outputs.tf.erb +9 -0
- data/lib/templates/new_app/infrastructure/routes.tf.rb.erb +1 -0
- data/lib/templates/new_app/lambda/lib/routes/routes.rb.erb +1 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 330331693d70f3a18ad04c47b4f0b07c69b6a75538291be891946e12c7863dfc
|
|
4
|
+
data.tar.gz: 643ed0cad19c1124581d86b7f816ce199d372be0415c03b53c8f9d5f990bc3a9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca9f5d87f6b8ab597106789e5a381aa3ba6f33b85cc2620552b8c9380ad5508d7f27d5ed34cea52b59858db91ab02653ee4a487044562f7c3c68739c3fa40779
|
|
7
|
+
data.tar.gz: 36cbc894c45482981d1c3ddd34f0b59dd7e5259dd5de975175af756c54488693e6b0396c44b81b396442149a70ff6067d8d40046cef749d7119d248f49d57990
|
data/lib/belt/action_router.rb
CHANGED
|
@@ -155,6 +155,13 @@ module Belt
|
|
|
155
155
|
end
|
|
156
156
|
end
|
|
157
157
|
|
|
158
|
+
# Final fallback: gem-embedded controllers under Belt:: namespace
|
|
159
|
+
class_name = "#{controller_name.split(%r{[_/]}).map(&:capitalize).join}Controller"
|
|
160
|
+
belt_class_name = "Belt::#{class_name}"
|
|
161
|
+
if Belt.const_defined?(class_name, false)
|
|
162
|
+
return Belt.const_get(class_name, false)
|
|
163
|
+
end
|
|
164
|
+
|
|
158
165
|
raise Belt::ActionNotFound, "Controller not found: #{controller_name}"
|
|
159
166
|
end
|
|
160
167
|
|
|
Binary file
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
2
|
+
body {
|
|
3
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
4
|
+
background: #f8f9fa;
|
|
5
|
+
color: #2d3748;
|
|
6
|
+
min-height: 100vh;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* Hero section — full width background image */
|
|
10
|
+
.hero {
|
|
11
|
+
position: relative;
|
|
12
|
+
width: 100%;
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
}
|
|
15
|
+
.hero-bg {
|
|
16
|
+
width: 100%;
|
|
17
|
+
height: auto;
|
|
18
|
+
display: block;
|
|
19
|
+
}
|
|
20
|
+
.overlay {
|
|
21
|
+
position: absolute;
|
|
22
|
+
top: 35%;
|
|
23
|
+
left: 50%;
|
|
24
|
+
transform: translate(-50%, -50%);
|
|
25
|
+
padding: 2rem 3rem;
|
|
26
|
+
background: rgba(255, 255, 255, 0.88);
|
|
27
|
+
backdrop-filter: blur(6px);
|
|
28
|
+
-webkit-backdrop-filter: blur(6px);
|
|
29
|
+
border-radius: 12px;
|
|
30
|
+
text-align: center;
|
|
31
|
+
max-width: 600px;
|
|
32
|
+
width: 90%;
|
|
33
|
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
|
34
|
+
}
|
|
35
|
+
.overlay h1 {
|
|
36
|
+
font-size: 2.2rem;
|
|
37
|
+
font-weight: 700;
|
|
38
|
+
color: #2d3748;
|
|
39
|
+
margin-bottom: 0.4rem;
|
|
40
|
+
}
|
|
41
|
+
.overlay .subtitle {
|
|
42
|
+
font-size: 1.1rem;
|
|
43
|
+
color: #4a5568;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Content below the hero */
|
|
47
|
+
.container {
|
|
48
|
+
max-width: 700px;
|
|
49
|
+
margin: 0 auto;
|
|
50
|
+
padding: 2rem;
|
|
51
|
+
}
|
|
52
|
+
.stack-check {
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
justify-content: center;
|
|
56
|
+
gap: 0.75rem;
|
|
57
|
+
margin-bottom: 2rem;
|
|
58
|
+
flex-wrap: wrap;
|
|
59
|
+
}
|
|
60
|
+
.check-item {
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
gap: 0.4rem;
|
|
64
|
+
padding: 0.5rem 1rem;
|
|
65
|
+
border-radius: 8px;
|
|
66
|
+
font-weight: 500;
|
|
67
|
+
}
|
|
68
|
+
.check-pass {
|
|
69
|
+
background: rgba(39, 174, 96, 0.1);
|
|
70
|
+
border: 1px solid rgba(39, 174, 96, 0.3);
|
|
71
|
+
color: #1e8449;
|
|
72
|
+
}
|
|
73
|
+
.check-warn {
|
|
74
|
+
background: rgba(243, 156, 18, 0.1);
|
|
75
|
+
border: 1px solid rgba(243, 156, 18, 0.3);
|
|
76
|
+
color: #d68910;
|
|
77
|
+
}
|
|
78
|
+
.icon { font-size: 1.1rem; }
|
|
79
|
+
.arrow { color: #718096; font-size: 1.2rem; }
|
|
80
|
+
.info {
|
|
81
|
+
background: #ffffff;
|
|
82
|
+
border: 1px solid #e2e8f0;
|
|
83
|
+
border-radius: 12px;
|
|
84
|
+
padding: 1.5rem;
|
|
85
|
+
margin-bottom: 1.5rem;
|
|
86
|
+
}
|
|
87
|
+
.info p { margin-bottom: 0.5rem; color: #4a5568; }
|
|
88
|
+
.info p:last-child { margin-bottom: 0; }
|
|
89
|
+
.info strong { color: #2d3748; }
|
|
90
|
+
.info code {
|
|
91
|
+
background: rgba(99, 45, 145, 0.08);
|
|
92
|
+
padding: 0.15rem 0.4rem;
|
|
93
|
+
border-radius: 4px;
|
|
94
|
+
color: #632d91;
|
|
95
|
+
font-size: 0.85rem;
|
|
96
|
+
}
|
|
97
|
+
.next-steps {
|
|
98
|
+
background: #ffffff;
|
|
99
|
+
border: 1px solid #e2e8f0;
|
|
100
|
+
border-radius: 12px;
|
|
101
|
+
padding: 1.5rem;
|
|
102
|
+
}
|
|
103
|
+
.next-steps h2 { font-size: 1.1rem; margin-bottom: 1rem; color: #2d3748; }
|
|
104
|
+
.next-steps ol { padding-left: 1.5rem; }
|
|
105
|
+
.next-steps li { margin-bottom: 0.6rem; color: #4a5568; line-height: 1.5; }
|
|
106
|
+
.next-steps code {
|
|
107
|
+
background: rgba(99, 45, 145, 0.08);
|
|
108
|
+
padding: 0.15rem 0.4rem;
|
|
109
|
+
border-radius: 4px;
|
|
110
|
+
color: #632d91;
|
|
111
|
+
font-size: 0.85rem;
|
|
112
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'env_resolver'
|
|
4
|
+
require_relative 'terraform_command'
|
|
5
|
+
|
|
6
|
+
module Belt
|
|
7
|
+
module CLI
|
|
8
|
+
class DeployCommand
|
|
9
|
+
def self.run(args)
|
|
10
|
+
# Handle `belt deploy frontend <env>` as before
|
|
11
|
+
if args.first == 'frontend'
|
|
12
|
+
args.shift
|
|
13
|
+
Belt::CLI::FrontendDeployCommand.run(args)
|
|
14
|
+
return
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
auto_approve = false
|
|
18
|
+
filtered_args = []
|
|
19
|
+
|
|
20
|
+
args.each do |arg|
|
|
21
|
+
case arg
|
|
22
|
+
when '--auto', '--yes', '-y'
|
|
23
|
+
auto_approve = true
|
|
24
|
+
when '-h', '--help'
|
|
25
|
+
puts help_text
|
|
26
|
+
exit 0
|
|
27
|
+
else
|
|
28
|
+
filtered_args << arg
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
env = EnvResolver.resolve(filtered_args)
|
|
33
|
+
|
|
34
|
+
# Default to the first available environment when none specified
|
|
35
|
+
if env.nil?
|
|
36
|
+
available = TerraformCommand.list_environments
|
|
37
|
+
if available.any?
|
|
38
|
+
env = available.first
|
|
39
|
+
else
|
|
40
|
+
puts 'Usage: belt deploy [environment] [options]'
|
|
41
|
+
puts "\nDefaults to BELT_ENV or the first available environment."
|
|
42
|
+
puts "\nOptions:"
|
|
43
|
+
puts ' --auto, --yes, -y Skip confirmation prompt (auto-approve)'
|
|
44
|
+
puts ' -h, --help Show this help'
|
|
45
|
+
puts "\nExamples:"
|
|
46
|
+
puts ' belt deploy # Deploy dev (or BELT_ENV)'
|
|
47
|
+
puts ' belt deploy prod # Deploy to prod'
|
|
48
|
+
puts ' belt deploy dev --auto # Deploy without confirmation'
|
|
49
|
+
puts ' belt deploy frontend dev # Deploy frontend only'
|
|
50
|
+
puts "\nNo environments found. Run `belt generate environment dev` first."
|
|
51
|
+
exit 1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
new(env, auto_approve: auto_approve, extra_args: filtered_args).run
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.help_text
|
|
59
|
+
<<~HELP
|
|
60
|
+
Deploy your Belt application to AWS.
|
|
61
|
+
|
|
62
|
+
Usage: belt deploy [environment] [options]
|
|
63
|
+
belt deploy frontend <environment>
|
|
64
|
+
|
|
65
|
+
This runs the full deployment lifecycle:
|
|
66
|
+
1. terraform init (initialize providers/modules)
|
|
67
|
+
2. terraform plan (preview changes)
|
|
68
|
+
3. Prompt for confirmation (unless --auto)
|
|
69
|
+
4. terraform apply (deploy changes)
|
|
70
|
+
|
|
71
|
+
Options:
|
|
72
|
+
--auto, --yes, -y Skip confirmation prompt (auto-approve)
|
|
73
|
+
-h, --help Show this help
|
|
74
|
+
|
|
75
|
+
Environment:
|
|
76
|
+
Defaults to BELT_ENV if set, otherwise requires an argument.
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
belt deploy # Deploy dev (or BELT_ENV)
|
|
80
|
+
belt deploy prod # Deploy to prod
|
|
81
|
+
belt deploy dev --auto # Deploy without confirmation (CI mode)
|
|
82
|
+
belt deploy frontend dev # Deploy frontend assets only
|
|
83
|
+
HELP
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def initialize(env, auto_approve: false, extra_args: [])
|
|
87
|
+
@env = env
|
|
88
|
+
@auto_approve = auto_approve
|
|
89
|
+
@extra_args = extra_args
|
|
90
|
+
@infra_dir = TerraformCommand.find_infrastructure_dir
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def run
|
|
94
|
+
validate!
|
|
95
|
+
env_dir = File.join(@infra_dir, @env)
|
|
96
|
+
|
|
97
|
+
puts "belt → deploying #{@env} (in #{env_dir}/)\n\n"
|
|
98
|
+
|
|
99
|
+
Dir.chdir(env_dir) do
|
|
100
|
+
run_init
|
|
101
|
+
run_plan
|
|
102
|
+
return unless confirm_apply
|
|
103
|
+
run_apply
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
puts "\n✅ Deployed #{@env} successfully!"
|
|
107
|
+
print_outputs(env_dir)
|
|
108
|
+
puts "\n Run `belt server` to view your app locally (auto-connects to the deployed API)."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def validate!
|
|
114
|
+
unless @infra_dir
|
|
115
|
+
abort "Error: No infrastructure/ directory found.\n" \
|
|
116
|
+
"Run `belt generate environment #{@env}` first."
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
env_dir = File.join(@infra_dir, @env)
|
|
120
|
+
return if Dir.exist?(env_dir)
|
|
121
|
+
|
|
122
|
+
abort "Error: Environment '#{@env}' not found at #{env_dir}/.\n\n" \
|
|
123
|
+
"Available environments:\n#{TerraformCommand.list_environments.map { |e| " #{e}" }.join("\n")}\n\n" \
|
|
124
|
+
"Create it with: belt generate environment #{@env}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def run_init
|
|
128
|
+
puts '━━━ terraform init ━━━'
|
|
129
|
+
success = system('terraform', 'init')
|
|
130
|
+
abort "\n✗ terraform init failed" unless success
|
|
131
|
+
puts ''
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def run_plan
|
|
135
|
+
puts '━━━ terraform plan ━━━'
|
|
136
|
+
success = system('terraform', 'plan', '-out=tfplan', *@extra_args)
|
|
137
|
+
abort "\n✗ terraform plan failed" unless success
|
|
138
|
+
puts ''
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def confirm_apply
|
|
142
|
+
return true if @auto_approve
|
|
143
|
+
|
|
144
|
+
if @env == 'prod' || @env == 'production'
|
|
145
|
+
print "⚠️ You are about to deploy to \e[1;31m#{@env}\e[0m. Continue? [y/N] "
|
|
146
|
+
else
|
|
147
|
+
print "Apply these changes to #{@env}? [y/N] "
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
response = $stdin.gets
|
|
151
|
+
return true if response&.strip&.downcase&.start_with?('y')
|
|
152
|
+
|
|
153
|
+
puts 'Cancelled.'
|
|
154
|
+
cleanup_plan
|
|
155
|
+
false
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def run_apply
|
|
159
|
+
puts '━━━ terraform apply ━━━'
|
|
160
|
+
success = system('terraform', 'apply', 'tfplan')
|
|
161
|
+
cleanup_plan
|
|
162
|
+
abort "\n✗ terraform apply failed" unless success
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def cleanup_plan
|
|
166
|
+
File.delete('tfplan') if File.exist?('tfplan')
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def print_outputs(env_dir)
|
|
170
|
+
Dir.chdir(env_dir) do
|
|
171
|
+
output = `terraform output 2>/dev/null`.strip
|
|
172
|
+
return if output.empty?
|
|
173
|
+
|
|
174
|
+
puts "\nOutputs:"
|
|
175
|
+
output.each_line do |line|
|
|
176
|
+
puts " #{line}"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -26,9 +26,10 @@ module Belt
|
|
|
26
26
|
new(env_name).generate
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def initialize(env_name)
|
|
29
|
+
def initialize(env_name, quiet: false)
|
|
30
30
|
@env_name = env_name.downcase.gsub(/[^a-z0-9_-]/, '')
|
|
31
31
|
@app_name = detect_app_name
|
|
32
|
+
@quiet = quiet
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
def generate
|
|
@@ -49,11 +50,14 @@ module Belt
|
|
|
49
50
|
end
|
|
50
51
|
|
|
51
52
|
puts "\n✓ Environment '#{@env_name}' created!"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
|
|
54
|
+
unless @quiet
|
|
55
|
+
puts "\nNext steps:"
|
|
56
|
+
puts " cd #{dest_dir}"
|
|
57
|
+
puts ' terraform init'
|
|
58
|
+
puts ' terraform plan'
|
|
59
|
+
puts ' terraform apply'
|
|
60
|
+
end
|
|
57
61
|
end
|
|
58
62
|
|
|
59
63
|
private
|
|
@@ -63,7 +67,8 @@ module Belt
|
|
|
63
67
|
'main.tf.erb' => 'main.tf',
|
|
64
68
|
'backend.tf.erb' => 'backend.tf',
|
|
65
69
|
'variables.tf.erb' => 'variables.tf',
|
|
66
|
-
'terraform.tfvars.erb' => 'terraform.tfvars'
|
|
70
|
+
'terraform.tfvars.erb' => 'terraform.tfvars',
|
|
71
|
+
'outputs.tf.erb' => 'outputs.tf'
|
|
67
72
|
}
|
|
68
73
|
end
|
|
69
74
|
|
|
@@ -15,19 +15,79 @@ module Belt
|
|
|
15
15
|
|
|
16
16
|
include AppDetection
|
|
17
17
|
|
|
18
|
+
FIELD_TYPES = %w[string text integer float boolean date datetime].freeze
|
|
19
|
+
|
|
20
|
+
GENERATOR_HELP = {
|
|
21
|
+
'resource' => {
|
|
22
|
+
description: 'Generate a model, controller, routes, and schema for a REST resource.',
|
|
23
|
+
usage: 'belt generate resource <name> [field:type ...] [options]',
|
|
24
|
+
options: [
|
|
25
|
+
['--skip-views', 'Skip generating frontend view pages']
|
|
26
|
+
],
|
|
27
|
+
examples: [
|
|
28
|
+
['belt g resource post title:string body:text', 'Post with title and body fields'],
|
|
29
|
+
['belt g resource comment author:string body:text status:string', 'Comment with multiple fields'],
|
|
30
|
+
['belt g resource task --skip-views', 'Task resource without frontend pages']
|
|
31
|
+
],
|
|
32
|
+
notes: <<~NOTES
|
|
33
|
+
Creates:
|
|
34
|
+
lambda/models/<name>.rb Model with validations and fields
|
|
35
|
+
lambda/controllers/<app>/<names>_controller.rb RESTful controller (index, show, create, update, destroy)
|
|
36
|
+
infrastructure/routes.tf.rb Route entry added
|
|
37
|
+
infrastructure/schema.tf.rb DynamoDB table schema added
|
|
38
|
+
lambda/lib/routes/<app>_routes.rb Route manifest updated
|
|
39
|
+
NOTES
|
|
40
|
+
},
|
|
41
|
+
'model' => {
|
|
42
|
+
description: 'Generate an ActiveItem model with validations and DynamoDB field definitions.',
|
|
43
|
+
usage: 'belt generate model <name> [field:type ...]',
|
|
44
|
+
options: [],
|
|
45
|
+
examples: [
|
|
46
|
+
['belt g model user email:string name:string', 'User model with email and name'],
|
|
47
|
+
['belt g model event title:string starts_at:datetime', 'Event model with datetime field']
|
|
48
|
+
],
|
|
49
|
+
notes: <<~NOTES
|
|
50
|
+
Creates:
|
|
51
|
+
lambda/models/<name>.rb Model class inheriting from ActiveItem::Base
|
|
52
|
+
NOTES
|
|
53
|
+
},
|
|
54
|
+
'controller' => {
|
|
55
|
+
description: 'Generate a controller with standard REST actions.',
|
|
56
|
+
usage: 'belt generate controller <name>',
|
|
57
|
+
options: [],
|
|
58
|
+
examples: [
|
|
59
|
+
['belt g controller posts', 'Posts controller with CRUD actions'],
|
|
60
|
+
['belt g controller admin/users', 'Namespaced controller']
|
|
61
|
+
],
|
|
62
|
+
notes: <<~NOTES
|
|
63
|
+
Creates:
|
|
64
|
+
lambda/controllers/<app>/<name>_controller.rb Controller class
|
|
65
|
+
NOTES
|
|
66
|
+
}
|
|
67
|
+
}.freeze
|
|
68
|
+
|
|
18
69
|
def self.run(args)
|
|
19
70
|
generator = args.shift
|
|
20
71
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
72
|
+
# Top-level help: belt generate --help or belt generate (no args)
|
|
73
|
+
if generator.nil? || generator =~ /\A-/
|
|
74
|
+
print_generate_help
|
|
75
|
+
exit 0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
unless GENERATORS.include?(generator)
|
|
79
|
+
puts "Unknown generator: '#{generator}'"
|
|
80
|
+
puts "Available generators: #{GENERATORS.join(', ')}"
|
|
81
|
+
puts "\nRun 'belt generate --help' for usage information."
|
|
28
82
|
exit 1
|
|
29
83
|
end
|
|
30
84
|
|
|
85
|
+
# Per-generator help: belt g resource --help
|
|
86
|
+
if args.include?('--help') || args.include?('-h')
|
|
87
|
+
print_generator_help(generator)
|
|
88
|
+
exit 0
|
|
89
|
+
end
|
|
90
|
+
|
|
31
91
|
return Belt::CLI::EnvironmentCommand.run(args) if generator == 'environment'
|
|
32
92
|
|
|
33
93
|
return Belt::CLI::FrontendCommand.run(args) if generator == 'frontend'
|
|
@@ -36,10 +96,12 @@ module Belt
|
|
|
36
96
|
|
|
37
97
|
name = args.shift
|
|
38
98
|
if name.nil? || name.empty?
|
|
39
|
-
|
|
40
|
-
exit
|
|
99
|
+
print_generator_help(generator)
|
|
100
|
+
exit 0
|
|
41
101
|
end
|
|
42
102
|
|
|
103
|
+
validate_resource_name!(name, generator)
|
|
104
|
+
|
|
43
105
|
skip_views = args.delete('--skip-views')
|
|
44
106
|
fields = args.map { |arg| parse_field(arg) }
|
|
45
107
|
new(generator, name, fields, skip_views: skip_views).generate
|
|
@@ -50,6 +112,99 @@ module Belt
|
|
|
50
112
|
{ name: name, type: type || 'string' }
|
|
51
113
|
end
|
|
52
114
|
|
|
115
|
+
RESERVED_NAMES = %w[
|
|
116
|
+
help new edit create update destroy index show delete remove
|
|
117
|
+
application base controller model resource generate
|
|
118
|
+
belt setup environment frontend views routes
|
|
119
|
+
].freeze
|
|
120
|
+
|
|
121
|
+
def self.validate_resource_name!(name, generator)
|
|
122
|
+
errors = []
|
|
123
|
+
|
|
124
|
+
errors << 'must start with a letter' unless name.match?(/\A[a-zA-Z]/)
|
|
125
|
+
errors << 'must be at least 2 characters' if name.length < 2
|
|
126
|
+
errors << 'must only contain letters, numbers, and underscores' unless name.match?(/\A[a-zA-Z][a-zA-Z0-9_]*\z/)
|
|
127
|
+
errors << 'must not start or end with an underscore' if name.match?(/\A_|_\z/)
|
|
128
|
+
errors << 'must not contain consecutive underscores' if name.include?('__')
|
|
129
|
+
errors << "is a reserved name" if RESERVED_NAMES.include?(name.downcase.chomp('s'))
|
|
130
|
+
errors << "is a reserved name" if RESERVED_NAMES.include?(name.downcase)
|
|
131
|
+
|
|
132
|
+
return if errors.empty?
|
|
133
|
+
|
|
134
|
+
puts "\n✗ Invalid #{generator} name '#{name}':"
|
|
135
|
+
errors.uniq.each { |e| puts " - #{e}" }
|
|
136
|
+
puts "\nName must start with a letter, be at least 2 characters, and contain only letters, numbers, and single underscores."
|
|
137
|
+
exit 1
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.print_generate_help
|
|
141
|
+
puts <<~HELP
|
|
142
|
+
Usage: belt generate <generator> <name> [field:type ...] [options]
|
|
143
|
+
belt g <generator> <name> [field:type ...] [options]
|
|
144
|
+
|
|
145
|
+
Generators:
|
|
146
|
+
resource Generate model, controller, routes, and schema (full REST resource)
|
|
147
|
+
model Generate an ActiveItem model
|
|
148
|
+
controller Generate a controller
|
|
149
|
+
environment Create a new deployment environment
|
|
150
|
+
frontend Scaffold a frontend app (react, vue, svelte)
|
|
151
|
+
views Generate React pages for a resource
|
|
152
|
+
|
|
153
|
+
Field Types:
|
|
154
|
+
#{FIELD_TYPES.join(', ')}
|
|
155
|
+
|
|
156
|
+
Examples:
|
|
157
|
+
belt g resource post title:string body:text status:string
|
|
158
|
+
belt g model user email:string name:string
|
|
159
|
+
belt g controller comments
|
|
160
|
+
belt g environment staging
|
|
161
|
+
belt g frontend react
|
|
162
|
+
belt g views post title:string body:text
|
|
163
|
+
|
|
164
|
+
Run 'belt generate <generator> --help' for detailed help on a specific generator.
|
|
165
|
+
HELP
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.print_generator_help(generator)
|
|
169
|
+
info = GENERATOR_HELP[generator]
|
|
170
|
+
|
|
171
|
+
unless info
|
|
172
|
+
puts "Usage: belt generate #{generator} <name>"
|
|
173
|
+
puts "\nRun 'belt generate #{generator} --help' is not yet documented."
|
|
174
|
+
puts "Try 'belt generate --help' for the full list of generators."
|
|
175
|
+
return
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
puts info[:description]
|
|
179
|
+
puts
|
|
180
|
+
puts "Usage: #{info[:usage]}"
|
|
181
|
+
|
|
182
|
+
if info[:options].any?
|
|
183
|
+
puts "\nOptions:"
|
|
184
|
+
info[:options].each do |flag, desc|
|
|
185
|
+
puts " %-20s %s" % [flag, desc]
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
puts "\nField Types:"
|
|
190
|
+
puts " #{FIELD_TYPES.join(', ')}"
|
|
191
|
+
|
|
192
|
+
puts "\nExamples:"
|
|
193
|
+
info[:examples].each do |cmd, desc|
|
|
194
|
+
puts " #{cmd}"
|
|
195
|
+
puts " #{desc}" if desc
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
puts "\n#{info[:notes]}" if info[:notes]
|
|
199
|
+
|
|
200
|
+
puts "Naming Rules:"
|
|
201
|
+
puts " - Must start with a letter"
|
|
202
|
+
puts " - At least 2 characters"
|
|
203
|
+
puts " - Letters, numbers, and single underscores only"
|
|
204
|
+
puts " - No leading/trailing/consecutive underscores"
|
|
205
|
+
puts " - Cannot use reserved names (help, new, create, destroy, etc.)"
|
|
206
|
+
end
|
|
207
|
+
|
|
53
208
|
def initialize(generator, name, fields, skip_views: false)
|
|
54
209
|
@generator = generator
|
|
55
210
|
@name = name.downcase.gsub(/[^a-z0-9_]/, '_')
|