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.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +24 -0
  5. data/exe/gemstack +6 -0
  6. data/lib/gemstack/cli/add_generator.rb +237 -0
  7. data/lib/gemstack/cli/app_generator.rb +110 -0
  8. data/lib/gemstack/cli/commands/db.rb +134 -0
  9. data/lib/gemstack/cli/commands/jobs.rb +96 -0
  10. data/lib/gemstack/cli/controller_generator.rb +73 -0
  11. data/lib/gemstack/cli/deploy_generator.rb +64 -0
  12. data/lib/gemstack/cli/doctor.rb +277 -0
  13. data/lib/gemstack/cli/generator.rb +122 -0
  14. data/lib/gemstack/cli/job_generator.rb +47 -0
  15. data/lib/gemstack/cli/migration_generator.rb +52 -0
  16. data/lib/gemstack/cli/policy_generator.rb +35 -0
  17. data/lib/gemstack/cli/project.rb +53 -0
  18. data/lib/gemstack/cli/resource_generator.rb +169 -0
  19. data/lib/gemstack/cli/resource_spec.rb +95 -0
  20. data/lib/gemstack/cli.rb +299 -0
  21. data/templates/app/Gemfile.tt +39 -0
  22. data/templates/app/README.md.tt +29 -0
  23. data/templates/app/app/controllers/application_controller.rb +6 -0
  24. data/templates/app/bin/gemstack +7 -0
  25. data/templates/app/config/app.rb.tt +30 -0
  26. data/templates/app/config/environments/development.rb +6 -0
  27. data/templates/app/config/environments/production.rb +8 -0
  28. data/templates/app/config/environments/test.rb +7 -0
  29. data/templates/app/config/puma.rb +21 -0
  30. data/templates/app/config/routes.rb +10 -0
  31. data/templates/app/config.ru +6 -0
  32. data/templates/app/db/migrations/dot_keep +0 -0
  33. data/templates/app/db/seeds.rb +5 -0
  34. data/templates/app/dot_env.example +9 -0
  35. data/templates/app/dot_gitignore +14 -0
  36. data/templates/app/dot_ruby-version.tt +1 -0
  37. data/templates/app/dot_tool-versions.tt +1 -0
  38. data/templates/app/test/health_test.rb +18 -0
  39. data/templates/app/test/test_helper.rb.tt +25 -0
  40. data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
  41. data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
  42. data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
  43. data/templates/auth/app/controllers/registrations_controller.rb +19 -0
  44. data/templates/auth/app/controllers/sessions_controller.rb +29 -0
  45. data/templates/auth/app/mailers/auth_mailer.rb +20 -0
  46. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
  47. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
  48. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
  49. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
  50. data/templates/auth/app/models/auth_token.rb +15 -0
  51. data/templates/auth/app/models/user.rb +12 -0
  52. data/templates/auth/app/policies/application_policy.rb +6 -0
  53. data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
  54. data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
  55. data/templates/auth/app/serializers/user_serializer.rb +6 -0
  56. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
  57. data/templates/auth/frontend/app/account/page.tsx +116 -0
  58. data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
  59. data/templates/auth/frontend/app/login/page.tsx +26 -0
  60. data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
  61. data/templates/auth/frontend/app/signup/page.tsx +27 -0
  62. data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
  63. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
  64. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
  65. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
  66. data/templates/auth/frontend/lib/auth.ts +113 -0
  67. data/templates/auth/test/controllers/auth_test.rb +108 -0
  68. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
  69. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
  70. data/templates/deploy/Caddyfile.tt +19 -0
  71. data/templates/deploy/Dockerfile.tt +62 -0
  72. data/templates/deploy/Procfile.tt +6 -0
  73. data/templates/deploy/compose.yaml.tt +80 -0
  74. data/templates/deploy/dot_dockerignore +14 -0
  75. data/templates/frontend/app/globals.css +171 -0
  76. data/templates/frontend/app/layout.tsx.tt +19 -0
  77. data/templates/frontend/app/page.tsx.tt +45 -0
  78. data/templates/frontend/app/providers.tsx +24 -0
  79. data/templates/frontend/lib/gemstack/client.ts +125 -0
  80. data/templates/frontend/next-env.d.ts +5 -0
  81. data/templates/frontend/next.config.ts +17 -0
  82. data/templates/frontend/package.json.tt +23 -0
  83. data/templates/frontend/tsconfig.json +21 -0
  84. data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
  85. data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
  86. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
  87. data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
  88. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
  89. data/templates/realtime/config/channels.rb +14 -0
  90. data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
  91. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
  92. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
  93. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
  94. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
  95. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
  96. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
  97. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
  98. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
  99. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
  100. data/templates/resource/frontend/frontend/lib/format.ts +12 -0
  101. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +63 -0
  102. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
  103. data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
  104. data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
  105. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
  106. data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
  107. data/templates/storage/app/serializers/upload_serializer.rb +11 -0
  108. data/templates/storage/frontend/lib/upload.ts +52 -0
  109. data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
  110. 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
@@ -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