gemstack 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 (199) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +7 -3
  4. data/lib/gemstack/cache/memory_store.rb +69 -0
  5. data/lib/gemstack/cache/null_store.rb +18 -0
  6. data/lib/gemstack/cache/redis_store.rb +70 -0
  7. data/lib/gemstack/cache/store.rb +89 -0
  8. data/lib/gemstack/cache.rb +61 -0
  9. data/lib/gemstack/cli/add_generator.rb +258 -0
  10. data/lib/gemstack/cli/app_generator.rb +142 -0
  11. data/lib/gemstack/cli/commands/db.rb +136 -0
  12. data/lib/gemstack/cli/commands/jobs.rb +96 -0
  13. data/lib/gemstack/cli/controller_generator.rb +73 -0
  14. data/lib/gemstack/cli/deploy_generator.rb +102 -0
  15. data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
  16. data/lib/gemstack/cli/doctor.rb +311 -0
  17. data/lib/gemstack/cli/generator.rb +161 -0
  18. data/lib/gemstack/cli/job_generator.rb +48 -0
  19. data/lib/gemstack/cli/migration_generator.rb +52 -0
  20. data/lib/gemstack/cli/policy_generator.rb +35 -0
  21. data/lib/gemstack/cli/project.rb +53 -0
  22. data/lib/gemstack/cli/resource_generator.rb +170 -0
  23. data/lib/gemstack/cli/resource_spec.rb +95 -0
  24. data/lib/gemstack/cli.rb +304 -0
  25. data/lib/gemstack/contract/builder.rb +168 -0
  26. data/lib/gemstack/contract/docs/index.html +264 -0
  27. data/lib/gemstack/contract/docs.rb +45 -0
  28. data/lib/gemstack/contract/openapi.rb +123 -0
  29. data/lib/gemstack/contract/typescript.rb +162 -0
  30. data/lib/gemstack/contract.rb +93 -0
  31. data/lib/gemstack/core.rb +129 -0
  32. data/lib/gemstack/db/configuration.rb +148 -0
  33. data/lib/gemstack/db/errors.rb +137 -0
  34. data/lib/gemstack/db/json_compat.rb +21 -0
  35. data/lib/gemstack/db/migrator.rb +80 -0
  36. data/lib/gemstack/db/model.rb +226 -0
  37. data/lib/gemstack/db/schema_types.rb +28 -0
  38. data/lib/gemstack/db/tasks.rb +94 -0
  39. data/lib/gemstack/db/testing.rb +99 -0
  40. data/lib/gemstack/db.rb +210 -0
  41. data/lib/gemstack/dev/file_watcher.rb +58 -0
  42. data/lib/gemstack/dev/gateway.rb +220 -0
  43. data/lib/gemstack/dev/managed_process.rb +96 -0
  44. data/lib/gemstack/dev/ports.rb +26 -0
  45. data/lib/gemstack/dev/supervisor.rb +267 -0
  46. data/lib/gemstack/dev/terminal.rb +40 -0
  47. data/lib/gemstack/dev/toolchain.rb +86 -0
  48. data/lib/gemstack/dev.rb +43 -0
  49. data/lib/gemstack/dotenv.rb +61 -0
  50. data/lib/gemstack/environment.rb +37 -0
  51. data/lib/gemstack/error_mapping.rb +37 -0
  52. data/lib/gemstack/errors.rb +100 -0
  53. data/lib/gemstack/http/app.rb +30 -0
  54. data/lib/gemstack/http/config.rb +84 -0
  55. data/lib/gemstack/http/controller.rb +343 -0
  56. data/lib/gemstack/http/error_page.rb +111 -0
  57. data/lib/gemstack/http/error_renderer.rb +63 -0
  58. data/lib/gemstack/http/json_codec.rb +114 -0
  59. data/lib/gemstack/http/middleware/body_limit.rb +69 -0
  60. data/lib/gemstack/http/middleware/compression.rb +127 -0
  61. data/lib/gemstack/http/middleware/cors.rb +77 -0
  62. data/lib/gemstack/http/middleware/error_handler.rb +39 -0
  63. data/lib/gemstack/http/middleware/etags.rb +24 -0
  64. data/lib/gemstack/http/middleware/health_check.rb +28 -0
  65. data/lib/gemstack/http/middleware/request_id.rb +31 -0
  66. data/lib/gemstack/http/middleware/request_logger.rb +39 -0
  67. data/lib/gemstack/http/middleware/security_headers.rb +31 -0
  68. data/lib/gemstack/http/middleware_stack.rb +96 -0
  69. data/lib/gemstack/http/page.rb +36 -0
  70. data/lib/gemstack/http/params.rb +140 -0
  71. data/lib/gemstack/http/request.rb +64 -0
  72. data/lib/gemstack/http/router.rb +315 -0
  73. data/lib/gemstack/http.rb +41 -0
  74. data/lib/gemstack/inflector.rb +133 -0
  75. data/lib/gemstack/job.rb +154 -0
  76. data/lib/gemstack/jobs/adapters/async.rb +94 -0
  77. data/lib/gemstack/jobs/adapters/database.rb +188 -0
  78. data/lib/gemstack/jobs/adapters/inline.rb +34 -0
  79. data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
  80. data/lib/gemstack/jobs/adapters/test.rb +59 -0
  81. data/lib/gemstack/jobs/executor.rb +70 -0
  82. data/lib/gemstack/jobs/testing.rb +55 -0
  83. data/lib/gemstack/jobs/worker.rb +137 -0
  84. data/lib/gemstack/jobs.rb +144 -0
  85. data/lib/gemstack/logger.rb +131 -0
  86. data/lib/gemstack/mail/delivery_job.rb +20 -0
  87. data/lib/gemstack/mail/testing.rb +33 -0
  88. data/lib/gemstack/mail.rb +230 -0
  89. data/lib/gemstack/plugins.rb +38 -0
  90. data/lib/gemstack/schema.rb +251 -0
  91. data/lib/gemstack/serializer.rb +186 -0
  92. data/lib/gemstack/settings.rb +86 -0
  93. data/lib/gemstack/storage/endpoint.rb +112 -0
  94. data/lib/gemstack/storage/services/disk.rb +60 -0
  95. data/lib/gemstack/storage/services/s3.rb +65 -0
  96. data/lib/gemstack/storage/testing.rb +30 -0
  97. data/lib/gemstack/storage.rb +183 -0
  98. data/lib/gemstack/types.rb +163 -0
  99. data/lib/gemstack/version.rb +6 -0
  100. data/lib/gemstack.rb +1 -1
  101. data/templates/app/Gemfile.tt +27 -0
  102. data/templates/app/README.md.tt +29 -0
  103. data/templates/app/app/controllers/application_controller.rb +6 -0
  104. data/templates/app/app/jobs/application_job.rb +9 -0
  105. data/templates/app/app/mailers/application_mailer.rb +8 -0
  106. data/templates/app/app/mailers/templates/dot_keep +0 -0
  107. data/templates/app/app/models/application_model.rb +19 -0
  108. data/templates/app/app/serializers/application_serializer.rb +6 -0
  109. data/templates/app/bin/gemstack +7 -0
  110. data/templates/app/config/app.rb.tt +60 -0
  111. data/templates/app/config/database.yml.tt +61 -0
  112. data/templates/app/config/environments/development.rb.tt +35 -0
  113. data/templates/app/config/environments/production.rb.tt +40 -0
  114. data/templates/app/config/environments/test.rb.tt +21 -0
  115. data/templates/app/config/puma.rb +21 -0
  116. data/templates/app/config/routes.rb +10 -0
  117. data/templates/app/config.ru +6 -0
  118. data/templates/app/db/migrations/dot_keep +0 -0
  119. data/templates/app/db/seeds.rb +5 -0
  120. data/templates/app/dot_env.example.tt +19 -0
  121. data/templates/app/dot_gitignore +16 -0
  122. data/templates/app/dot_node-version.tt +1 -0
  123. data/templates/app/dot_nvmrc.tt +1 -0
  124. data/templates/app/dot_ruby-version.tt +1 -0
  125. data/templates/app/dot_tool-versions.tt +4 -0
  126. data/templates/app/test/health_test.rb +18 -0
  127. data/templates/app/test/test_helper.rb.tt +25 -0
  128. data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
  129. data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
  130. data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
  131. data/templates/auth/app/controllers/registrations_controller.rb +19 -0
  132. data/templates/auth/app/controllers/sessions_controller.rb +29 -0
  133. data/templates/auth/app/mailers/auth_mailer.rb +20 -0
  134. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
  135. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
  136. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
  137. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
  138. data/templates/auth/app/models/auth_token.rb +15 -0
  139. data/templates/auth/app/models/user.rb +12 -0
  140. data/templates/auth/app/policies/application_policy.rb +6 -0
  141. data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
  142. data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
  143. data/templates/auth/app/serializers/user_serializer.rb +6 -0
  144. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
  145. data/templates/auth/frontend/app/account/page.tsx +116 -0
  146. data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
  147. data/templates/auth/frontend/app/login/page.tsx +26 -0
  148. data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
  149. data/templates/auth/frontend/app/signup/page.tsx +27 -0
  150. data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
  151. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
  152. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
  153. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
  154. data/templates/auth/frontend/lib/auth.ts +113 -0
  155. data/templates/auth/test/controllers/auth_test.rb +108 -0
  156. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
  157. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
  158. data/templates/deploy/Caddyfile.tt +19 -0
  159. data/templates/deploy/Dockerfile.tt +63 -0
  160. data/templates/deploy/Procfile.tt +6 -0
  161. data/templates/deploy/compose.yaml.tt +129 -0
  162. data/templates/deploy/dot_dockerignore +14 -0
  163. data/templates/frontend/app/globals.css +171 -0
  164. data/templates/frontend/app/layout.tsx.tt +19 -0
  165. data/templates/frontend/app/page.module.css +315 -0
  166. data/templates/frontend/app/page.tsx.tt +183 -0
  167. data/templates/frontend/app/providers.tsx +24 -0
  168. data/templates/frontend/lib/gemstack/client.ts +125 -0
  169. data/templates/frontend/next-env.d.ts +5 -0
  170. data/templates/frontend/next.config.ts +17 -0
  171. data/templates/frontend/package.json.tt +23 -0
  172. data/templates/frontend/tsconfig.json +21 -0
  173. data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
  174. data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
  175. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
  176. data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
  177. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
  178. data/templates/realtime/config/channels.rb +14 -0
  179. data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
  180. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
  181. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
  182. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
  183. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
  184. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
  185. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
  186. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
  187. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
  188. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
  189. data/templates/resource/frontend/frontend/lib/format.ts +12 -0
  190. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
  191. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
  192. data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
  193. data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
  194. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
  195. data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
  196. data/templates/storage/app/serializers/upload_serializer.rb +11 -0
  197. data/templates/storage/frontend/lib/upload.ts +52 -0
  198. data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
  199. metadata +251 -39
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "socket"
5
+ require "gemstack/dev"
6
+ require_relative "doctor/upgrade_check"
7
+
8
+ module GemStack
9
+ class CLI < Thor
10
+ # `gemstack doctor` — checks that an application can run, and says how to
11
+ # fix what can't. Every check is independent: one
12
+ # failure never hides the others. `--production` checks the settings a
13
+ # deploy needs.
14
+ class Doctor
15
+ include UpgradeCheck
16
+
17
+ Result = Struct.new(:status, :title, :hint)
18
+
19
+ attr_reader :results
20
+
21
+ def initialize(root:, output: $stdout, production: false, color: output.tty?, run: nil)
22
+ @root = root
23
+ @output = output
24
+ @production = production
25
+ @color = color
26
+ @run = run || ->(*command) { Open3.capture2e(*command) }
27
+ @results = []
28
+ end
29
+
30
+ def run
31
+ header
32
+ check_ruby
33
+ check_node
34
+ check_frontend_dependencies
35
+ check_git_secrets
36
+ check_gemstack_upgrade
37
+ booted = check_boot
38
+ if booted
39
+ database_ok = check_database
40
+ check_migrations_and_jobs if database_ok
41
+ check_cache
42
+ check_realtime_broker
43
+ check_contract if database_ok
44
+ end
45
+ check_production_settings if @production
46
+ check_port unless @production
47
+ summary
48
+ self
49
+ end
50
+
51
+ def ok? = results.none? { |result| result.status == :fail }
52
+
53
+ # ── checks ─────────────────────────────────────────────────────────
54
+
55
+ def check_ruby
56
+ wanted = read(".ruby-version")&.strip&.delete_prefix("ruby-")
57
+ if !Dev::Toolchain.ruby_ok?
58
+ problem("Ruby #{RUBY_VERSION}", "GemStack needs Ruby #{Dev::Toolchain::MIN_RUBY} or newer — " \
59
+ "#{Dev::Toolchain.ruby_hint(wanted.to_s.empty? ? "3.4" : wanted)}")
60
+ elsif wanted && !wanted.empty? && !RUBY_VERSION.start_with?(wanted)
61
+ caution("Ruby #{RUBY_VERSION}", "the app pins #{wanted} (.ruby-version) — #{Dev::Toolchain.ruby_hint(wanted)}")
62
+ else
63
+ pass("Ruby #{RUBY_VERSION}#{" (YJIT available)" if defined?(RubyVM::YJIT)}")
64
+ end
65
+ end
66
+
67
+ def check_node
68
+ return unless File.directory?(path("frontend"))
69
+
70
+ node = Dev::Toolchain.node(@run)
71
+ wanted = read(".node-version")&.strip || read(".nvmrc")&.strip || Dev::Toolchain::LTS_NODE
72
+ if node.nil?
73
+ problem("Node.js not found", Dev::Toolchain.node_hint(wanted, nil))
74
+ elsif !Dev::Toolchain.node_ok?(node[:version])
75
+ problem("Node.js #{node[:version]} (#{node[:path]})",
76
+ "Next.js 16 needs #{Dev::Toolchain::MIN_NODE.join(".")}+ — " \
77
+ "#{Dev::Toolchain.node_hint(wanted, Dev::Toolchain.node_manager(node[:path]))}")
78
+ else
79
+ pass("Node.js #{node[:version]}")
80
+ end
81
+ end
82
+
83
+ def check_frontend_dependencies
84
+ return unless File.directory?(path("frontend"))
85
+
86
+ if File.file?(path("frontend/node_modules/.package-lock.json"))
87
+ lock = path("frontend/package-lock.json")
88
+ stale = File.file?(lock) && File.mtime(lock) > File.mtime(path("frontend/node_modules/.package-lock.json"))
89
+ if stale
90
+ caution("frontend dependencies",
91
+ "package-lock.json changed — run: cd frontend && npm install")
92
+ else
93
+ pass("frontend dependencies installed")
94
+ end
95
+ else
96
+ problem("frontend dependencies missing", "run: cd frontend && npm install")
97
+ end
98
+ end
99
+
100
+ # Secrets committed to git stay in its history (and in every clone).
101
+ def check_git_secrets
102
+ return unless File.directory?(path(".git"))
103
+
104
+ out, status = @run.call("git", "-C", @root, "ls-files", "--", ".env", ".env.*", "*.pem", "*.key",
105
+ "tmp/*_secret")
106
+ return unless status.success?
107
+
108
+ tracked = out.lines.map(&:strip).reject { |file| file.empty? || file.end_with?(".example") }
109
+ if tracked.empty?
110
+ pass("no secret files tracked by git")
111
+ else
112
+ problem("secret files tracked by git: #{tracked.join(", ")}",
113
+ "remove them from git (git rm --cached …), rotate every secret they contain, and ask whoever " \
114
+ "manages your infrastructure to check where the repository was shared")
115
+ end
116
+ end
117
+
118
+ def check_boot
119
+ ENV["GEMSTACK_ENV"] = "production" if @production
120
+ ENV["GEMSTACK_ENV"] ||= "development"
121
+ Project.load_config!(@root)
122
+ config = GemStack.config
123
+ config.reload_code = false
124
+ config.eager_load = false
125
+ config.logger.output = nil
126
+ config.db.log_queries = false if config.respond_to?(:db)
127
+ GemStack.boot!
128
+ pass("application boots (#{GemStack.env}, #{GemStack.application.routes.size} routes)")
129
+ true
130
+ rescue StandardError, ScriptError => e
131
+ problem("application fails to boot: #{e.class}: #{e.message.lines.first&.strip}", location(e))
132
+ false
133
+ end
134
+
135
+ def check_database
136
+ return true unless defined?(GemStack::DB)
137
+
138
+ settings = GemStack::DB.settings
139
+ where = "#{GemStack::DB::Configuration.describe(settings)}, from #{settings[:source]}"
140
+ if settings[:adapter] == "sqlite" && !GemStack::DB::Tasks.exists?(settings)
141
+ problem("SQLite database file missing (#{where})", "run: gemstack db:create")
142
+ return false
143
+ end
144
+ GemStack::DB.connection.test_connection
145
+ pass("database reachable (#{where})")
146
+ true
147
+ rescue StandardError, LoadError => e
148
+ message = e.message.lines.first.to_s.strip
149
+ message = message.split(/FATAL:\s*/, 2).last if message.include?("FATAL:")
150
+ hint = if message.match?(/does not exist|Unknown database/) then "run: gemstack db:create"
151
+ elsif e.is_a?(GemStack::ConfigurationError) then "fix config/database.yml or DATABASE_URL"
152
+ else "is the database server running? Check config/database.yml or DATABASE_URL (#{where})"
153
+ end
154
+ problem("database: #{message}", hint)
155
+ false
156
+ end
157
+
158
+ # MySQL/SQLite apps: a jobs worker is a separate process, and the memory
159
+ # broker can't carry its broadcasts to the web server.
160
+ def check_realtime_broker
161
+ return unless defined?(GemStack::Realtime) && defined?(GemStack::Jobs) && GemStack::Jobs.database_queue?
162
+ return unless GemStack.config.realtime.broker.to_s == "memory" && jobs_used?
163
+
164
+ caution("realtime uses the in-process memory broker",
165
+ "broadcasts from jobs won't reach the web server — set config.realtime.broker = :redis (REDIS_URL)")
166
+ end
167
+
168
+ def check_migrations_and_jobs
169
+ pending = GemStack::DB::Migrator.new.pending
170
+ if pending.empty?
171
+ pass("migrations up to date")
172
+ else
173
+ caution("#{pending.size} pending migration(s)", "run: gemstack db:migrate")
174
+ end
175
+ return unless defined?(GemStack::Jobs) && GemStack::Jobs.database_queue?
176
+ return unless jobs_used?
177
+
178
+ if GemStack::DB.connection.table_exists?(:gemstack_jobs)
179
+ pass("jobs table present")
180
+ elsif pending.empty?
181
+ problem("background jobs are used but the gemstack_jobs table is missing",
182
+ "run: gemstack jobs:install && gemstack db:migrate")
183
+ end
184
+ end
185
+
186
+ def check_cache
187
+ return unless GemStack.config.respond_to?(:cache) && GemStack.config.cache.store.to_s == "redis"
188
+
189
+ GemStack.cache.write("gemstack:doctor", 1, expires_in: 5)
190
+ pass("Redis cache reachable")
191
+ rescue StandardError => e
192
+ problem("Redis cache: #{e.message.lines.first&.strip}", "is Redis running? Check REDIS_URL")
193
+ end
194
+
195
+ def check_contract
196
+ return unless File.directory?(path("frontend"))
197
+
198
+ contract = Contract.build(GemStack.application)
199
+ report = Contract.write(contract, root: @root, dry_run: true)
200
+ changed = report[:written] + report[:removed]
201
+ if changed.empty?
202
+ pass("TypeScript contract up to date")
203
+ else
204
+ caution("TypeScript contract out of date (#{changed.size} file(s))",
205
+ "run: gemstack contract (gemstack dev does it on save)")
206
+ end
207
+ contract[:warnings].first(3).each { |message| caution("contract: #{message}", nil) }
208
+ rescue StandardError => e
209
+ caution("contract could not be built: #{e.message.lines.first&.strip}", "run: gemstack contract")
210
+ end
211
+
212
+ def check_production_settings
213
+ secret = GemStack.config.secret_key_base.to_s
214
+ if secret.empty? then problem("SECRET_KEY_BASE is not set", "generate one: openssl rand -hex 64")
215
+ elsif secret.length < 64 then problem("SECRET_KEY_BASE is too short", "use: openssl rand -hex 64")
216
+ else pass("SECRET_KEY_BASE is set")
217
+ end
218
+ check_production_database if defined?(GemStack::DB)
219
+ if defined?(GemStack::Mail) && GemStack.config.mail.delivery.to_s == "smtp"
220
+ env_check("SMTP_URL", "e.g. smtp://user:password@smtp.example.com:587")
221
+ end
222
+ env_check("APP_URL", "the frontend's public URL, used in email links") if defined?(GemStack::Auth)
223
+ check_storage_settings if defined?(GemStack::Storage)
224
+ return unless GemStack.config.respond_to?(:cache) && GemStack.config.cache.store.to_s == "redis"
225
+
226
+ env_check("REDIS_URL", "the Redis used by the cache")
227
+ end
228
+
229
+ def check_production_database
230
+ settings = GemStack::DB.settings
231
+ if settings[:source].start_with?("default")
232
+ problem("no production database configured",
233
+ "set DATABASE_URL, or a production section in config/database.yml")
234
+ elsif settings[:adapter] == "sqlite"
235
+ caution("SQLite in production (#{settings[:database]})",
236
+ "keep the file on a persistent volume and run a single server; back it up")
237
+ else
238
+ pass("production database: #{GemStack::DB::Configuration.describe(settings)} (#{settings[:source]})")
239
+ end
240
+ end
241
+
242
+ def check_storage_settings
243
+ return unless GemStack.config.storage.service.to_s == "s3"
244
+
245
+ env_check("S3_BUCKET", "the bucket uploads go to")
246
+ begin
247
+ require "aws-sdk-s3"
248
+ pass("aws-sdk-s3 available")
249
+ rescue LoadError
250
+ problem("storage uses S3 but aws-sdk-s3 isn't installed", "add gem \"aws-sdk-s3\" to the Gemfile")
251
+ end
252
+ end
253
+
254
+ def check_port
255
+ port = Integer(ENV.fetch("PORT", 3000))
256
+ TCPServer.new("127.0.0.1", port).close
257
+ pass("port #{port} is free for gemstack dev")
258
+ rescue Errno::EADDRINUSE
259
+ caution("port #{port} is in use", "stop the other process, or run: PORT=#{port + 1} gemstack dev")
260
+ rescue ArgumentError, SystemCallError
261
+ nil
262
+ end
263
+
264
+ private
265
+
266
+ def header
267
+ @output.puts("GemStack doctor · #{File.basename(@root)}#{" · production settings" if @production}\n\n")
268
+ end
269
+
270
+ def summary
271
+ failures = results.count { |r| r.status == :fail }
272
+ warnings = results.count { |r| r.status == :warn }
273
+ line = if failures.zero? && warnings.zero? then paint("Everything looks good.", 32)
274
+ else
275
+ [(paint("#{failures} problem(s)", 31) if failures.positive?),
276
+ (paint("#{warnings} warning(s)", 33) if warnings.positive?)].compact.join(", ")
277
+ end
278
+ @output.puts("\n#{line}")
279
+ end
280
+
281
+ def record(status, title, hint)
282
+ @results << Result.new(status, title, hint)
283
+ symbol = { ok: paint("✓", 32), warn: paint("!", 33), fail: paint("✗", 31) }.fetch(status)
284
+ @output.puts(" #{symbol} #{title}")
285
+ @output.puts(" → #{hint}") if hint
286
+ end
287
+
288
+ def pass(title) = record(:ok, title, nil)
289
+ def caution(title, hint) = record(:warn, title, hint)
290
+ def problem(title, hint) = record(:fail, title, hint)
291
+
292
+ def env_check(name, hint)
293
+ ENV.fetch(name, "").strip.empty? ? problem("#{name} is not set", hint) : pass("#{name} is set")
294
+ end
295
+
296
+ def jobs_used?
297
+ Generator.background_work?(@root)
298
+ end
299
+
300
+ def location(error)
301
+ frame = Array(error.backtrace_locations || error.backtrace).map(&:to_s).find { |line| line.start_with?(@root) }
302
+ frame ? "at #{frame.delete_prefix("#{@root}/")}" : "run `gemstack server` to see the full error"
303
+ end
304
+
305
+ def redact(url) = url.to_s.sub(%r{//([^:/@]+):[^@]+@}, '//\1:***@')
306
+ def path(relative) = File.join(@root, relative)
307
+ def read(relative) = File.file?(path(relative)) ? File.read(path(relative)) : nil
308
+ def paint(text, code) = @color ? "\e[#{code}m#{text}\e[0m" : text
309
+ end
310
+ end
311
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+
6
+ module GemStack
7
+ class CLI < Thor
8
+ # Shared machinery for generators: renders a template directory into a
9
+ # destination. Files ending in .tt are ERB templates evaluated against the
10
+ # generator; a leading "dot_" in a file name becomes "." (so dotfiles
11
+ # survive gem packaging). Existing files are never overwritten silently.
12
+ #
13
+ # Applications can override any template by placing a file with the same
14
+ # relative path in lib/templates/gemstack/<generator>/ (ARCHITECTURE §7).
15
+ class Generator
16
+ TEMPLATES = File.expand_path("../../../templates", __dir__)
17
+
18
+ attr_reader :created
19
+
20
+ def initialize(output: $stdout, force: false)
21
+ @output = output
22
+ @force = force
23
+ @created = []
24
+ end
25
+
26
+ def template_root(name) = File.join(TEMPLATES, name)
27
+
28
+ # Ruby core/stdlib names an app class must not reuse (a top-level class
29
+ # named Digest or Set would collide with Ruby's own). Constants already
30
+ # defined in this process (stdlib and gems loaded by the CLI) are
31
+ # checked too; the app's own code isn't loaded here, so those are always
32
+ # foreign.
33
+ RESERVED = %w[
34
+ Array BasicObject Benchmark Binding Class Comparable Data Date DateTime Digest Dir Encoding Enumerable
35
+ Enumerator Errno Exception FalseClass Fiber File FileTest FileUtils Float GC Hash IO Integer JSON Kernel
36
+ Logger Marshal MatchData Math Method Module Monitor Mutex NilClass Numeric Object ObjectSpace Observable
37
+ OpenStruct Pathname Proc Process Queue Random Range Rational Regexp Ripper Set Signal Socket String
38
+ StringIO Struct Symbol Thread Time Timeout TracePoint TrueClass URI Warning Zlib Rack Sequel Thor Puma
39
+ GemStack
40
+ ].freeze
41
+
42
+ def self.check_constant!(name, suggestion: nil)
43
+ top = name.to_s.split("::").first
44
+ return unless RESERVED.include?(top) || Object.const_defined?(top, false)
45
+
46
+ hint = suggestion ? " — try #{suggestion}" : ""
47
+ raise Thor::Error, "#{name} would clash with Ruby's (or a loaded gem's) #{top} constant#{hint}"
48
+ end
49
+
50
+ # A migration version not used yet in db/migrations: generating several
51
+ # migrations within one second must not give them the same version.
52
+ def self.migration_timestamp(root, time = Time.now.utc)
53
+ loop do
54
+ stamp = time.strftime("%Y%m%d%H%M%S")
55
+ return stamp if Dir.glob(File.join(root.to_s, "db/migrations/#{stamp}_*.rb")).empty?
56
+
57
+ time += 1
58
+ end
59
+ end
60
+
61
+ # Every template file for a generator, with app overrides applied.
62
+ # The app's base classes (written by `gemstack new` since 0.2.1). Apps
63
+ # created earlier get the ones a generator's code inherits from.
64
+ BASE_CLASSES = {
65
+ model: "app/models/application_model.rb", serializer: "app/serializers/application_serializer.rb",
66
+ job: "app/jobs/application_job.rb", mailer: "app/mailers/application_mailer.rb"
67
+ }.freeze
68
+
69
+ # Which GemStack modules an app uses. Since 0.3.0 the core modules are
70
+ # part of the gemstack gem and switched on with `require "gemstack/db"`
71
+ # (etc.) in config/app.rb; apps from before list gemstack-db in the
72
+ # Gemfile. Auth and realtime are gems of their own either way.
73
+ MODULE_GEMS = %w[auth realtime].freeze
74
+
75
+ def self.uses?(root, name)
76
+ name = name.to_s
77
+ gemfile = read_file(root, "Gemfile")
78
+ return true if gemfile.match?(/^\s*gem "gemstack-#{name}"/)
79
+ return false if MODULE_GEMS.include?(name)
80
+
81
+ read_file(root, "config/app.rb").match?(%r{^\s*require "gemstack/#{name}"})
82
+ end
83
+
84
+ def self.read_file(root, rel) = File.file?(File.join(root, rel)) ? File.read(File.join(root, rel)) : ""
85
+
86
+ # Whether the app has jobs or mailers of its own (base classes don't count).
87
+ def self.background_work?(root)
88
+ %w[app/jobs/*.rb app/mailers/*.rb].any? do |glob|
89
+ Dir.glob(File.join(root, glob)).any? { |file| !File.basename(file).start_with?("application_") }
90
+ end
91
+ end
92
+
93
+ def ensure_base_classes(root, *kinds)
94
+ kinds.each do |kind|
95
+ rel = BASE_CLASSES.fetch(kind)
96
+ target = File.join(root, rel)
97
+ write(target, File.read(File.join(template_root("app"), rel))) unless File.exist?(target)
98
+ end
99
+ end
100
+
101
+ def template_files(name, override_root: nil)
102
+ files = relative_files(template_root(name)).to_h { |rel| [rel, File.join(template_root(name), rel)] }
103
+ if override_root
104
+ custom = File.join(override_root, "lib/templates/gemstack", name)
105
+ relative_files(custom).each { |rel| files[rel] = File.join(custom, rel) } if File.directory?(custom)
106
+ end
107
+ files
108
+ end
109
+
110
+ def render_directory(name, destination, override_root: nil, skip: nil)
111
+ template_files(name, override_root: override_root).sort.each do |rel, source|
112
+ next if skip&.call(rel)
113
+
114
+ target = File.join(destination, output_path(rel))
115
+ content = File.binread(source)
116
+ content = render(content, source) if source.end_with?(".tt")
117
+ write(target, content, mode: File.stat(source).mode)
118
+ end
119
+ end
120
+
121
+ def render(content, source = "(template)")
122
+ erb = ERB.new(content, trim_mode: "-")
123
+ erb.filename = source
124
+ erb.result(binding)
125
+ end
126
+
127
+ def write(path, content, mode: nil)
128
+ if File.exist?(path) && !@force
129
+ if File.binread(path) == content
130
+ status("identical", path)
131
+ else
132
+ status("skip", path, "exists — not overwritten")
133
+ end
134
+ return false
135
+ end
136
+ FileUtils.mkdir_p(File.dirname(path))
137
+ File.binwrite(path, content)
138
+ File.chmod(mode & 0o777, path) if mode
139
+ @created << path
140
+ status("create", path)
141
+ true
142
+ end
143
+
144
+ def status(label, path, note = nil)
145
+ shown = path.delete_prefix("#{Dir.pwd}/")
146
+ @output.puts(" #{label.rjust(9)} #{shown}#{" (#{note})" if note}")
147
+ end
148
+
149
+ private
150
+
151
+ def relative_files(root)
152
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: root)
153
+ .reject { |rel| File.directory?(File.join(root, rel)) || File.basename(rel) == ".DS_Store" }
154
+ end
155
+
156
+ def output_path(rel)
157
+ rel.delete_suffix(".tt").split("/").map { |part| part.sub(/\Adot_/, ".") }.join("/")
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ # `gemstack generate job SendWelcomeEmail [queue]`
6
+ #
7
+ # Writes app/jobs/<name>.rb and a test, and — the first time — the
8
+ # migration for the gemstack_jobs table, so apps get the table only once
9
+ # they actually use background jobs.
10
+ class JobGenerator < Generator
11
+ MIGRATION_GLOB = "db/migrations/*_create_gemstack_jobs.rb"
12
+
13
+ attr_reader :class_name, :file_name, :queue_name
14
+
15
+ def self.install_migration(root, output: $stdout, timestamp: nil)
16
+ existing = Dir.glob(File.join(root, MIGRATION_GLOB)).first
17
+ generator = new("Placeholder", root: root, output: output)
18
+ return generator.status("identical", existing, "jobs table migration already present") if existing
19
+
20
+ require "gemstack/jobs"
21
+ stamp = timestamp || Generator.migration_timestamp(root)
22
+ generator.write(File.join(root, "db/migrations/#{stamp}_create_gemstack_jobs.rb"), Jobs::Migration::SOURCE)
23
+ end
24
+
25
+ def initialize(name, root:, queue: nil, output: $stdout, force: false)
26
+ super(output: output, force: force)
27
+ @root = root
28
+ base = Inflector.camelize(name.to_s).delete_suffix("Job")
29
+ raise Thor::Error, "Invalid job name #{name.inspect}" unless base.match?(/\A[A-Z][A-Za-z0-9]*\z/)
30
+
31
+ @class_name = name.to_s.end_with?("Job") ? Inflector.camelize(name.to_s) : base
32
+ @file_name = Inflector.underscore(@class_name)
33
+ @queue_name = queue
34
+ Generator.check_constant!(@class_name, suggestion: "#{@class_name}Job") unless name == "Placeholder"
35
+ end
36
+
37
+ def run
38
+ ensure_base_classes(@root, :job)
39
+ template_files("job", override_root: @root).each do |rel, source|
40
+ target = File.join(@root, rel.delete_suffix(".tt").gsub("%file_name%", file_name))
41
+ write(target, render(File.read(source), source))
42
+ end
43
+ self.class.install_migration(@root, output: @output) if File.directory?(File.join(@root, "db"))
44
+ self
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ # `gemstack generate migration NAME [fields]`
6
+ #
7
+ # AddSkuToProducts sku:string:unique → alter_table(:products) { add_column ... }
8
+ # anything else → an empty `change` block
9
+ class MigrationGenerator < Generator
10
+ attr_reader :file_name, :table, :columns, :timestamp
11
+
12
+ def initialize(name, field_args, root:, output: $stdout, timestamp: nil)
13
+ super(output: output)
14
+ @root = root
15
+ @file_name = Inflector.underscore(name.to_s)
16
+ raise Thor::Error, "Invalid migration name #{name.inspect}" unless @file_name.match?(/\A[a-z][a-z0-9_]*\z/)
17
+
18
+ @timestamp = timestamp || Generator.migration_timestamp(root)
19
+ @table = @file_name[/\Aadd_\w+_to_(\w+)\z/, 1]
20
+ spec = ResourceSpec.new("Migration", field_args)
21
+ builder = ResourceGenerator.new(spec, root: root, output: output)
22
+ @columns = spec.fields.flat_map do |field|
23
+ [normalize(builder.migration_column(field),
24
+ field)] + (field.index && !field.unique ? ["add_index :#{field.column}"] : [])
25
+ end
26
+ end
27
+
28
+ def run
29
+ template_files("migration", override_root: @root).each do |rel, source|
30
+ path = rel.delete_suffix(".tt").gsub("%timestamp%", timestamp).gsub("%file_name%", file_name)
31
+ write(File.join(@root, path), render(File.read(source), source))
32
+ end
33
+ self
34
+ end
35
+
36
+ private
37
+
38
+ # Turns a create_table column line into its alter_table equivalent.
39
+ def normalize(line, field)
40
+ if field.reference?
41
+ line.sub(/\Aforeign_key/, "add_foreign_key")
42
+ elsif line.start_with?("column ")
43
+ line.sub(/\Acolumn :(\w+), /, 'add_column :\1, ')
44
+ else
45
+ type, rest = line.split(" ", 2)
46
+ name, options = rest.split(",", 2)
47
+ "add_column #{name}, #{type}#{",#{options}" if options}"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ # `gemstack generate policy Order` → app/policies/order_policy.rb + test.
6
+ class PolicyGenerator < Generator
7
+ attr_reader :model_name, :class_name, :file_name
8
+
9
+ def initialize(name, root:, output: $stdout, force: false)
10
+ super(output: output, force: force)
11
+ @root = root
12
+ @model_name = Inflector.camelize(name.to_s.delete_suffix("Policy").delete_suffix("_policy"))
13
+ unless @model_name.match?(/\A[A-Z][A-Za-z0-9]*(::[A-Z][A-Za-z0-9]*)*\z/)
14
+ raise Thor::Error,
15
+ "Invalid policy name #{name.inspect}"
16
+ end
17
+
18
+ @class_name = "#{@model_name}Policy"
19
+ @file_name = Inflector.underscore(@model_name)
20
+ end
21
+
22
+ def parent_class
23
+ File.file?(File.join(@root, "app/policies/application_policy.rb")) ? "ApplicationPolicy" : "GemStack::Policy"
24
+ end
25
+
26
+ def run
27
+ template_files("policy", override_root: @root).each do |rel, source|
28
+ target = File.join(@root, rel.delete_suffix(".tt").gsub("%file_name%", file_name))
29
+ write(target, render(File.read(source), source))
30
+ end
31
+ self
32
+ end
33
+ end
34
+ end
35
+ end
@@ -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