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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1a7919922a3f8ef313ffffa34f0a8884905d6f552772d99ef38908696640de5a
4
+ data.tar.gz: 65dc878039db71201b6ab2514649e52d1f0b1ede7d4d0c52299cfa7c8e68b8c8
5
+ SHA512:
6
+ metadata.gz: 6d00e50a3e84430210d7810dba99a89ebce41a7c9ddac5ede6dfe15f76d78f96ad60f15fa2ad4b24fcb2f5e8e49da602f3f7d81f736c72d2239fb92b8b3f9a68
7
+ data.tar.gz: 62a0bf6f9e50c21a636d27964a478aafd465f4ccf51f1012c94d57d8ad6e02e4822191bc87f928c5f3d76135ca968d55439a6adaefd801be94aa7555494d55c0
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shoaib Malik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # gemstack-cli
2
+
3
+ GemStack command-line interface and generators.
4
+
5
+ Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
6
+ applications. All GemStack gems are developed together in that repository and released with the same
7
+ version.
8
+
9
+ ## Installation
10
+
11
+ Installed with the `gemstack` gem; you rarely need to add it yourself.
12
+
13
+ ## Documentation
14
+
15
+ - [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/getting-started.md)
16
+ - [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
17
+ [Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
18
+
19
+ Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
20
+ (this gem lives in `gems/gemstack-cli`).
21
+
22
+ ## License
23
+
24
+ MIT — see [LICENSE.txt](LICENSE.txt).
data/exe/gemstack ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "gemstack/cli"
5
+
6
+ GemStack::CLI.start(ARGV)
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ # `gemstack add FEATURE` — opt-in modules, so apps that don't use them
6
+ # pay nothing: realtime, auth, storage.
7
+ class AddGenerator < Generator
8
+ FEATURES = %w[realtime auth storage].freeze
9
+
10
+ AUTH_ROUTES = <<~RUBY
11
+ # Authentication (gemstack add auth) — docs/authentication.md
12
+ post "/auth/signup", to: "registrations#create"
13
+ post "/auth/login", to: "sessions#create"
14
+ get "/auth/me", to: "sessions#show"
15
+ delete "/auth/logout", to: "sessions#destroy"
16
+ post "/auth/password/forgot", to: "password_resets#create"
17
+ post "/auth/password/reset", to: "password_resets#update"
18
+ post "/auth/email/resend", to: "email_verifications#create"
19
+ post "/auth/email/verify", to: "email_verifications#update"
20
+ get "/auth/tokens", to: "api_tokens#index"
21
+ post "/auth/tokens", to: "api_tokens#create"
22
+ delete "/auth/tokens/:id", to: "api_tokens#destroy"
23
+ RUBY
24
+
25
+ MAIL_ENV = <<~ENV
26
+
27
+ # Email. In development messages are saved to tmp/mail instead of being sent.
28
+ # SMTP_URL=smtp://user:password@smtp.example.com:587
29
+ # MAIL_FROM="My App <hello@example.com>"
30
+ # APP_URL=https://example.com # the frontend, for links in emails (default http://localhost:3000)
31
+ ENV
32
+
33
+ def initialize(feature, root:, output: $stdout, install: true)
34
+ super(output: output)
35
+ unless FEATURES.include?(feature)
36
+ raise Thor::Error,
37
+ "Unknown feature #{feature.inspect}. Available: #{FEATURES.join(", ")}"
38
+ end
39
+
40
+ @feature = feature
41
+ @root = root
42
+ @install = install
43
+ end
44
+
45
+ def run
46
+ public_send(:"add_#{@feature}")
47
+ self
48
+ end
49
+
50
+ def add_realtime
51
+ add_gem("gemstack-realtime")
52
+ template_files("realtime", override_root: @root).each do |rel, source|
53
+ next if rel.start_with?("frontend/") && !File.directory?(File.join(@root, "frontend"))
54
+
55
+ write(File.join(@root, rel), File.read(source))
56
+ end
57
+ add_test_support('require "gemstack/realtime/testing"', "GemStack::Realtime::Testing")
58
+ bundle_install
59
+ end
60
+
61
+ STORAGE_ENV = <<~ENV
62
+
63
+ # File storage. Development and tests store files under storage/ (git-ignored).
64
+ # STORAGE_SERVICE=s3 # production default
65
+ # S3_BUCKET=my-app-uploads
66
+ # AWS_REGION=eu-west-3
67
+ # AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... (or an instance role)
68
+ # S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com # S3-compatible services
69
+ ENV
70
+
71
+ def add_storage
72
+ add_gem("gemstack-storage")
73
+ copy_feature_templates("storage")
74
+ add_routes(%(post "/uploads", to: "uploads#create" # direct uploads (gemstack add storage)\n),
75
+ marker: "uploads#create")
76
+ append_env_example(STORAGE_ENV, marker: "STORAGE_SERVICE")
77
+ append_line(".gitignore", "/storage/")
78
+ add_test_support('require "gemstack/storage/testing"', "GemStack::Storage::Testing")
79
+ status("note", "Gemfile", "add gem \"aws-sdk-s3\" for S3 in production (docs/storage.md)")
80
+ bundle_install
81
+ end
82
+
83
+ # For templates: whether `gemstack add auth` ran.
84
+ def auth? = File.read(File.join(@root, "Gemfile")).match?(/^\s*gem "gemstack-auth"/)
85
+
86
+ def add_auth
87
+ require_database!("auth")
88
+ user_model = File.join(@root, "app/models/user.rb")
89
+ if File.file?(user_model) && !File.read(user_model).include?("GemStack::Auth::User")
90
+ raise Thor::Error, "app/models/user.rb already exists. Add `include GemStack::Auth::User` to it and " \
91
+ "see docs/authentication.md (\"Existing users table\") instead."
92
+ end
93
+
94
+ add_gem("gemstack-mail")
95
+ add_gem("gemstack-auth")
96
+ JobGenerator.install_migration(@root, output: @output) # emails are sent from background jobs
97
+ copy_feature_templates("auth", skip_migration: "*_create_auth_tables.rb")
98
+ include_in_application_controller("GemStack::Auth::Controller")
99
+ add_routes(AUTH_ROUTES, marker: "/auth/login")
100
+ append_env_example(MAIL_ENV, marker: "SMTP_URL")
101
+ add_test_support('require "gemstack/mail/testing"', "GemStack::Mail::Testing")
102
+ add_test_support('require "gemstack/auth/testing"', "GemStack::Auth::Testing")
103
+ bundle_install
104
+ end
105
+
106
+ private
107
+
108
+ def frontend? = File.directory?(File.join(@root, "frontend"))
109
+
110
+ def require_database!(feature)
111
+ return if File.read(File.join(@root, "Gemfile")).match?(/^\s*gem "gemstack-db"/)
112
+
113
+ raise Thor::Error, "#{feature} stores its data in PostgreSQL; this app was created without a database " \
114
+ "(add gem \"gemstack-db\" first — docs/database.md)"
115
+ end
116
+
117
+ # Writes a feature's templates (frontend/ files only when the app has
118
+ # one). %timestamp% in a path becomes a fresh migration version, unless a
119
+ # migration matching skip_migration already exists.
120
+ def copy_feature_templates(name, skip_migration: nil)
121
+ has_migration = skip_migration && !Dir.glob(File.join(@root, "db/migrations", skip_migration)).empty?
122
+ template_files(name, override_root: @root).sort.each do |rel, source|
123
+ next if rel.start_with?("frontend/") && !frontend?
124
+
125
+ if rel.include?("%timestamp%")
126
+ next status("identical", rel, "migration already present") if has_migration
127
+
128
+ rel = rel.sub("%timestamp%", Generator.migration_timestamp(@root))
129
+ end
130
+ content = File.read(source)
131
+ content = render(content, source) if rel.end_with?(".tt")
132
+ write(File.join(@root, rel.delete_suffix(".tt")), content)
133
+ end
134
+ end
135
+
136
+ def include_in_application_controller(mixin)
137
+ path = File.join(@root, "app/controllers/application_controller.rb")
138
+ return status("skip", path, "not found — add `include #{mixin}` yourself") unless File.file?(path)
139
+
140
+ content = File.read(path)
141
+ return status("identical", path, mixin) if content.include?(mixin)
142
+
143
+ updated = content.sub(/^class ApplicationController < .*\n/) { |line| "#{line} include #{mixin}\n" }
144
+ return status("skip", path, "add `include #{mixin}` yourself") if updated == content
145
+
146
+ File.write(path, updated)
147
+ status("update", path, mixin)
148
+ end
149
+
150
+ def add_routes(block, marker:)
151
+ path = File.join(@root, "config/routes.rb")
152
+ content = File.file?(path) ? File.read(path) : ""
153
+ return status("identical", path, "routes") if content.include?(marker)
154
+ return status("skip", path, "no `GemStack.routes do` block — add the routes yourself") unless
155
+ content.match?(ControllerGenerator::ROUTES_BLOCK)
156
+
157
+ File.write(path, content.sub(ControllerGenerator::ROUTES_BLOCK) { |open| open + block.gsub(/^/, " ") })
158
+ status("route", path)
159
+ end
160
+
161
+ def append_line(file, line)
162
+ path = File.join(@root, file)
163
+ return unless File.file?(path)
164
+
165
+ content = File.read(path)
166
+ return if content.lines.map(&:strip).include?(line)
167
+
168
+ File.write(path, "#{content.rstrip}\n#{line}\n")
169
+ status("update", path, line)
170
+ end
171
+
172
+ def append_env_example(text, marker:)
173
+ path = File.join(@root, ".env.example")
174
+ return unless File.file?(path)
175
+
176
+ content = File.read(path)
177
+ return if content.include?(marker)
178
+
179
+ File.write(path, "#{content.rstrip}\n#{text}")
180
+ status("update", path)
181
+ end
182
+
183
+ # Adds the gem inside the GemStack `path` block (checkout apps) or as a
184
+ # versioned dependency next to gem "gemstack".
185
+ def add_gem(name)
186
+ path = File.join(@root, "Gemfile")
187
+ content = File.read(path)
188
+ return status("identical", path, name) if content.match?(/^\s*gem "#{name}"/)
189
+
190
+ updated = content.match?(/^path ".*" do\n/) ? add_to_path_block(content, name) : add_versioned(content, name)
191
+ if updated == content
192
+ raise Thor::Error,
193
+ "couldn't find where to add #{name} in the Gemfile; add `gem \"#{name}\"` yourself"
194
+ end
195
+
196
+ File.write(path, updated)
197
+ status("gemfile", path, name)
198
+ end
199
+
200
+ # Keeps the block's gems in alphabetical order (Bundler/OrderedGems).
201
+ def add_to_path_block(content, name)
202
+ content.sub(/^(path ".*" do\n)((?: gem .*\n)*)/) do
203
+ opening = ::Regexp.last_match(1)
204
+ lines = ::Regexp.last_match(2) # before sort_by's regexes reset last_match
205
+ gems = (lines.lines + [" gem \"#{name}\"\n"]).sort_by { |line| line[/"([^"]+)"/, 1] }
206
+ opening + gems.join
207
+ end
208
+ end
209
+
210
+ def add_versioned(content, name)
211
+ content.sub(/^gem "gemstack",.*\n/) { |line| "#{line}gem \"#{name}\", \"~> #{GemStack::VERSION}\"\n" }
212
+ end
213
+
214
+ def add_test_support(require_line, mixin)
215
+ path = File.join(@root, "test/test_helper.rb")
216
+ return unless File.file?(path)
217
+
218
+ content = File.read(path)
219
+ return status("identical", path) if content.include?(mixin)
220
+
221
+ content = content.sub(%(require "gemstack/testing"\n)) { |line| "#{line}#{require_line}\n" }
222
+ content = "#{content.rstrip}\nGemStack::TestCase.include #{mixin}\n"
223
+ File.write(path, content)
224
+ status("update", path, mixin)
225
+ end
226
+
227
+ def bundle_install
228
+ return unless @install
229
+
230
+ status("run", "bundle install")
231
+ install = -> { system("bundle", "install", "--quiet", chdir: @root) }
232
+ ok = defined?(Bundler) ? Bundler.with_unbundled_env(&install) : install.call
233
+ status("warning", "bundle install failed — run it yourself") unless ok
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ # `gemstack new NAME` — a Ruby API + Next.js application containing the
6
+ # framework infrastructure and deliberately no business resources.
7
+ class AppGenerator < Generator
8
+ NAME = /\A[a-z][a-z0-9_-]*\z/
9
+
10
+ # The gems/ directory of the GemStack checkout this CLI runs from, if any.
11
+ CHECKOUT = File.expand_path("../../../..", __dir__)
12
+
13
+ attr_reader :name, :module_name, :destination, :gemstack_path, :api_path
14
+
15
+ def initialize(name, options = {}, output: $stdout)
16
+ super(output: output)
17
+ @name = File.basename(name.to_s)
18
+ @destination = File.expand_path(name.to_s)
19
+ @options = options
20
+ @module_name = Inflector.camelize(@name.tr("-", "_"))
21
+ @gemstack_path = options[:gemstack_path] ? File.expand_path(options[:gemstack_path]) : detect_checkout
22
+ @api_path = "/api"
23
+ end
24
+
25
+ def version = GemStack::VERSION
26
+
27
+ # Pins the app to the Ruby that generated it (.ruby-version, .tool-versions).
28
+ def ruby_version = RUBY_VERSION
29
+ def frontend? = !@options[:skip_frontend]
30
+ def database? = !@options[:skip_database]
31
+
32
+ def run
33
+ validate!
34
+ @output.puts("Creating GemStack application #{name} in #{destination}")
35
+ render_directory("app", destination, skip: ->(rel) { !database? && rel.start_with?("db/") })
36
+ render_directory("frontend", File.join(destination, "frontend")) if frontend?
37
+ install unless @options[:skip_install]
38
+ git_init unless @options[:skip_git]
39
+ summary
40
+ self
41
+ end
42
+
43
+ private
44
+
45
+ def validate!
46
+ unless NAME.match?(name)
47
+ raise Thor::Error,
48
+ "Invalid name #{name.inspect}: use lowercase letters, digits, - and _, starting with a letter"
49
+ end
50
+ return unless File.exist?(destination) && !Dir.empty?(destination)
51
+
52
+ raise Thor::Error, "#{destination} already exists and is not empty"
53
+ end
54
+
55
+ def detect_checkout
56
+ File.file?(File.join(CHECKOUT, "gemstack", "gemstack.gemspec")) ? CHECKOUT : nil
57
+ end
58
+
59
+ def install
60
+ bundled = run_step("bundle install", destination, "bundle", "install", "--quiet")
61
+ create_database if bundled && database?
62
+ return unless frontend?
63
+
64
+ run_step("npm install", File.join(destination, "frontend"), "npm", "install", "--no-fund",
65
+ "--no-audit")
66
+ end
67
+
68
+ # Best effort: a missing or password-protected PostgreSQL shouldn't fail `new`.
69
+ def create_database
70
+ @output.puts(" #{"run".rjust(9)} gemstack db:create")
71
+ ok = unbundled { system("bin/gemstack", "db:create", chdir: destination, out: File::NULL, err: File::NULL) }
72
+ return if ok
73
+
74
+ @output.puts(" #{"warning".rjust(9)} couldn't create the database — " \
75
+ "set DATABASE_URL in .env, then run `gemstack db:create`")
76
+ end
77
+
78
+ def git_init
79
+ return if File.directory?(File.join(destination, ".git")) || !system("git --version", out: File::NULL)
80
+
81
+ run_step("git init", destination, "git", "init", "--quiet")
82
+ end
83
+
84
+ def run_step(label, dir, *command)
85
+ @output.puts(" #{"run".rjust(9)} #{label}")
86
+ ok = unbundled { system(*command, chdir: dir) }
87
+ @output.puts(" #{"warning".rjust(9)} `#{label}` failed — run it manually in #{dir}") unless ok
88
+ ok
89
+ end
90
+
91
+ # Run child commands with the new app's bundle, not the CLI's.
92
+ def unbundled(&)
93
+ defined?(Bundler) ? Bundler.with_unbundled_env(&) : yield
94
+ end
95
+
96
+ def summary
97
+ @output.puts <<~TEXT
98
+
99
+ GemStack application #{name} created.
100
+
101
+ cd #{name}
102
+ gemstack dev # → http://localhost:3000
103
+
104
+ Next: gemstack generate resource Product name:string price:decimal
105
+ Useful commands: gemstack routes · gemstack test · gemstack console · gemstack db:migrate
106
+ TEXT
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ map "db:create" => :db_create, "db:drop" => :db_drop, "db:migrate" => :db_migrate,
6
+ "db:rollback" => :db_rollback, "db:seed" => :db_seed, "db:setup" => :db_setup,
7
+ "db:status" => :db_status, "db:reset" => :db_reset
8
+
9
+ class_option :environment, aliases: "-e", type: :string, desc: "Environment (default: GEMSTACK_ENV or development)"
10
+
11
+ desc "db:create", "Create the database (config.db.url)"
12
+ def db_create
13
+ with_database do
14
+ result = DB::Tasks.create
15
+ say("#{result == :created ? "Created" : "Already exists"}: #{DB::Tasks.database_name}")
16
+ end
17
+ end
18
+
19
+ desc "db:drop", "Drop the database (refused in production unless GEMSTACK_ALLOW_DB_DROP=1)"
20
+ def db_drop
21
+ with_database do
22
+ result = DB::Tasks.drop
23
+ say("#{result == :dropped ? "Dropped" : "Does not exist"}: #{DB::Tasks.database_name}")
24
+ end
25
+ end
26
+
27
+ desc "db:migrate", "Apply pending migrations (--target VERSION to migrate up or down to a version)"
28
+ method_option :target, type: :numeric
29
+ def db_migrate
30
+ with_database do
31
+ changed = DB::Migrator.new.migrate(target: options[:target])
32
+ changed.empty? ? say("No pending migrations.") : changed.sort.each { |file| say(" migrated #{file}") }
33
+ end
34
+ end
35
+
36
+ desc "db:rollback", "Revert the last migration (--steps N for more)"
37
+ method_option :steps, type: :numeric, default: 1
38
+ def db_rollback
39
+ with_database do
40
+ reverted = DB::Migrator.new.rollback(steps: options[:steps])
41
+ if reverted.empty?
42
+ say("Nothing to roll back.")
43
+ else
44
+ reverted.sort.reverse_each do |file|
45
+ say(" reverted #{file}")
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ desc "db:status", "List migrations and whether they are applied"
52
+ def db_status
53
+ with_database do
54
+ rows = DB::Migrator.new.status.map { |m| [m.applied ? "up" : "down", m.version.to_s, m.name] }
55
+ if rows.empty?
56
+ say("No migrations in #{DB.config.migrations_path}.")
57
+ else
58
+ print_table([%w[Status Version Name],
59
+ *rows])
60
+ end
61
+ end
62
+ end
63
+
64
+ desc "db:seed", "Load db/seeds.rb"
65
+ def db_seed
66
+ with_database(boot: true) do
67
+ DB::Tasks.seed ? say("Seeded from #{DB.config.seeds_path}.") : say("No #{DB.config.seeds_path} found.")
68
+ end
69
+ end
70
+
71
+ desc "db:setup", "Create the database, migrate and seed"
72
+ def db_setup
73
+ db_create
74
+ db_migrate
75
+ db_seed
76
+ end
77
+
78
+ desc "db:reset", "Drop, recreate, migrate and seed (development/test only)"
79
+ def db_reset
80
+ db_drop
81
+ db_setup
82
+ end
83
+
84
+ desc "contract", "Generate TypeScript types, API clients and OpenAPI from the backend"
85
+ method_option :quiet, type: :boolean, default: false
86
+ def contract
87
+ root = Project.ensure_bundle!(self.class.argv)
88
+ ENV["GEMSTACK_ENV"] = options[:environment] || ENV["GEMSTACK_ENV"] || "development"
89
+ Project.load_config!(root)
90
+ require "gemstack/contract"
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
+ contract = Contract.build(GemStack.boot!)
95
+ report = Contract.write(contract, root: root, typescript: File.directory?(File.join(root, "frontend")))
96
+ print_contract_report(report, contract, root)
97
+ rescue Sequel::DatabaseConnectionError => e
98
+ abort("gemstack contract: database unreachable (#{e.message.lines.first.strip}). Models need it to load.")
99
+ end
100
+
101
+ no_commands do
102
+ def with_database(boot: false)
103
+ root = Project.ensure_bundle!(self.class.argv)
104
+ ENV["GEMSTACK_ENV"] = options[:environment] || ENV["GEMSTACK_ENV"] || "development"
105
+ Project.load_config!(root)
106
+ unless defined?(GemStack::DB)
107
+ abort("This app doesn't use gemstack-db. Add `gem \"gemstack-db\"` to the Gemfile.")
108
+ end
109
+ GemStack.config.reload_code = false
110
+ GemStack.config.db.log_queries = false
111
+ GemStack.config.logger.level = :warn # these commands print their own summary
112
+ GemStack.boot! if boot
113
+ yield
114
+ rescue Sequel::DatabaseConnectionError => e
115
+ abort("Can't connect to #{redact(DB.config.url)}: #{e.message.lines.first.strip}\n" \
116
+ "Set DATABASE_URL (e.g. in .env) — see docs/models.md.")
117
+ rescue GemStack::Error => e
118
+ abort(e.message)
119
+ end
120
+
121
+ def redact(url) = url.sub(%r{//([^:/@]+):[^@]+@}, '//\1:***@')
122
+
123
+ def print_contract_report(report, contract, root)
124
+ rel = ->(path) { path.delete_prefix("#{root}/") }
125
+ report[:written].each { |path| say(" #{"write".rjust(9)} #{rel.call(path)}") }
126
+ report[:removed].each { |path| say(" #{"remove".rjust(9)} #{rel.call(path)}") }
127
+ contract[:warnings].each { |w| say(" #{"warning".rjust(9)} #{w}", :yellow) } unless options[:quiet]
128
+ return if options[:quiet] || !report[:written].empty? || !report[:removed].empty?
129
+
130
+ say("Contract up to date (#{contract[:resources].size} resources, #{contract[:types].size} types).")
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ map "jobs:status" => :jobs_status, "jobs:retry" => :jobs_retry, "jobs:discard" => :jobs_discard,
6
+ "jobs:install" => :jobs_install, "jobs:failed" => :jobs_failed
7
+
8
+ desc "jobs", "Run a background job worker (PostgreSQL queue)"
9
+ method_option :queues, aliases: "-q", type: :string, desc: "Comma-separated queues (default: all)"
10
+ method_option :concurrency, aliases: "-c", type: :numeric,
11
+ desc: "Worker threads (default: GEMSTACK_JOB_CONCURRENCY or 5)"
12
+ def jobs
13
+ with_jobs do |config|
14
+ config.jobs.queues = options[:queues].split(",").map(&:strip) if options[:queues]
15
+ config.jobs.concurrency = options[:concurrency] if options[:concurrency]
16
+ # Each worker thread holds a connection, plus LISTEN and the reaper.
17
+ config.db.pool_size = [config.db.pool_size, config.jobs.concurrency + 2].max
18
+ GemStack.boot!
19
+ GemStack.application.eager_load!
20
+ require "gemstack/jobs/worker"
21
+ Jobs::Worker.new.run
22
+ end
23
+ end
24
+
25
+ desc "jobs:status", "Show queued, scheduled, running and failed jobs per queue"
26
+ def jobs_status
27
+ with_jobs(quiet: true) do
28
+ stats = Jobs::Adapters::Postgres.new.stats
29
+ return say("No jobs.") if stats.empty?
30
+
31
+ rows = stats.sort.map { |queue, s| [queue, s[:ready], s[:scheduled], s[:running], s[:failed]].map(&:to_s) }
32
+ print_table([%w[Queue Ready Scheduled Running Failed], *rows])
33
+ end
34
+ end
35
+
36
+ desc "jobs:failed", "List recently failed jobs"
37
+ method_option :limit, type: :numeric, default: 20
38
+ def jobs_failed
39
+ with_jobs(quiet: true) do
40
+ failed = Jobs::Adapters::Postgres.new.failed(limit: options[:limit])
41
+ return say("No failed jobs.") if failed.empty?
42
+
43
+ failed.each do |job|
44
+ say("##{job[:id]} #{job[:job_class]} (#{job[:queue]}) " \
45
+ "attempts=#{job[:attempts]} failed_at=#{job[:failed_at]}")
46
+ say(" #{job[:last_error].to_s.lines.first&.strip}")
47
+ end
48
+ end
49
+ end
50
+
51
+ desc "jobs:retry [IDS...]", "Put failed jobs back on the queue (all failed jobs without IDS)"
52
+ def jobs_retry(*ids)
53
+ with_jobs(quiet: true) do
54
+ count = Jobs::Adapters::Postgres.new.retry_failed(ids.empty? ? nil : ids.map(&:to_i))
55
+ say("Re-queued #{count} job(s).")
56
+ end
57
+ end
58
+
59
+ desc "jobs:discard [IDS...]", "Delete failed jobs (all failed jobs without IDS)"
60
+ def jobs_discard(*ids)
61
+ with_jobs(quiet: true) do
62
+ count = Jobs::Adapters::Postgres.new.discard_failed(ids.empty? ? nil : ids.map(&:to_i))
63
+ say("Deleted #{count} failed job(s).")
64
+ end
65
+ end
66
+
67
+ desc "jobs:install", "Add the gemstack_jobs table migration (done automatically by `generate job`)"
68
+ def jobs_install
69
+ JobGenerator.install_migration(Project.root!, output: $stdout)
70
+ end
71
+
72
+ no_commands do
73
+ def with_jobs(quiet: false)
74
+ root = Project.ensure_bundle!(self.class.argv)
75
+ ENV["GEMSTACK_ENV"] = options[:environment] || ENV["GEMSTACK_ENV"] || "development"
76
+ Project.load_config!(root)
77
+ unless defined?(GemStack::Jobs)
78
+ abort('This app doesn\'t use gemstack-jobs. Add `gem "gemstack-jobs"` to the Gemfile.')
79
+ end
80
+ config = GemStack.config
81
+ config.reload_code = false
82
+ if quiet
83
+ config.logger.level = :warn
84
+ config.db.log_queries = false if config.respond_to?(:db)
85
+ end
86
+ yield config
87
+ rescue Sequel::DatabaseError => e
88
+ raise unless e.message.include?("gemstack_jobs")
89
+
90
+ abort("The gemstack_jobs table doesn't exist yet. Run `gemstack jobs:install && gemstack db:migrate`.")
91
+ rescue Sequel::DatabaseConnectionError => e
92
+ abort("Can't connect to the database: #{e.message.lines.first.strip}")
93
+ end
94
+ end
95
+ end
96
+ end