gemstack-cli 0.2.5 → 0.3.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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +3 -25
  3. data/README.md +6 -17
  4. data/exe/gemstack +8 -1
  5. metadata +6 -159
  6. data/lib/gemstack/cli/add_generator.rb +0 -239
  7. data/lib/gemstack/cli/app_generator.rb +0 -142
  8. data/lib/gemstack/cli/commands/db.rb +0 -135
  9. data/lib/gemstack/cli/commands/jobs.rb +0 -96
  10. data/lib/gemstack/cli/controller_generator.rb +0 -73
  11. data/lib/gemstack/cli/deploy_generator.rb +0 -102
  12. data/lib/gemstack/cli/doctor.rb +0 -308
  13. data/lib/gemstack/cli/generator.rb +0 -144
  14. data/lib/gemstack/cli/job_generator.rb +0 -48
  15. data/lib/gemstack/cli/migration_generator.rb +0 -52
  16. data/lib/gemstack/cli/policy_generator.rb +0 -35
  17. data/lib/gemstack/cli/project.rb +0 -53
  18. data/lib/gemstack/cli/resource_generator.rb +0 -170
  19. data/lib/gemstack/cli/resource_spec.rb +0 -95
  20. data/lib/gemstack/cli.rb +0 -304
  21. data/templates/app/Gemfile.tt +0 -44
  22. data/templates/app/README.md.tt +0 -29
  23. data/templates/app/app/controllers/application_controller.rb +0 -6
  24. data/templates/app/app/jobs/application_job.rb +0 -9
  25. data/templates/app/app/mailers/application_mailer.rb +0 -8
  26. data/templates/app/app/mailers/templates/dot_keep +0 -0
  27. data/templates/app/app/models/application_model.rb +0 -19
  28. data/templates/app/app/serializers/application_serializer.rb +0 -6
  29. data/templates/app/bin/gemstack +0 -7
  30. data/templates/app/config/app.rb.tt +0 -54
  31. data/templates/app/config/database.yml.tt +0 -61
  32. data/templates/app/config/environments/development.rb.tt +0 -35
  33. data/templates/app/config/environments/production.rb.tt +0 -40
  34. data/templates/app/config/environments/test.rb.tt +0 -21
  35. data/templates/app/config/puma.rb +0 -21
  36. data/templates/app/config/routes.rb +0 -10
  37. data/templates/app/config.ru +0 -6
  38. data/templates/app/db/migrations/dot_keep +0 -0
  39. data/templates/app/db/seeds.rb +0 -5
  40. data/templates/app/dot_env.example.tt +0 -19
  41. data/templates/app/dot_gitignore +0 -16
  42. data/templates/app/dot_node-version.tt +0 -1
  43. data/templates/app/dot_nvmrc.tt +0 -1
  44. data/templates/app/dot_ruby-version.tt +0 -1
  45. data/templates/app/dot_tool-versions.tt +0 -4
  46. data/templates/app/test/health_test.rb +0 -18
  47. data/templates/app/test/test_helper.rb.tt +0 -25
  48. data/templates/auth/app/controllers/api_tokens_controller.rb +0 -35
  49. data/templates/auth/app/controllers/email_verifications_controller.rb +0 -31
  50. data/templates/auth/app/controllers/password_resets_controller.rb +0 -40
  51. data/templates/auth/app/controllers/registrations_controller.rb +0 -19
  52. data/templates/auth/app/controllers/sessions_controller.rb +0 -29
  53. data/templates/auth/app/mailers/auth_mailer.rb +0 -20
  54. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +0 -2
  55. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +0 -2
  56. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +0 -3
  57. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +0 -6
  58. data/templates/auth/app/models/auth_token.rb +0 -15
  59. data/templates/auth/app/models/user.rb +0 -12
  60. data/templates/auth/app/policies/application_policy.rb +0 -6
  61. data/templates/auth/app/serializers/api_token_serializer.rb +0 -6
  62. data/templates/auth/app/serializers/new_api_token_serializer.rb +0 -9
  63. data/templates/auth/app/serializers/user_serializer.rb +0 -6
  64. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +0 -41
  65. data/templates/auth/frontend/app/account/page.tsx +0 -116
  66. data/templates/auth/frontend/app/forgot-password/page.tsx +0 -48
  67. data/templates/auth/frontend/app/login/page.tsx +0 -26
  68. data/templates/auth/frontend/app/reset-password/page.tsx +0 -11
  69. data/templates/auth/frontend/app/signup/page.tsx +0 -27
  70. data/templates/auth/frontend/app/verify-email/page.tsx +0 -11
  71. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +0 -58
  72. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +0 -43
  73. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +0 -28
  74. data/templates/auth/frontend/lib/auth.ts +0 -113
  75. data/templates/auth/test/controllers/auth_test.rb +0 -108
  76. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +0 -10
  77. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +0 -14
  78. data/templates/deploy/Caddyfile.tt +0 -19
  79. data/templates/deploy/Dockerfile.tt +0 -63
  80. data/templates/deploy/Procfile.tt +0 -6
  81. data/templates/deploy/compose.yaml.tt +0 -129
  82. data/templates/deploy/dot_dockerignore +0 -14
  83. data/templates/frontend/app/globals.css +0 -171
  84. data/templates/frontend/app/layout.tsx.tt +0 -19
  85. data/templates/frontend/app/page.module.css +0 -315
  86. data/templates/frontend/app/page.tsx.tt +0 -183
  87. data/templates/frontend/app/providers.tsx +0 -24
  88. data/templates/frontend/lib/gemstack/client.ts +0 -125
  89. data/templates/frontend/next-env.d.ts +0 -5
  90. data/templates/frontend/next.config.ts +0 -17
  91. data/templates/frontend/package.json.tt +0 -23
  92. data/templates/frontend/tsconfig.json +0 -21
  93. data/templates/job/app/jobs/%file_name%.rb.tt +0 -15
  94. data/templates/job/test/jobs/%file_name%_test.rb.tt +0 -15
  95. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +0 -17
  96. data/templates/policy/app/policies/%file_name%_policy.rb.tt +0 -21
  97. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +0 -9
  98. data/templates/realtime/config/channels.rb +0 -14
  99. data/templates/realtime/frontend/lib/gemstack/realtime.ts +0 -120
  100. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +0 -51
  101. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +0 -74
  102. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +0 -45
  103. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +0 -49
  104. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +0 -27
  105. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +0 -51
  106. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +0 -15
  107. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +0 -98
  108. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +0 -36
  109. data/templates/resource/frontend/frontend/lib/format.ts +0 -12
  110. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +0 -64
  111. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +0 -18
  112. data/templates/resource/model/app/models/%file_name%.rb.tt +0 -15
  113. data/templates/resource/model/test/models/%file_name%_test.rb.tt +0 -29
  114. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +0 -7
  115. data/templates/storage/app/controllers/uploads_controller.rb.tt +0 -33
  116. data/templates/storage/app/serializers/upload_serializer.rb +0 -11
  117. data/templates/storage/frontend/lib/upload.ts +0 -52
  118. data/templates/storage/test/controllers/uploads_test.rb.tt +0 -30
