gemstack-cli 0.1.0
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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +24 -0
- data/exe/gemstack +6 -0
- data/lib/gemstack/cli/add_generator.rb +237 -0
- data/lib/gemstack/cli/app_generator.rb +110 -0
- data/lib/gemstack/cli/commands/db.rb +134 -0
- data/lib/gemstack/cli/commands/jobs.rb +96 -0
- data/lib/gemstack/cli/controller_generator.rb +73 -0
- data/lib/gemstack/cli/deploy_generator.rb +64 -0
- data/lib/gemstack/cli/doctor.rb +277 -0
- data/lib/gemstack/cli/generator.rb +122 -0
- data/lib/gemstack/cli/job_generator.rb +47 -0
- data/lib/gemstack/cli/migration_generator.rb +52 -0
- data/lib/gemstack/cli/policy_generator.rb +35 -0
- data/lib/gemstack/cli/project.rb +53 -0
- data/lib/gemstack/cli/resource_generator.rb +169 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +299 -0
- data/templates/app/Gemfile.tt +39 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +30 -0
- data/templates/app/config/environments/development.rb +6 -0
- data/templates/app/config/environments/production.rb +8 -0
- data/templates/app/config/environments/test.rb +7 -0
- data/templates/app/config/puma.rb +21 -0
- data/templates/app/config/routes.rb +10 -0
- data/templates/app/config.ru +6 -0
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +5 -0
- data/templates/app/dot_env.example +9 -0
- data/templates/app/dot_gitignore +14 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +1 -0
- data/templates/app/test/health_test.rb +18 -0
- data/templates/app/test/test_helper.rb.tt +25 -0
- data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
- data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
- data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
- data/templates/auth/app/controllers/registrations_controller.rb +19 -0
- data/templates/auth/app/controllers/sessions_controller.rb +29 -0
- data/templates/auth/app/mailers/auth_mailer.rb +20 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
- data/templates/auth/app/models/auth_token.rb +15 -0
- data/templates/auth/app/models/user.rb +12 -0
- data/templates/auth/app/policies/application_policy.rb +6 -0
- data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
- data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
- data/templates/auth/app/serializers/user_serializer.rb +6 -0
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
- data/templates/auth/frontend/app/account/page.tsx +116 -0
- data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
- data/templates/auth/frontend/app/login/page.tsx +26 -0
- data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
- data/templates/auth/frontend/app/signup/page.tsx +27 -0
- data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
- data/templates/auth/frontend/lib/auth.ts +113 -0
- data/templates/auth/test/controllers/auth_test.rb +108 -0
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
- data/templates/deploy/Caddyfile.tt +19 -0
- data/templates/deploy/Dockerfile.tt +62 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +80 -0
- data/templates/deploy/dot_dockerignore +14 -0
- data/templates/frontend/app/globals.css +171 -0
- data/templates/frontend/app/layout.tsx.tt +19 -0
- data/templates/frontend/app/page.tsx.tt +45 -0
- data/templates/frontend/app/providers.tsx +24 -0
- data/templates/frontend/lib/gemstack/client.ts +125 -0
- data/templates/frontend/next-env.d.ts +5 -0
- data/templates/frontend/next.config.ts +17 -0
- data/templates/frontend/package.json.tt +23 -0
- data/templates/frontend/tsconfig.json +21 -0
- data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
- data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
- data/templates/realtime/config/channels.rb +14 -0
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
- data/templates/resource/frontend/frontend/lib/format.ts +12 -0
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +63 -0
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
- data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
- data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
- data/templates/storage/app/serializers/upload_serializer.rb +11 -0
- data/templates/storage/frontend/lib/upload.ts +52 -0
- data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
- metadata +195 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
# Locates the GemStack application the CLI is run from and makes sure
|
|
6
|
+
# commands run inside that application's bundle, so the app's own
|
|
7
|
+
# GemStack version and gems are used (like a binstub would).
|
|
8
|
+
module Project
|
|
9
|
+
MARKER = "config/app.rb"
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def root(start = Dir.pwd)
|
|
14
|
+
dir = File.expand_path(start)
|
|
15
|
+
loop do
|
|
16
|
+
return dir if File.file?(File.join(dir, MARKER)) && File.file?(File.join(dir, "Gemfile"))
|
|
17
|
+
|
|
18
|
+
parent = File.dirname(dir)
|
|
19
|
+
return nil if parent == dir
|
|
20
|
+
|
|
21
|
+
dir = parent
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def root!
|
|
26
|
+
root || abort("Not inside a GemStack application (no #{MARKER} found). Create one with `gemstack new myapp`.")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def in_bundle?(app_root)
|
|
30
|
+
gemfile = ENV.fetch("BUNDLE_GEMFILE", nil)
|
|
31
|
+
gemfile && File.expand_path(gemfile) == File.join(app_root, "Gemfile") && defined?(Bundler)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Re-executes the current command under `bundle exec` unless the app's
|
|
35
|
+
# bundle is already active, then chdirs to the app root.
|
|
36
|
+
def ensure_bundle!(argv)
|
|
37
|
+
app_root = root!
|
|
38
|
+
unless in_bundle?(app_root)
|
|
39
|
+
Dir.chdir(app_root)
|
|
40
|
+
ENV["BUNDLE_GEMFILE"] = File.join(app_root, "Gemfile")
|
|
41
|
+
exec("bundle", "exec", "gemstack", *argv)
|
|
42
|
+
end
|
|
43
|
+
Dir.chdir(app_root)
|
|
44
|
+
app_root
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Loads config/app.rb (configuration + gems) without booting the app.
|
|
48
|
+
def load_config!(app_root)
|
|
49
|
+
require File.join(app_root, MARKER)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
# Generates a vertical slice for a ResourceSpec from composable parts:
|
|
6
|
+
#
|
|
7
|
+
# migration db/migrations/<ts>_create_<table>.rb
|
|
8
|
+
# model app/models/<name>.rb + test/models/<name>_test.rb
|
|
9
|
+
# serializer app/serializers/<name>_serializer.rb
|
|
10
|
+
# controller app/controllers/<plural>_controller.rb + test + routes
|
|
11
|
+
# frontend Next.js pages, components and TanStack Query hooks
|
|
12
|
+
#
|
|
13
|
+
# `generate model` uses [migration, model, serializer]; `generate resource`
|
|
14
|
+
# uses all parts (minus backend or frontend with --frontend-only/--api-only).
|
|
15
|
+
# Every template can be overridden in lib/templates/gemstack/resource/<part>/.
|
|
16
|
+
class ResourceGenerator < Generator
|
|
17
|
+
PARTS = %i[migration model serializer controller frontend].freeze
|
|
18
|
+
|
|
19
|
+
# Frontend files that only make sense for some actions.
|
|
20
|
+
REQUIRES = {
|
|
21
|
+
"new/page.tsx" => %w[create], "[id]/edit/page.tsx" => %w[update], "[id]/page.tsx" => %w[show],
|
|
22
|
+
"Card.tsx" => %w[show], "Form.tsx" => %w[create update]
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :spec, :timestamp, :tests
|
|
26
|
+
|
|
27
|
+
def initialize(spec, root:, parts: PARTS, tests: true, output: $stdout, force: false, timestamp: nil)
|
|
28
|
+
super(output: output, force: force)
|
|
29
|
+
@spec = spec
|
|
30
|
+
@root = root
|
|
31
|
+
@parts = parts
|
|
32
|
+
@tests = tests
|
|
33
|
+
@timestamp = timestamp || Generator.migration_timestamp(root)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run
|
|
37
|
+
@parts.each { |part| render_part(part) }
|
|
38
|
+
add_routes if @parts.include?(:controller)
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# --- template helpers -------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def fields = spec.fields
|
|
45
|
+
def class_name = spec.class_name
|
|
46
|
+
def input_type = spec.action?(:create) ? "#{class_name}Input" : "#{class_name}UpdateInput"
|
|
47
|
+
|
|
48
|
+
# Sequel schema DSL per field type ("%s" is the column name).
|
|
49
|
+
COLUMN_TYPES = {
|
|
50
|
+
"string" => "String :%s", "text" => "String :%s, text: true", "integer" => "Integer :%s",
|
|
51
|
+
"bigint" => "Bignum :%s", "float" => "Float :%s", "decimal" => "BigDecimal :%s, size: [12, 2]",
|
|
52
|
+
"date" => "Date :%s", "datetime" => "column :%s, :timestamptz", "uuid" => "column :%s, :uuid",
|
|
53
|
+
"json" => "column :%s, :jsonb"
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
def migration_column(field)
|
|
57
|
+
null = field.required? ? ", null: false" : ""
|
|
58
|
+
case field.type
|
|
59
|
+
when "references"
|
|
60
|
+
"foreign_key :#{field.column}, :#{field.referenced_table}, type: :Bignum#{null}, on_delete: :restrict"
|
|
61
|
+
when "boolean" then "TrueClass :#{field.name}, null: false, default: false"
|
|
62
|
+
else
|
|
63
|
+
unique = field.unique && field.type != "json" ? ", unique: true" : ""
|
|
64
|
+
format(COLUMN_TYPES.fetch(field.type), field.name) + null + unique
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def model_field(field)
|
|
69
|
+
options = []
|
|
70
|
+
options << "null: false" if field.required?
|
|
71
|
+
options << "null: false, default: false" if field.type == "boolean"
|
|
72
|
+
options << "size: 255" if field.type == "string"
|
|
73
|
+
["field :#{field.column}, :#{field.model_type}", *options].join(", ")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ts_value_type(field) = field.type == "boolean" ? "boolean" : "string"
|
|
77
|
+
|
|
78
|
+
# Expression turning a record (possibly undefined) into a form value.
|
|
79
|
+
def form_initial(field)
|
|
80
|
+
attr = "record?.#{field.column}"
|
|
81
|
+
case field.type
|
|
82
|
+
when "boolean" then "#{attr} ?? false"
|
|
83
|
+
when "integer", "bigint", "float", "references" then "String(#{attr} ?? \"\")"
|
|
84
|
+
when "datetime" then "(#{attr} ?? \"\").slice(0, 16)"
|
|
85
|
+
when "json" then "#{attr} == null ? \"\" : JSON.stringify(#{attr}, null, 2)"
|
|
86
|
+
else "#{attr} ?? \"\""
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Expression turning a form value into an API input value.
|
|
91
|
+
def form_output(field)
|
|
92
|
+
value = "values.#{field.column}"
|
|
93
|
+
return value if field.type == "boolean"
|
|
94
|
+
|
|
95
|
+
converted =
|
|
96
|
+
case field.type
|
|
97
|
+
when "integer", "bigint", "float", "references" then "Number(#{value})"
|
|
98
|
+
when "json" then "parseJson(#{value})"
|
|
99
|
+
else value
|
|
100
|
+
end
|
|
101
|
+
blank = field.required? ? "\"\"" : "null"
|
|
102
|
+
converted == value && field.required? ? value : "#{value} === \"\" ? #{blank} : #{converted}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def form_control(field)
|
|
106
|
+
key = field.column
|
|
107
|
+
common = %(id="#{field.column}" name="#{field.column}")
|
|
108
|
+
required = field.required? ? " required" : ""
|
|
109
|
+
case field.type
|
|
110
|
+
when "boolean"
|
|
111
|
+
%(<input #{common} type="checkbox" checked={values.#{key}} onChange={set("#{key}")} />)
|
|
112
|
+
when "text", "json"
|
|
113
|
+
%(<textarea #{common} rows={4} value={values.#{key}} onChange={set("#{key}")}#{required} />)
|
|
114
|
+
else
|
|
115
|
+
attrs = { "integer" => %(type="number" step="1"), "bigint" => %(type="number" step="1"),
|
|
116
|
+
"references" => %(type="number" step="1"), "float" => %(type="number" step="any"),
|
|
117
|
+
"decimal" => %(type="text" inputMode="decimal"), "date" => %(type="date"),
|
|
118
|
+
"datetime" => %(type="datetime-local") }.fetch(field.type, %(type="text"))
|
|
119
|
+
%(<input #{common} #{attrs} value={values.#{key}} onChange={set("#{key}")}#{required} />)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def table_fields = fields.first(4)
|
|
124
|
+
def uses_json? = fields.any? { |f| f.type == "json" }
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def render_part(part)
|
|
129
|
+
template_files("resource/#{part}", override_root: @root).sort.each do |rel, source|
|
|
130
|
+
next if skip?(part, rel)
|
|
131
|
+
|
|
132
|
+
target = File.join(@root, substitute(rel.delete_suffix(".tt")))
|
|
133
|
+
write(target, render(File.read(source), source))
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def skip?(part, rel)
|
|
138
|
+
return true if !tests && rel.start_with?("test/")
|
|
139
|
+
return false unless part == :frontend
|
|
140
|
+
|
|
141
|
+
requirement = REQUIRES.find { |suffix, _| rel.delete_suffix(".tt").end_with?(suffix) }&.last
|
|
142
|
+
requirement ? requirement.none? { |action| spec.action?(action) } : false
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def substitute(path)
|
|
146
|
+
path.gsub(/%(\w+)%/) do
|
|
147
|
+
key = ::Regexp.last_match(1)
|
|
148
|
+
key == "timestamp" ? timestamp : spec.public_send(key)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def add_routes
|
|
153
|
+
path = File.join(@root, "config/routes.rb")
|
|
154
|
+
return status("skip", path, "not found — add `resources :#{spec.plural}` manually") unless File.file?(path)
|
|
155
|
+
|
|
156
|
+
only = spec.crud? ? "" : ", only: %i[#{spec.actions.join(" ")}]"
|
|
157
|
+
line = "resources :#{spec.plural}#{only}"
|
|
158
|
+
content = File.read(path)
|
|
159
|
+
return status("identical", path) if content.match?(/^\s*resources :#{spec.plural}\b/)
|
|
160
|
+
unless content.match?(ControllerGenerator::ROUTES_BLOCK)
|
|
161
|
+
return status("skip", path, "no `GemStack.routes do` block — add `#{line}`")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
File.write(path, content.sub(ControllerGenerator::ROUTES_BLOCK) { |open| "#{open} #{line}\n" })
|
|
165
|
+
status("route", path, line)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
# Everything the model/migration/resource generators need to know about a
|
|
6
|
+
# resource, parsed once from the command line (ARCHITECTURE §7):
|
|
7
|
+
#
|
|
8
|
+
# gemstack generate resource Product name:string price:decimal description:text:optional \
|
|
9
|
+
# sku:string:unique category:references active:boolean
|
|
10
|
+
#
|
|
11
|
+
# Field syntax: name:type[:modifier...]. Fields are required (NOT NULL)
|
|
12
|
+
# unless marked :optional. Booleans default to false. Modifiers: optional,
|
|
13
|
+
# unique, index.
|
|
14
|
+
class ResourceSpec
|
|
15
|
+
TYPES = %w[string text integer bigint float decimal boolean date datetime uuid json references].freeze
|
|
16
|
+
MODIFIERS = %w[optional unique index].freeze
|
|
17
|
+
REST_ACTIONS = %w[index show create update destroy].freeze
|
|
18
|
+
|
|
19
|
+
Field = Struct.new(:name, :type, :optional, :unique, :index, keyword_init: true) do
|
|
20
|
+
def required? = !optional && type != "boolean"
|
|
21
|
+
def reference? = type == "references"
|
|
22
|
+
def column = reference? ? "#{name}_id" : name
|
|
23
|
+
def label = Inflector.humanize(name)
|
|
24
|
+
def referenced_class = Inflector.camelize(name)
|
|
25
|
+
def referenced_table = Inflector.pluralize(name)
|
|
26
|
+
def model_type = reference? ? "references" : type
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :name, :fields, :actions
|
|
30
|
+
|
|
31
|
+
def initialize(name, field_args = [], actions: REST_ACTIONS)
|
|
32
|
+
@name = Inflector.camelize(Inflector.singularize(name.to_s.delete_suffix("Controller")))
|
|
33
|
+
unless @name.match?(/\A[A-Z][A-Za-z0-9]*\z/)
|
|
34
|
+
raise Thor::Error,
|
|
35
|
+
"Invalid resource name #{name.inspect}: use a single CamelCase or snake_case name (e.g. Product)"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Generator.check_constant!(@name, suggestion: "#{@name}Record") unless @name == "Migration"
|
|
39
|
+
@fields = field_args.map { |arg| parse_field(arg) }
|
|
40
|
+
duplicates = @fields.group_by(&:column).select { |_, list| list.size > 1 }.keys
|
|
41
|
+
raise Thor::Error, "Duplicate field(s): #{duplicates.join(", ")}" unless duplicates.empty?
|
|
42
|
+
|
|
43
|
+
@actions = actions.map(&:to_s)
|
|
44
|
+
invalid = @actions - REST_ACTIONS
|
|
45
|
+
return if invalid.empty?
|
|
46
|
+
|
|
47
|
+
raise Thor::Error,
|
|
48
|
+
"Unknown action(s) #{invalid.join(", ")}; resources support #{REST_ACTIONS.join(", ")}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Product / LineItem examples:
|
|
52
|
+
# class_name Product · file_name product · plural products · plural_class Products
|
|
53
|
+
# url_segment line-items · client_name lineItems · variable lineItem · human "Line item"
|
|
54
|
+
def class_name = name
|
|
55
|
+
def file_name = Inflector.underscore(name)
|
|
56
|
+
def plural = Inflector.pluralize(file_name)
|
|
57
|
+
def table = plural
|
|
58
|
+
def plural_class = Inflector.camelize(plural)
|
|
59
|
+
def url_segment = Inflector.dasherize(plural)
|
|
60
|
+
def client_name = lower_camel(plural)
|
|
61
|
+
def variable = lower_camel(file_name)
|
|
62
|
+
def human = Inflector.humanize(file_name)
|
|
63
|
+
def human_plural = Inflector.humanize(plural)
|
|
64
|
+
|
|
65
|
+
def action?(action) = actions.include?(action.to_s)
|
|
66
|
+
def crud? = actions.sort == REST_ACTIONS.sort
|
|
67
|
+
def references = fields.select(&:reference?)
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def parse_field(arg)
|
|
72
|
+
name, type, *mods = arg.to_s.split(":")
|
|
73
|
+
type ||= "string"
|
|
74
|
+
type = "references" if type == "belongs_to"
|
|
75
|
+
raise Thor::Error, "Invalid field name in #{arg.inspect}" unless name&.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
76
|
+
unless TYPES.include?(type)
|
|
77
|
+
raise Thor::Error,
|
|
78
|
+
"Unknown type #{type.inspect} in #{arg.inspect}; types: #{TYPES.join(", ")}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
unknown = mods - MODIFIERS
|
|
82
|
+
raise Thor::Error, "Unknown modifier(s) #{unknown.join(", ")} in #{arg.inspect}" unless unknown.empty?
|
|
83
|
+
|
|
84
|
+
name = name.delete_suffix("_id") if type == "references"
|
|
85
|
+
Field.new(name: name, type: type, optional: mods.include?("optional"), unique: mods.include?("unique"),
|
|
86
|
+
index: mods.include?("index") || type == "references")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def lower_camel(term)
|
|
90
|
+
camel = Inflector.camelize(term)
|
|
91
|
+
camel[0].downcase + camel[1..]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/gemstack/cli.rb
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "gemstack/core"
|
|
5
|
+
|
|
6
|
+
module GemStack
|
|
7
|
+
# The `gemstack` command.
|
|
8
|
+
class CLI < Thor
|
|
9
|
+
require_relative "cli/project"
|
|
10
|
+
require_relative "cli/generator"
|
|
11
|
+
require_relative "cli/app_generator"
|
|
12
|
+
require_relative "cli/controller_generator"
|
|
13
|
+
require_relative "cli/resource_spec"
|
|
14
|
+
require_relative "cli/resource_generator"
|
|
15
|
+
require_relative "cli/migration_generator"
|
|
16
|
+
require_relative "cli/job_generator"
|
|
17
|
+
require_relative "cli/policy_generator"
|
|
18
|
+
require_relative "cli/doctor"
|
|
19
|
+
require_relative "cli/deploy_generator"
|
|
20
|
+
require_relative "cli/add_generator"
|
|
21
|
+
require_relative "cli/commands/db"
|
|
22
|
+
require_relative "cli/commands/jobs"
|
|
23
|
+
|
|
24
|
+
# Commands that need the application's bundle (see Project.ensure_bundle!).
|
|
25
|
+
class << self
|
|
26
|
+
attr_accessor :argv
|
|
27
|
+
|
|
28
|
+
def start(given_args = ARGV, config = {})
|
|
29
|
+
self.argv = given_args.dup
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def exit_on_failure? = true
|
|
34
|
+
|
|
35
|
+
# Shown in help output regardless of how the CLI was launched.
|
|
36
|
+
def basename = "gemstack"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
GENERATORS = "resource, model, migration, controller, job, policy, deploy"
|
|
40
|
+
|
|
41
|
+
map %w[-v --version] => :version
|
|
42
|
+
map "s" => :server
|
|
43
|
+
map "c" => :console
|
|
44
|
+
map "t" => :test
|
|
45
|
+
map "g" => :generate
|
|
46
|
+
|
|
47
|
+
desc "new NAME", "Create a new GemStack application (Ruby API + Next.js frontend)"
|
|
48
|
+
long_desc <<~DESC
|
|
49
|
+
Creates NAME/ with a Ruby API, a Next.js + TypeScript frontend and no business resources.
|
|
50
|
+
|
|
51
|
+
Then: cd NAME && gemstack dev
|
|
52
|
+
DESC
|
|
53
|
+
method_option :skip_install, type: :boolean, default: false, desc: "Don't run bundle install / npm install"
|
|
54
|
+
method_option :skip_git, type: :boolean, default: false, desc: "Don't initialise a git repository"
|
|
55
|
+
method_option :skip_frontend, type: :boolean, default: false, desc: "API only: no Next.js frontend"
|
|
56
|
+
method_option :skip_database, type: :boolean, default: false, desc: "No PostgreSQL / gemstack-db"
|
|
57
|
+
method_option :gemstack_path, type: :string, desc: "Use GemStack from a local checkout (its gems/ directory)"
|
|
58
|
+
def new(name)
|
|
59
|
+
AppGenerator.new(name, options.transform_keys(&:to_sym)).run
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
desc "dev", "Run the application: Next.js + Ruby API behind one origin (http://localhost:3000)"
|
|
63
|
+
def dev
|
|
64
|
+
root = Project.ensure_bundle!(self.class.argv)
|
|
65
|
+
ENV["GEMSTACK_ENV"] ||= "development"
|
|
66
|
+
Project.load_config!(root)
|
|
67
|
+
require "gemstack/dev"
|
|
68
|
+
Dev::Supervisor.new(root: root).run
|
|
69
|
+
rescue GemStack::Error => e
|
|
70
|
+
abort("gemstack dev: #{e.message}")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
desc "server", "Run only the Ruby API with Puma (alias: s)"
|
|
74
|
+
method_option :port, aliases: "-p", type: :numeric, desc: "Port (default: GEMSTACK_API_PORT or 4000)"
|
|
75
|
+
def server
|
|
76
|
+
root = Project.ensure_bundle!(self.class.argv)
|
|
77
|
+
env = {}
|
|
78
|
+
env["GEMSTACK_API_PORT"] = options[:port].to_s if options[:port]
|
|
79
|
+
env["GEMSTACK_ENV"] = options[:environment] if options[:environment]
|
|
80
|
+
exec(env, "bundle", "exec", "puma", "-C", File.join(root, "config/puma.rb"))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
desc "routes", "List API routes"
|
|
84
|
+
method_option :grep, aliases: "-g", type: :string, desc: "Only routes matching this text"
|
|
85
|
+
def routes
|
|
86
|
+
root = Project.ensure_bundle!(self.class.argv)
|
|
87
|
+
ENV["GEMSTACK_ENV"] ||= "development"
|
|
88
|
+
Project.load_config!(root)
|
|
89
|
+
GemStack.config.reload_code = false
|
|
90
|
+
GemStack.config.logger.level = :warn
|
|
91
|
+
GemStack.config.db.log_queries = false if GemStack.config.respond_to?(:db)
|
|
92
|
+
rows = GemStack.boot!.routes.map { |r| [r.verb, r.path, r.target] }
|
|
93
|
+
rows.select! { |row| row.join(" ").include?(options[:grep]) } if options[:grep]
|
|
94
|
+
return say("No routes defined. Add them to config/routes.rb.") if rows.empty?
|
|
95
|
+
|
|
96
|
+
print_table(rows)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
desc "console", "Start an IRB session with the application loaded (alias: c)"
|
|
100
|
+
def console
|
|
101
|
+
root = Project.ensure_bundle!(self.class.argv)
|
|
102
|
+
ENV["GEMSTACK_ENV"] = options[:environment] || ENV["GEMSTACK_ENV"] || "development"
|
|
103
|
+
Project.load_config!(root)
|
|
104
|
+
GemStack.boot!
|
|
105
|
+
require "irb"
|
|
106
|
+
say("GemStack #{GemStack::VERSION} console (#{GemStack.env}). `GemStack.application.reload!` reloads code.")
|
|
107
|
+
ARGV.clear
|
|
108
|
+
IRB.start
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
desc "test [FILES...]", "Run the Ruby test suite (alias: t)"
|
|
112
|
+
def test(*files)
|
|
113
|
+
root = Project.ensure_bundle!(self.class.argv)
|
|
114
|
+
files = Dir.glob("test/**/*_test.rb", base: root).sort if files.empty?
|
|
115
|
+
return say("No tests found in test/.") if files.empty?
|
|
116
|
+
|
|
117
|
+
loader = "ARGV.each { |f| require File.expand_path(f) }"
|
|
118
|
+
exec({ "GEMSTACK_ENV" => "test" }, RbConfig.ruby, "-Itest", "-e", loader, *files)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
desc "generate GENERATOR NAME [ARGS]",
|
|
122
|
+
"Generate code (alias: g). Generators: resource, model, migration, controller, job, policy, deploy"
|
|
123
|
+
long_desc <<~DESC
|
|
124
|
+
gemstack generate resource Product name:string price:decimal description:text:optional active:boolean
|
|
125
|
+
Full vertical slice: migration, model, serializer, controller, routes, tests,
|
|
126
|
+
TypeScript client (via the contract) and Next.js pages. Without fields it asks interactively.
|
|
127
|
+
--api-only backend only
|
|
128
|
+
--frontend-only Next.js pages/components only (for an existing backend resource)
|
|
129
|
+
--actions=index,show a subset of index,show,create,update,destroy (default: all = --crud)
|
|
130
|
+
--skip-tests
|
|
131
|
+
|
|
132
|
+
gemstack generate model Product name:string price:decimal
|
|
133
|
+
Migration, model, serializer and model test.
|
|
134
|
+
|
|
135
|
+
gemstack generate migration AddSkuToProducts sku:string:unique
|
|
136
|
+
|
|
137
|
+
gemstack generate controller Products index show publish
|
|
138
|
+
|
|
139
|
+
gemstack generate job SendWelcomeEmail [QUEUE]
|
|
140
|
+
app/jobs/send_welcome_email.rb + test (+ the gemstack_jobs migration the first time)
|
|
141
|
+
|
|
142
|
+
gemstack generate policy Order
|
|
143
|
+
app/policies/order_policy.rb + test (needs gemstack add auth)
|
|
144
|
+
|
|
145
|
+
gemstack generate deploy
|
|
146
|
+
Dockerfile (api + web images), compose.yaml, Caddyfile, Procfile, .dockerignore
|
|
147
|
+
|
|
148
|
+
Field syntax: name:type[:optional][:unique][:index]. Types: #{ResourceSpec::TYPES.join(", ")}.
|
|
149
|
+
Fields are required unless marked :optional.
|
|
150
|
+
DESC
|
|
151
|
+
method_option :api_only, type: :boolean, default: false, desc: "resource: backend only"
|
|
152
|
+
method_option :frontend_only, type: :boolean, default: false, desc: "resource: frontend only"
|
|
153
|
+
method_option :crud, type: :boolean, default: false, desc: "resource: all REST actions (the default)"
|
|
154
|
+
method_option :actions, type: :string, desc: "resource: comma-separated subset of REST actions"
|
|
155
|
+
method_option :skip_tests, type: :boolean, default: false
|
|
156
|
+
method_option :skip_contract, type: :boolean, default: false, desc: "don't regenerate the TypeScript contract"
|
|
157
|
+
method_option :force, type: :boolean, default: false, desc: "overwrite existing files"
|
|
158
|
+
def generate(generator = nil, name = nil, *args)
|
|
159
|
+
root = Project.root!
|
|
160
|
+
case generator
|
|
161
|
+
when "controller"
|
|
162
|
+
abort("Usage: gemstack generate controller NAME [ACTIONS...]") unless name
|
|
163
|
+
ControllerGenerator.new(name, args, root: root, force: options[:force]).run
|
|
164
|
+
when "model" then generate_model(root, name, args)
|
|
165
|
+
when "resource" then generate_resource(root, name, args)
|
|
166
|
+
when "migration"
|
|
167
|
+
abort("Usage: gemstack generate migration NAME [field:type ...]") unless name
|
|
168
|
+
MigrationGenerator.new(name, args, root: root).run
|
|
169
|
+
when "job" then generate_job(root, name, args)
|
|
170
|
+
when "policy" then generate_policy(root, name)
|
|
171
|
+
when "deploy"
|
|
172
|
+
DeployGenerator.new(root: root, force: options[:force]).run
|
|
173
|
+
say("\nNext: docker compose up --build (see compose.yaml) · recipes: docs/deployment.md")
|
|
174
|
+
when nil then abort("Usage: gemstack generate GENERATOR NAME. Generators: #{GENERATORS}")
|
|
175
|
+
else abort("Unknown generator #{generator.inspect}. Available: #{GENERATORS}")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
desc "add FEATURE", "Add an optional module to this app: realtime, auth, storage"
|
|
180
|
+
long_desc <<~DESC
|
|
181
|
+
gemstack add realtime
|
|
182
|
+
Adds gemstack-realtime, config/channels.rb, frontend/lib/gemstack/realtime.ts and test helpers.
|
|
183
|
+
|
|
184
|
+
gemstack add auth
|
|
185
|
+
Sign up, log in/out, password reset and email verification, API tokens and policies:
|
|
186
|
+
migrations (users, sessions, auth_tokens), User model, controllers, routes, AuthMailer,
|
|
187
|
+
tests and Next.js pages (/login, /signup, /forgot-password, /reset-password, /verify-email, /account).
|
|
188
|
+
|
|
189
|
+
gemstack add storage
|
|
190
|
+
File uploads straight from the browser to disk (development) or S3: config, uploads
|
|
191
|
+
controller, routes and frontend/lib/upload.ts.
|
|
192
|
+
DESC
|
|
193
|
+
method_option :skip_install, type: :boolean, default: false
|
|
194
|
+
method_option :skip_contract, type: :boolean, default: false, desc: "don't regenerate the TypeScript contract"
|
|
195
|
+
def add(feature)
|
|
196
|
+
root = Project.root!
|
|
197
|
+
AddGenerator.new(feature, root: root, install: !options[:skip_install]).run
|
|
198
|
+
refresh_contract(root) if %w[auth
|
|
199
|
+
storage].include?(feature) && !options[:skip_install] && !options[:skip_contract]
|
|
200
|
+
say("\n#{ADD_NEXT_STEPS.fetch(feature)}")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
ADD_NEXT_STEPS = {
|
|
204
|
+
"realtime" => "Next: declare channels in config/channels.rb, then GemStack.broadcast(...) — see docs/realtime.md",
|
|
205
|
+
"auth" => "Next: gemstack db:migrate · open http://localhost:3000/signup · " \
|
|
206
|
+
"`before :require_login` in controllers — see docs/authentication.md",
|
|
207
|
+
"storage" => "Next: uploadFile(file) from frontend/lib/upload.ts · production: STORAGE_SERVICE=s3, S3_BUCKET " \
|
|
208
|
+
"— see docs/storage.md"
|
|
209
|
+
}.freeze
|
|
210
|
+
|
|
211
|
+
desc "doctor", "Check that this app can run (Ruby, Node, database, migrations, contract…) and how to fix it"
|
|
212
|
+
long_desc <<~DESC
|
|
213
|
+
gemstack doctor checks for development
|
|
214
|
+
gemstack doctor --production also checks the settings a deploy needs (run it with the production
|
|
215
|
+
environment variables: SECRET_KEY_BASE, DATABASE_URL, SMTP_URL…)
|
|
216
|
+
DESC
|
|
217
|
+
method_option :production, type: :boolean, default: false
|
|
218
|
+
def doctor
|
|
219
|
+
root = Project.ensure_bundle!(self.class.argv)
|
|
220
|
+
exit(1) unless Doctor.new(root: root, production: options[:production]).run.ok?
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
desc "version", "Print the GemStack version"
|
|
224
|
+
def version
|
|
225
|
+
say("GemStack #{GemStack::VERSION}")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
no_commands do
|
|
229
|
+
def generate_model(root, name, args)
|
|
230
|
+
abort("Usage: gemstack generate model NAME field:type ...") unless name && !args.empty?
|
|
231
|
+
spec = ResourceSpec.new(name, args)
|
|
232
|
+
ResourceGenerator.new(spec, root: root, parts: %i[migration model serializer], tests: !options[:skip_tests],
|
|
233
|
+
force: options[:force]).run
|
|
234
|
+
say("\nNext: gemstack db:migrate")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def generate_job(root, name, args)
|
|
238
|
+
abort("Usage: gemstack generate job NAME [QUEUE]") unless name
|
|
239
|
+
JobGenerator.new(name, queue: args.first, root: root, force: options[:force]).run
|
|
240
|
+
say("\nNext: gemstack db:migrate (first job only) · #{Inflector.camelize(name)}.perform_later(...)")
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def generate_policy(root, name)
|
|
244
|
+
abort("Usage: gemstack generate policy MODEL") unless name
|
|
245
|
+
PolicyGenerator.new(name, root: root, force: options[:force]).run
|
|
246
|
+
say("\nNext: authorize!(record) and policy_scope(#{Inflector.camelize(name)}) in controllers")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def generate_resource(root, name, args)
|
|
250
|
+
abort("Usage: gemstack generate resource NAME field:type ...") unless name
|
|
251
|
+
abort("--api-only and --frontend-only can't be combined") if options[:api_only] && options[:frontend_only]
|
|
252
|
+
interactive = args.empty? && $stdin.tty?
|
|
253
|
+
args = ask("Fields (e.g. name:string price:decimal description:text:optional):").split if interactive
|
|
254
|
+
abort("Give at least one field, e.g. `gemstack generate resource #{name} name:string`") if args.empty?
|
|
255
|
+
|
|
256
|
+
spec = ResourceSpec.new(name, args, actions: resource_actions(interactive))
|
|
257
|
+
parts = resource_parts(root, interactive)
|
|
258
|
+
tests = !options[:skip_tests] && (!interactive || yes?("Generate tests? [Y/n]"))
|
|
259
|
+
ResourceGenerator.new(spec, root: root, parts: parts, tests: tests, force: options[:force]).run
|
|
260
|
+
refresh_contract(root) unless options[:skip_contract]
|
|
261
|
+
page = " · open http://localhost:3000/#{spec.url_segment}" if parts.include?(:frontend)
|
|
262
|
+
say("\nNext: gemstack db:migrate#{page}")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def resource_parts(root, interactive)
|
|
266
|
+
parts = options[:frontend_only] ? [] : %i[migration model serializer controller]
|
|
267
|
+
frontend = File.directory?(File.join(root, "frontend")) && !options[:api_only]
|
|
268
|
+
frontend &&= yes?("Generate Next.js pages? [Y/n]") if interactive
|
|
269
|
+
frontend ? parts + [:frontend] : parts
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def resource_actions(interactive)
|
|
273
|
+
return options[:actions].split(",").map(&:strip) if options[:actions]
|
|
274
|
+
return ResourceSpec::REST_ACTIONS if !interactive || yes?("Generate the full CRUD API? [Y/n]")
|
|
275
|
+
|
|
276
|
+
ask("Actions (comma-separated from #{ResourceSpec::REST_ACTIONS.join(",")}):").split(",").map(&:strip)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Thor's yes? only accepts y/yes; treat an empty answer as "yes".
|
|
280
|
+
def yes?(question)
|
|
281
|
+
answer = ask(question).strip.downcase
|
|
282
|
+
answer.empty? || %w[y yes].include?(answer)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Regenerates TypeScript types/clients in the app's own bundle.
|
|
286
|
+
def refresh_contract(root)
|
|
287
|
+
say(" #{"run".rjust(9)} gemstack contract")
|
|
288
|
+
ok = (defined?(Bundler) ? Bundler.with_unbundled_env { contract_command(root) } : contract_command(root))
|
|
289
|
+
return if ok
|
|
290
|
+
|
|
291
|
+
say(" #{"warning".rjust(9)} contract not generated — run `gemstack contract` once the database is reachable")
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def contract_command(root)
|
|
295
|
+
system("bundle", "exec", "gemstack", "contract", "--quiet", chdir: root)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
<% if gemstack_path -%>
|
|
6
|
+
# GemStack from a local checkout.
|
|
7
|
+
path "<%= gemstack_path %>" do
|
|
8
|
+
gem "gemstack"
|
|
9
|
+
gem "gemstack-cache"
|
|
10
|
+
gem "gemstack-cli"
|
|
11
|
+
gem "gemstack-contract"
|
|
12
|
+
gem "gemstack-core"
|
|
13
|
+
<% if database? -%>
|
|
14
|
+
gem "gemstack-db"
|
|
15
|
+
<% end -%>
|
|
16
|
+
gem "gemstack-jobs"
|
|
17
|
+
gem "gemstack-dev"
|
|
18
|
+
gem "gemstack-http"
|
|
19
|
+
gem "gemstack-schema"
|
|
20
|
+
end
|
|
21
|
+
<% else -%>
|
|
22
|
+
gem "gemstack", "~> <%= version %>"
|
|
23
|
+
<% if database? -%>
|
|
24
|
+
gem "gemstack-db", "~> <%= version %>"
|
|
25
|
+
<% end -%>
|
|
26
|
+
gem "gemstack-jobs", "~> <%= version %>"
|
|
27
|
+
<% end -%>
|
|
28
|
+
gem "puma", ">= 6.4"
|
|
29
|
+
|
|
30
|
+
# Any gem works here as usual, e.g.:
|
|
31
|
+
# gem "brotli" # Brotli compression (gzip is used without it)
|
|
32
|
+
# gem "redis-client" # shared cache: config.cache.store = :redis
|
|
33
|
+
# gem "stripe"
|
|
34
|
+
|
|
35
|
+
group :development, :test do
|
|
36
|
+
gem "irb" # `gemstack console`; a bundled (not default) gem since Ruby 4.0
|
|
37
|
+
gem "minitest", "~> 5.25"
|
|
38
|
+
gem "rack-test", "~> 2.2"
|
|
39
|
+
end
|