@@ -1,170 +0,0 @@
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
- ensure_base_classes(@root, *(%i[model serializer] & @parts))
38
- @parts.each { |part| render_part(part) }
39
- add_routes if @parts.include?(:controller)
40
- self
41
- end
42
-
43
- # --- template helpers -------------------------------------------------
44
-
45
- def fields = spec.fields
46
- def class_name = spec.class_name
47
- def input_type = spec.action?(:create) ? "#{class_name}Input" : "#{class_name}UpdateInput"
48
-
49
- # Sequel schema DSL per field type ("%s" is the column name).
50
- COLUMN_TYPES = {
51
- "string" => "String :%s", "text" => "String :%s, text: true", "integer" => "Integer :%s",
52
- "bigint" => "Bignum :%s", "float" => "Float :%s", "decimal" => "BigDecimal :%s, size: [12, 2]",
53
- "date" => "Date :%s", "datetime" => "column :%s, :timestamptz", "uuid" => "column :%s, :uuid",
54
- "json" => "column :%s, :jsonb"
55
- }.freeze
56
-
57
- def migration_column(field)
58
- null = field.required? ? ", null: false" : ""
59
- case field.type
60
- when "references"
61
- "foreign_key :#{field.column}, :#{field.referenced_table}, type: :Bignum#{null}, on_delete: :restrict"
62
- when "boolean" then "TrueClass :#{field.name}, null: false, default: false"
63
- else
64
- unique = field.unique && field.type != "json" ? ", unique: true" : ""
65
- format(COLUMN_TYPES.fetch(field.type), field.name) + null + unique
66
- end
67
- end
68
-
69
- def model_field(field)
70
- options = []
71
- options << "null: false" if field.required?
72
- options << "null: false, default: false" if field.type == "boolean"
73
- options << "size: 255" if field.type == "string"
74
- ["field :#{field.column}, :#{field.model_type}", *options].join(", ")
75
- end
76
-
77
- def ts_value_type(field) = field.type == "boolean" ? "boolean" : "string"
78
-
79
- # Expression turning a record (possibly undefined) into a form value.
80
- def form_initial(field)
81
- attr = "record?.#{field.column}"
82
- case field.type
83
- when "boolean" then "#{attr} ?? false"
84
- when "integer", "bigint", "float", "references" then "String(#{attr} ?? \"\")"
85
- when "datetime" then "(#{attr} ?? \"\").slice(0, 16)"
86
- when "json" then "#{attr} == null ? \"\" : JSON.stringify(#{attr}, null, 2)"
87
- else "#{attr} ?? \"\""
88
- end
89
- end
90
-
91
- # Expression turning a form value into an API input value.
92
- def form_output(field)
93
- value = "values.#{field.column}"
94
- return value if field.type == "boolean"
95
-
96
- converted =
97
- case field.type
98
- when "integer", "bigint", "float", "references" then "Number(#{value})"
99
- when "json" then "parseJson(#{value})"
100
- else value
101
- end
102
- blank = field.required? ? "\"\"" : "null"
103
- converted == value && field.required? ? value : "#{value} === \"\" ? #{blank} : #{converted}"
104
- end
105
-
106
- def form_control(field)
107
- key = field.column
108
- common = %(id="#{field.column}" name="#{field.column}")
109
- required = field.required? ? " required" : ""
110
- case field.type
111
- when "boolean"
112
- %(<input #{common} type="checkbox" checked={values.#{key}} onChange={set("#{key}")} />)
113
- when "text", "json"
114
- %(<textarea #{common} rows={4} value={values.#{key}} onChange={set("#{key}")}#{required} />)
115
- else
116
- attrs = { "integer" => %(type="number" step="1"), "bigint" => %(type="number" step="1"),
117
- "references" => %(type="number" step="1"), "float" => %(type="number" step="any"),
118
- "decimal" => %(type="text" inputMode="decimal"), "date" => %(type="date"),
119
- "datetime" => %(type="datetime-local") }.fetch(field.type, %(type="text"))
120
- %(<input #{common} #{attrs} value={values.#{key}} onChange={set("#{key}")}#{required} />)
121
- end
122
- end
123
-
124
- def table_fields = fields.first(4)
125
- def uses_json? = fields.any? { |f| f.type == "json" }
126
-
127
- private
128
-
129
- def render_part(part)
130
- template_files("resource/#{part}", override_root: @root).sort.each do |rel, source|
131
- next if skip?(part, rel)
132
-
133
- target = File.join(@root, substitute(rel.delete_suffix(".tt")))
134
- write(target, render(File.read(source), source))
135
- end
136
- end
137
-
138
- def skip?(part, rel)
139
- return true if !tests && rel.start_with?("test/")
140
- return false unless part == :frontend
141
-
142
- requirement = REQUIRES.find { |suffix, _| rel.delete_suffix(".tt").end_with?(suffix) }&.last
143
- requirement ? requirement.none? { |action| spec.action?(action) } : false
144
- end
145
-
146
- def substitute(path)
147
- path.gsub(/%(\w+)%/) do
148
- key = ::Regexp.last_match(1)
149
- key == "timestamp" ? timestamp : spec.public_send(key)
150
- end
151
- end
152
-
153
- def add_routes
154
- path = File.join(@root, "config/routes.rb")
155
- return status("skip", path, "not found — add `resources :#{spec.plural}` manually") unless File.file?(path)
156
-
157
- only = spec.crud? ? "" : ", only: %i[#{spec.actions.join(" ")}]"
158
- line = "resources :#{spec.plural}#{only}"
159
- content = File.read(path)
160
- return status("identical", path) if content.match?(/^\s*resources :#{spec.plural}\b/)
161
- unless content.match?(ControllerGenerator::ROUTES_BLOCK)
162
- return status("skip", path, "no `GemStack.routes do` block — add `#{line}`")
163
- end
164
-
165
- File.write(path, content.sub(ControllerGenerator::ROUTES_BLOCK) { |open| "#{open} #{line}\n" })
166
- status("route", path, line)
167
- end
168
- end
169
- end
170
- end
@@ -1,95 +0,0 @@
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 DELETED
@@ -1,304 +0,0 @@
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 :database, aliases: "-d", type: :string, default: "sqlite3",
57
- desc: "sqlite3 (default), postgresql, mysql2 or trilogy"
58
- method_option :skip_database, type: :boolean, default: false, desc: "No database / gemstack-db"
59
- method_option :gemstack_path, type: :string, desc: "Use GemStack from a local checkout (its gems/ directory)"
60
- def new(name)
61
- AppGenerator.new(name, options.transform_keys(&:to_sym)).run
62
- end
63
-
64
- desc "dev", "Run the application: Next.js + Ruby API behind one origin (http://localhost:3000)"
65
- def dev
66
- root = Project.ensure_bundle!(self.class.argv)
67
- ENV["GEMSTACK_ENV"] ||= "development"
68
- Project.load_config!(root)
69
- require "gemstack/dev"
70
- Dev::Supervisor.new(root: root).run
71
- rescue GemStack::Error => e
72
- abort("gemstack dev: #{e.message}")
73
- end
74
-
75
- desc "server", "Run only the Ruby API with Puma (alias: s)"
76
- method_option :port, aliases: "-p", type: :numeric, desc: "Port (default: GEMSTACK_API_PORT or 4000)"
77
- def server
78
- root = Project.ensure_bundle!(self.class.argv)
79
- env = {}
80
- env["GEMSTACK_API_PORT"] = options[:port].to_s if options[:port]
81
- env["GEMSTACK_ENV"] = options[:environment] if options[:environment]
82
- exec(env, "bundle", "exec", "puma", "-C", File.join(root, "config/puma.rb"))
83
- end
84
-
85
- desc "routes", "List API routes"
86
- method_option :grep, aliases: "-g", type: :string, desc: "Only routes matching this text"
87
- def routes
88
- root = Project.ensure_bundle!(self.class.argv)
89
- use_environment!
90
- Project.load_config!(root)
91
- GemStack.config.reload_code = false
92
- GemStack.config.logger.level = :warn
93
- GemStack.config.db.log_queries = false if GemStack.config.respond_to?(:db)
94
- rows = GemStack.boot!.routes.map { |r| [r.verb, r.path, r.target] }
95
- rows.select! { |row| row.join(" ").include?(options[:grep]) } if options[:grep]
96
- return say("No routes defined. Add them to config/routes.rb.") if rows.empty?
97
-
98
- print_table(rows)
99
- end
100
-
101
- desc "console", "Start an IRB session with the application loaded (alias: c)"
102
- def console
103
- root = Project.ensure_bundle!(self.class.argv)
104
- use_environment!
105
- Project.load_config!(root)
106
- GemStack.boot!
107
- require "irb"
108
- say("GemStack #{GemStack::VERSION} console (#{GemStack.env}). `GemStack.application.reload!` reloads code.")
109
- ARGV.clear
110
- IRB.start
111
- end
112
-
113
- desc "test [FILES...]", "Run the Ruby test suite (alias: t)"
114
- def test(*files)
115
- root = Project.ensure_bundle!(self.class.argv)
116
- files = Dir.glob("test/**/*_test.rb", base: root).sort if files.empty?
117
- return say("No tests found in test/.") if files.empty?
118
-
119
- loader = "ARGV.each { |f| require File.expand_path(f) }"
120
- exec({ "GEMSTACK_ENV" => "test" }, RbConfig.ruby, "-Itest", "-e", loader, *files)
121
- end
122
-
123
- desc "generate GENERATOR NAME [ARGS]",
124
- "Generate code (alias: g). Generators: resource, model, migration, controller, job, policy, deploy"
125
- long_desc <<~DESC
126
- gemstack generate resource Product name:string price:decimal description:text:optional active:boolean
127
- Full vertical slice: migration, model, serializer, controller, routes, tests,
128
- TypeScript client (via the contract) and Next.js pages. Without fields it asks interactively.
129
- --api-only backend only
130
- --frontend-only Next.js pages/components only (for an existing backend resource)
131
- --actions=index,show a subset of index,show,create,update,destroy (default: all = --crud)
132
- --skip-tests
133
-
134
- gemstack generate model Product name:string price:decimal
135
- Migration, model, serializer and model test.
136
-
137
- gemstack generate migration AddSkuToProducts sku:string:unique
138
-
139
- gemstack generate controller Products index show publish
140
-
141
- gemstack generate job SendWelcomeEmail [QUEUE]
142
- app/jobs/send_welcome_email.rb + test (+ the gemstack_jobs migration the first time)
143
-
144
- gemstack generate policy Order
145
- app/policies/order_policy.rb + test (needs gemstack add auth)
146
-
147
- gemstack generate deploy
148
- Dockerfile (api + web images), compose.yaml, Caddyfile, Procfile, .dockerignore
149
-
150
- Field syntax: name:type[:optional][:unique][:index]. Types: #{ResourceSpec::TYPES.join(", ")}.
151
- Fields are required unless marked :optional.
152
- DESC
153
- method_option :api_only, type: :boolean, default: false, desc: "resource: backend only"
154
- method_option :frontend_only, type: :boolean, default: false, desc: "resource: frontend only"
155
- method_option :crud, type: :boolean, default: false, desc: "resource: all REST actions (the default)"
156
- method_option :actions, type: :string, desc: "resource: comma-separated subset of REST actions"
157
- method_option :skip_tests, type: :boolean, default: false
158
- method_option :skip_contract, type: :boolean, default: false, desc: "don't regenerate the TypeScript contract"
159
- method_option :force, type: :boolean, default: false, desc: "overwrite existing files"
160
- def generate(generator = nil, name = nil, *args)
161
- root = Project.root!
162
- case generator
163
- when "controller"
164
- abort("Usage: gemstack generate controller NAME [ACTIONS...]") unless name
165
- ControllerGenerator.new(name, args, root: root, force: options[:force]).run
166
- when "model" then generate_model(root, name, args)
167
- when "resource" then generate_resource(root, name, args)
168
- when "migration"
169
- abort("Usage: gemstack generate migration NAME [field:type ...]") unless name
170
- MigrationGenerator.new(name, args, root: root).run
171
- when "job" then generate_job(root, name, args)
172
- when "policy" then generate_policy(root, name)
173
- when "deploy"
174
- DeployGenerator.new(root: root, force: options[:force]).run
175
- say("\nNext: docker compose up --build (see compose.yaml) · recipes: docs/deployment.md")
176
- when nil then abort("Usage: gemstack generate GENERATOR NAME. Generators: #{GENERATORS}")
177
- else abort("Unknown generator #{generator.inspect}. Available: #{GENERATORS}")
178
- end
179
- end
180
-
181
- desc "add FEATURE", "Add an optional module to this app: realtime, auth, storage"
182
- long_desc <<~DESC
183
- gemstack add realtime
184
- Adds gemstack-realtime, config/channels.rb, frontend/lib/gemstack/realtime.ts and test helpers.
185
-
186
- gemstack add auth
187
- Sign up, log in/out, password reset and email verification, API tokens and policies:
188
- migrations (users, sessions, auth_tokens), User model, controllers, routes, AuthMailer,
189
- tests and Next.js pages (/login, /signup, /forgot-password, /reset-password, /verify-email, /account).
190
-
191
- gemstack add storage
192
- File uploads straight from the browser to disk (development) or S3: config, uploads
193
- controller, routes and frontend/lib/upload.ts.
194
- DESC
195
- method_option :skip_install, type: :boolean, default: false
196
- method_option :skip_contract, type: :boolean, default: false, desc: "don't regenerate the TypeScript contract"
197
- def add(feature)
198
- root = Project.root!
199
- AddGenerator.new(feature, root: root, install: !options[:skip_install]).run
200
- refresh_contract(root) if %w[auth
201
- storage].include?(feature) && !options[:skip_install] && !options[:skip_contract]
202
- say("\n#{ADD_NEXT_STEPS.fetch(feature)}")
203
- end
204
-
205
- ADD_NEXT_STEPS = {
206
- "realtime" => "Next: declare channels in config/channels.rb, then GemStack.broadcast(...) — see docs/realtime.md",
207
- "auth" => "Next: gemstack db:migrate · open http://localhost:3000/signup · " \
208
- "`before :require_login` in controllers — see docs/authentication.md",
209
- "storage" => "Next: uploadFile(file) from frontend/lib/upload.ts · production: STORAGE_SERVICE=s3, S3_BUCKET " \
210
- "— see docs/storage.md"
211
- }.freeze
212
-
213
- desc "doctor", "Check that this app can run (Ruby, Node, database, migrations, contract…) and how to fix it"
214
- long_desc <<~DESC
215
- gemstack doctor checks for development
216
- gemstack doctor --production also checks the settings a deploy needs (run it with the production
217
- environment variables: SECRET_KEY_BASE, DATABASE_URL, SMTP_URL…)
218
- DESC
219
- method_option :production, type: :boolean, default: false
220
- def doctor
221
- root = Project.ensure_bundle!(self.class.argv)
222
- exit(1) unless Doctor.new(root: root, production: options[:production]).run.ok?
223
- end
224
-
225
- desc "version", "Print the GemStack version"
226
- def version
227
- say("GemStack #{GemStack::VERSION}")
228
- end
229
-
230
- no_commands do
231
- # -e ENV, else GEMSTACK_ENV, else development.
232
- def use_environment! = ENV["GEMSTACK_ENV"] = options[:environment] || ENV["GEMSTACK_ENV"] || "development"
233
-
234
- def generate_model(root, name, args)
235
- abort("Usage: gemstack generate model NAME field:type ...") unless name && !args.empty?
236
- spec = ResourceSpec.new(name, args)
237
- ResourceGenerator.new(spec, root: root, parts: %i[migration model serializer], tests: !options[:skip_tests],
238
- force: options[:force]).run
239
- say("\nNext: gemstack db:migrate")
240
- end
241
-
242
- def generate_job(root, name, args)
243
- abort("Usage: gemstack generate job NAME [QUEUE]") unless name
244
- JobGenerator.new(name, queue: args.first, root: root, force: options[:force]).run
245
- say("\nNext: gemstack db:migrate (first job only) · #{Inflector.camelize(name)}.perform_later(...)")
246
- end
247
-
248
- def generate_policy(root, name)
249
- abort("Usage: gemstack generate policy MODEL") unless name
250
- PolicyGenerator.new(name, root: root, force: options[:force]).run
251
- say("\nNext: authorize!(record) and policy_scope(#{Inflector.camelize(name)}) in controllers")
252
- end
253
-
254
- def generate_resource(root, name, args)
255
- abort("Usage: gemstack generate resource NAME field:type ...") unless name
256
- abort("--api-only and --frontend-only can't be combined") if options[:api_only] && options[:frontend_only]
257
- interactive = args.empty? && $stdin.tty?
258
- args = ask("Fields (e.g. name:string price:decimal description:text:optional):").split if interactive
259
- abort("Give at least one field, e.g. `gemstack generate resource #{name} name:string`") if args.empty?
260
-
261
- spec = ResourceSpec.new(name, args, actions: resource_actions(interactive))
262
- parts = resource_parts(root, interactive)
263
- tests = !options[:skip_tests] && (!interactive || yes?("Generate tests? [Y/n]"))
264
- ResourceGenerator.new(spec, root: root, parts: parts, tests: tests, force: options[:force]).run
265
- refresh_contract(root) unless options[:skip_contract]
266
- page = " · open http://localhost:3000/#{spec.url_segment}" if parts.include?(:frontend)
267
- say("\nNext: gemstack db:migrate#{page}")
268
- end
269
-
270
- def resource_parts(root, interactive)
271
- parts = options[:frontend_only] ? [] : %i[migration model serializer controller]
272
- frontend = File.directory?(File.join(root, "frontend")) && !options[:api_only]
273
- frontend &&= yes?("Generate Next.js pages? [Y/n]") if interactive
274
- frontend ? parts + [:frontend] : parts
275
- end
276
-
277
- def resource_actions(interactive)
278
- return options[:actions].split(",").map(&:strip) if options[:actions]
279
- return ResourceSpec::REST_ACTIONS if !interactive || yes?("Generate the full CRUD API? [Y/n]")
280
-
281
- ask("Actions (comma-separated from #{ResourceSpec::REST_ACTIONS.join(",")}):").split(",").map(&:strip)
282
- end
283
-
284
- # Thor's yes? only accepts y/yes; treat an empty answer as "yes".
285
- def yes?(question)
286
- answer = ask(question).strip.downcase
287
- answer.empty? || %w[y yes].include?(answer)
288
- end
289
-
290
- # Regenerates TypeScript types/clients in the app's own bundle.
291
- def refresh_contract(root)
292
- say(" #{"run".rjust(9)} gemstack contract")
293
- ok = (defined?(Bundler) ? Bundler.with_unbundled_env { contract_command(root) } : contract_command(root))
294
- return if ok
295
-
296
- say(" #{"warning".rjust(9)} contract not generated — run `gemstack contract` once the database is reachable")
297
- end
298
-
299
- def contract_command(root)
300
- system("bundle", "exec", "gemstack", "contract", "--quiet", chdir: root)
301
- end
302
- end
303
- end
304
- end
@@ -1,44 +0,0 @@
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-mail"
18
- gem "gemstack-dev"
19
- gem "gemstack-http"
20
- gem "gemstack-schema"
21
- end
22
- <% else -%>
23
- gem "gemstack", "~> <%= version %>"
24
- <% if database? -%>
25
- gem "gemstack-db", "~> <%= version %>"
26
- <% end -%>
27
- gem "gemstack-jobs", "~> <%= version %>"
28
- gem "gemstack-mail", "~> <%= version %>"
29
- <% end -%>
30
- gem "puma", ">= 6.4"
31
- <% if database? -%>
32
- gem "<%= driver_gem %>", "<%= driver_version %>" # the <%= database_adapter %> driver (config/database.yml)
33
- <% end -%>
34
-
35
- # Any gem works here as usual, e.g.:
36
- # gem "brotli" # Brotli compression (gzip is used without it)
37
- # gem "redis-client" # shared cache: config.cache.store = :redis
38
- # gem "stripe"
39
-
40
- group :development, :test do
41
- gem "irb" # `gemstack console` (a bundled gem, not a default one, in newer Rubies)
42
- gem "minitest", "~> 5.25"
43
- gem "rack-test", "~> 2.2"
44
- end
@@ -1,29 +0,0 @@
1
- # <%= module_name %>
2
-
3
- A [GemStack](https://github.com/gemstack/gemstack) application: a Ruby API with a
4
- Next.js + TypeScript frontend, served from one origin.
5
-
6
- ```bash
7
- gemstack dev # http://localhost:3000 (/api/* → Ruby, everything else → Next.js)
8
- gemstack routes # list API routes
9
- gemstack test # run the Ruby tests
10
- gemstack console # IRB with the app loaded
11
- gemstack generate controller Status show
12
- ```
13
-
14
- ## Layout
15
-
16
- | Path | What lives there |
17
- |---|---|
18
- | `config/app.rb` | configuration (`GemStack.configure`) |
19
- | `config/routes.rb` | API routes, relative to `/api` |
20
- | `app/` | autoloaded code: `controllers/`, and any folder you add (`services/`, ...) |
21
- | `test/` | Minitest + rack-test (`GemStack::TestCase`) |
22
- | `frontend/` | the Next.js app; `lib/gemstack/client.ts` talks to the API |
23
-
24
- ## Production
25
-
26
- Run the API with `gemstack server` (Puma, `config/puma.rb`) and the frontend
27
- with `npm run build && npm start` in `frontend/`. Either route `/api/*` to the
28
- API with your reverse proxy, or set `GEMSTACK_API_URL` for the Next.js process
29
- and it will proxy `/api/*` itself.
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Shared behaviour for every controller: before callbacks (e.g. authentication),
4
- # rescue_from handlers and helpers.
5
- class ApplicationController < GemStack::Controller
6
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # The base class for this app's background jobs (docs/background-jobs.md):
4
- # retry and discard rules declared here apply to every job.
5
- class ApplicationJob < GemStack::Job
6
- # retry_on Net::ReadTimeout, attempts: 5, wait: 30 # seconds, :exponential or ->(attempt) { … }
7
- # discard_on GemStack::NotFound # e.g. the record was deleted before the job ran
8
- # queue :default
9
- end
@@ -1,8 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # The base class for this app's mailers (docs/mail.md). Templates live in
4
- # app/mailers/templates/<mailer>/<action>.{text,html}.erb.
5
- class ApplicationMailer < GemStack::Mailer
6
- # default from: "My App <hello@example.com>" # otherwise config.mail.default_from (MAIL_FROM)
7
- # default reply_to: "support@example.com"
8
- end
File without changes