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,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Dev
5
+ # Runs `gemstack dev`: the gateway on the public port, the Ruby API and
6
+ # Next.js on private ports chosen automatically, prefixed output, restarts
7
+ # of the API when configuration changes, and a clean shutdown on Ctrl-C.
8
+ class Supervisor
9
+ LOOPBACK = "127.0.0.1"
10
+ TICK = 0.25
11
+
12
+ attr_reader :gateway, :processes
13
+
14
+ def initialize(root:, config: GemStack.config, terminal: Terminal.new, env: ENV)
15
+ @root = Pathname.new(root)
16
+ @config = config
17
+ @dev = config.dev
18
+ @terminal = terminal
19
+ @env = env
20
+ @processes = {}
21
+ @stopping = false
22
+ end
23
+
24
+ def run
25
+ if frontend?
26
+ check_node!
27
+ prepare_frontend
28
+ end
29
+ start
30
+ install_signal_handlers
31
+ loop_until_stopped
32
+ ensure
33
+ shutdown
34
+ end
35
+
36
+ def start
37
+ @started_at = monotonic
38
+ api_port = Ports.free(LOOPBACK)
39
+ web_port = Ports.free(LOOPBACK) if frontend?
40
+ @gateway = Gateway.new(
41
+ port: @dev.port, bind: @dev.bind, api_path: @config.http.api_path,
42
+ api: Gateway::Upstream.new(:api, LOOPBACK, api_port, "Ruby API"),
43
+ frontend: web_port && Gateway::Upstream.new(:next, LOOPBACK, web_port, "Next.js"),
44
+ on_error: ->(e) { @terminal.line("gateway", "#{e.class}: #{e.message}") }
45
+ ).start
46
+
47
+ start_processes(api_port, web_port)
48
+ @restart_watcher = FileWatcher.new(@dev.restart_on, root: @root, exclude: @dev.restart_exclude)
49
+ @recovery_watcher = FileWatcher.new(["app/**/*", "config/**/*", "Gemfile.lock"], root: @root)
50
+ @jobs_watcher = FileWatcher.new(["app/**/*.rb", "db/migrations/*.rb"], root: @root) if @dev.jobs_command
51
+ start_jobs if jobs_enabled?
52
+ if web_port && @dev.contract_command
53
+ @contract_watcher = FileWatcher.new(@dev.contract_watch, root: @root)
54
+ @contract_pending = true # generate once at startup
55
+ end
56
+ banner
57
+ watch_readiness
58
+ self
59
+ end
60
+
61
+ def start_processes(api_port, web_port)
62
+ @processes[:api] = spawn("api", @dev.api_command, @root, api_env(api_port))
63
+ @processes[:next] = spawn("next", frontend_command(web_port), frontend_dir, frontend_env(api_port)) if web_port
64
+ end
65
+
66
+ def spawn(name, command, dir, env)
67
+ ManagedProcess.new(name, command, terminal: @terminal, chdir: dir, env: env).start
68
+ end
69
+
70
+ def stop!
71
+ @stopping = true
72
+ end
73
+
74
+ def shutdown
75
+ return if @shut_down
76
+
77
+ @shut_down = true
78
+ @terminal.puts
79
+ @terminal.line("gemstack", "shutting down…")
80
+ @processes.values.map { |process| Thread.new { process.stop } }.each(&:join)
81
+ @gateway&.stop
82
+ end
83
+
84
+ private
85
+
86
+ def frontend? = frontend_dir.join("package.json").file?
87
+ def frontend_dir = @root.join(@dev.frontend_dir)
88
+
89
+ def api_env(port)
90
+ {
91
+ "GEMSTACK_ENV" => @env.fetch("GEMSTACK_ENV", "development"),
92
+ "GEMSTACK_API_HOST" => LOOPBACK,
93
+ "GEMSTACK_API_PORT" => port.to_s,
94
+ "GEMSTACK_LOG_COLOR" => @terminal.color ? "1" : "0",
95
+ "PORT" => nil # the public port belongs to the gateway
96
+ }
97
+ end
98
+
99
+ def frontend_env(api_port)
100
+ {
101
+ "GEMSTACK_API_URL" => "http://#{LOOPBACK}:#{api_port}",
102
+ "NEXT_PUBLIC_GEMSTACK_API_PATH" => @config.http.api_path,
103
+ "FORCE_COLOR" => @terminal.color ? "1" : nil,
104
+ "PORT" => nil
105
+ }
106
+ end
107
+
108
+ def frontend_command(port)
109
+ return Array(@dev.frontend_command) + ["--port", port.to_s] if @dev.frontend_command
110
+
111
+ [frontend_dir.join("node_modules/.bin/next").to_s, "dev", "--hostname", LOOPBACK, "--port", port.to_s]
112
+ end
113
+
114
+ # Next.js exits at once on an old Node.js with a line that's easy to miss
115
+ # among the other output; say it up front, with the command that fixes it.
116
+ def check_node!(node = Toolchain.node)
117
+ return if node && Toolchain.node_ok?(node[:version])
118
+
119
+ wanted = [@root.join(".node-version"), @root.join(".nvmrc")].find(&:file?)&.read&.strip
120
+ wanted = Toolchain::LTS_NODE if wanted.nil? || wanted.empty?
121
+ found = node ? "Node.js #{node[:version]} (#{node[:path]})" : "no `node` on the PATH"
122
+ raise Error, "Next.js needs Node.js #{Toolchain::MIN_NODE.join(".")} or newer; found #{found}.\n " \
123
+ "→ #{Toolchain.node_hint(wanted, Toolchain.node_manager(node&.fetch(:path)))}"
124
+ end
125
+
126
+ # Installs frontend dependencies on first run so `gemstack new && gemstack dev`
127
+ # works even with --skip-install.
128
+ def prepare_frontend
129
+ return if frontend_dir.join("node_modules/.bin/next").exist?
130
+
131
+ manager = package_manager
132
+ @terminal.line("gemstack", "installing frontend dependencies with #{manager} (first run)…")
133
+ installer = ManagedProcess.new(manager, [manager, "install"], terminal: @terminal, chdir: frontend_dir).start
134
+ sleep 0.1 while installer.running?
135
+ return if installer.status.is_a?(Process::Status) && installer.status.success?
136
+
137
+ raise Error, "#{manager} install failed (#{installer.describe_status}) in #{frontend_dir}"
138
+ end
139
+
140
+ def package_manager
141
+ {
142
+ "pnpm-lock.yaml" => "pnpm", "yarn.lock" => "yarn", "bun.lockb" => "bun", "bun.lock" => "bun"
143
+ }.each { |lock, manager| return manager if frontend_dir.join(lock).exist? }
144
+ "npm"
145
+ end
146
+
147
+ def banner
148
+ url = "http://localhost:#{@gateway.port}"
149
+ t = @terminal
150
+ t.puts
151
+ t.puts(" #{t.bold("GemStack")} #{t.dim("v#{GemStack::VERSION} · #{@env.fetch("GEMSTACK_ENV",
152
+ "development")}")}")
153
+ t.puts
154
+ split = t.dim("(#{@config.http.api_path}/* → Ruby, everything else → Next.js)")
155
+ t.puts(" #{t.green("✓")} Gateway #{url} #{split}")
156
+ t.puts(" #{t.yellow("…")} Ruby API #{t.dim("starting on #{@gateway.api} (internal)")}")
157
+ if @gateway.frontend
158
+ t.puts(" #{t.yellow("…")} Next.js #{t.dim("starting on #{@gateway.frontend} (internal)")}")
159
+ else
160
+ t.puts(" #{t.dim("-")} Next.js #{t.dim("no #{@dev.frontend_dir}/package.json — API only")}")
161
+ end
162
+ t.puts
163
+ t.puts(" Application: #{t.bold(url)}")
164
+ t.puts
165
+ end
166
+
167
+ def watch_readiness
168
+ [@gateway.api, @gateway.frontend].compact.each do |upstream|
169
+ Thread.new do
170
+ until @stopping
171
+ if Ports.open?(upstream.host, upstream.port)
172
+ seconds = (monotonic - @started_at).round(1)
173
+ @terminal.line("gemstack", "#{@terminal.green("✓")} #{upstream.label} ready (#{seconds}s)")
174
+ break
175
+ end
176
+ sleep TICK
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ def install_signal_handlers
183
+ %w[INT TERM].each { |signal| trap(signal) { @stopping = true } }
184
+ end
185
+
186
+ def loop_until_stopped
187
+ until @stopping
188
+ sleep TICK
189
+ supervise
190
+ end
191
+ end
192
+
193
+ def supervise
194
+ api = @processes[:api]
195
+ if @restart_watcher.changed?
196
+ restart_api("configuration changed")
197
+ elsif !api.running? && api.exited?
198
+ report_exit(api)
199
+ restart_api("files changed") if @recovery_watcher.changed?
200
+ end
201
+ web = @processes[:next]
202
+ report_exit(web) if web && !web.running? && web.exited?
203
+ supervise_contract
204
+ supervise_jobs
205
+ end
206
+
207
+ # A worker only makes sense with the database queue and its table.
208
+ def jobs_enabled?
209
+ return false unless @dev.jobs_command && defined?(GemStack::Jobs) && @config.respond_to?(:jobs)
210
+ return false unless %w[database postgres].include?(@config.jobs.adapter.to_s)
211
+
212
+ Dir.glob(@root.join("db/migrations/*_create_gemstack_jobs.rb").to_s).any?
213
+ end
214
+
215
+ def start_jobs
216
+ @processes[:jobs] = spawn("jobs", @dev.jobs_command, @root, { "GEMSTACK_ENV" => "development" })
217
+ end
218
+
219
+ # The worker doesn't reload code, so restart it when app/ changes
220
+ # (it finishes running jobs first), and start it once the jobs table exists.
221
+ def supervise_jobs
222
+ return unless @jobs_watcher&.changed?
223
+
224
+ jobs = @processes[:jobs]
225
+ if jobs&.running?
226
+ @terminal.line("gemstack", "app changed — restarting job worker")
227
+ jobs.restart
228
+ elsif jobs_enabled?
229
+ start_jobs
230
+ end
231
+ end
232
+
233
+ # One contract run at a time; changes during a run queue exactly one more.
234
+ def supervise_contract
235
+ return unless @contract_watcher
236
+
237
+ @contract_pending = true if @contract_watcher.changed?
238
+ running = @processes[:contract]&.running?
239
+ return if running || !@contract_pending
240
+
241
+ @contract_pending = false
242
+ @processes[:contract] = spawn("contract", @dev.contract_command, @root, { "GEMSTACK_ENV" => "development" })
243
+ end
244
+
245
+ # Both watchers overlap (config/), so re-baseline both after a restart
246
+ # to avoid restarting twice for a single change.
247
+ def restart_api(reason)
248
+ @terminal.line("gemstack", "#{reason} — restarting Ruby API")
249
+ @processes[:api].restart
250
+ @restart_watcher.changed?
251
+ @recovery_watcher.changed?
252
+ @reported_exit = nil
253
+ end
254
+
255
+ def report_exit(process)
256
+ key = [process.name, process.pid]
257
+ return if @reported_exit&.include?(key)
258
+
259
+ (@reported_exit ||= []) << key
260
+ hint = process.name == "api" ? " — fix the error; it restarts when you save a file" : ""
261
+ @terminal.line("gemstack", @terminal.red("#{process.name} exited (#{process.describe_status})#{hint}"))
262
+ end
263
+
264
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Dev
5
+ # Serialised, prefixed, coloured output for multiple processes:
6
+ #
7
+ # api │ 12:00:01.120 INFO GET /api/health status=200 ms=0.4
8
+ # next │ ✓ Compiled / in 812ms
9
+ class Terminal
10
+ COLORS = { "api" => 35, "next" => 36, "gateway" => 33, "gemstack" => 32, "jobs" => 34 }.freeze
11
+ WIDTH = 8
12
+
13
+ def initialize(io = $stdout, color: io.respond_to?(:tty?) && io.tty?)
14
+ @io = io
15
+ @io.sync = true if @io.respond_to?(:sync=) # output may be a pipe or file
16
+ @color = color
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ attr_reader :color
21
+
22
+ def line(name, text)
23
+ prefix = paint(name.ljust(WIDTH), COLORS.fetch(name, 37))
24
+ text = text.to_s.chomp
25
+ @mutex.synchronize { @io.puts("#{prefix}│ #{text}") }
26
+ end
27
+
28
+ def puts(text = "")
29
+ @mutex.synchronize { @io.puts(text) }
30
+ end
31
+
32
+ def paint(text, code) = @color ? "\e[#{code}m#{text}\e[0m" : text
33
+ def bold(text) = @color ? "\e[1m#{text}\e[0m" : text
34
+ def dim(text) = paint(text, 90)
35
+ def green(text) = paint(text, 32)
36
+ def red(text) = paint(text, 31)
37
+ def yellow(text) = paint(text, 33)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "rbconfig"
5
+
6
+ module GemStack
7
+ module Dev
8
+ # The Ruby and Node.js a developer runs, and how to change them — for
9
+ # whichever version manager installed them (rbenv, rvm, asdf, mise,
10
+ # chruby, nvm, fnm, nodenv, Volta, Homebrew…). GemStack pins versions in
11
+ # files every manager reads (.ruby-version, .node-version, .nvmrc,
12
+ # .tool-versions) and never assumes one manager.
13
+ module Toolchain
14
+ MIN_RUBY = "3.3"
15
+ MIN_NODE = [20, 9].freeze # Next.js 16
16
+ LTS_NODE = "22"
17
+
18
+ # [pattern in the executable's path, manager name]; the first match wins.
19
+ RUBY_MANAGERS = [
20
+ [%r{/\.rbenv/}, :rbenv], [%r{/\.rvm/|/rvm/rubies/}, :rvm], [%r{/\.asdf/|/asdf/installs/}, :asdf],
21
+ [%r{/mise/installs/|/\.local/share/mise/}, :mise], [%r{/\.rubies/|/opt/rubies/}, :chruby],
22
+ [%r{/(?:opt/)?homebrew/|/usr/local/Cellar/}, :homebrew]
23
+ ].freeze
24
+ NODE_MANAGERS = [
25
+ [%r{/\.nvm/}, :nvm], [%r{/fnm/|/\.fnm/|fnm_multishells}, :fnm], [%r{/\.nodenv/}, :nodenv],
26
+ [%r{/\.asdf/|/asdf/installs/}, :asdf], [%r{/mise/installs/|/\.local/share/mise/}, :mise],
27
+ [%r{/\.volta/}, :volta], [%r{/(?:opt/)?homebrew/|/usr/local/Cellar/}, :homebrew]
28
+ ].freeze
29
+
30
+ module_function
31
+
32
+ def ruby_manager(path = RbConfig.ruby) = detect(RUBY_MANAGERS, path)
33
+ def node_manager(path) = path && detect(NODE_MANAGERS, path)
34
+
35
+ def detect(managers, path)
36
+ managers.find { |pattern, _| path.to_s.match?(pattern) }&.last
37
+ end
38
+
39
+ # How to install and select a Ruby, with the manager in use.
40
+ def ruby_hint(version, manager = ruby_manager)
41
+ {
42
+ rbenv: "rbenv install #{version} && rbenv local #{version}",
43
+ rvm: "rvm install #{version} && rvm use #{version}",
44
+ asdf: "asdf install ruby #{version} && asdf set ruby #{version}",
45
+ mise: "mise use ruby@#{version}",
46
+ chruby: "ruby-install ruby #{version}, then: chruby #{version}",
47
+ homebrew: "brew upgrade ruby (or install #{version} with rbenv, rvm, asdf or mise)"
48
+ }.fetch(manager, "install Ruby #{version} (e.g. with rbenv, rvm, asdf or mise) and make it the active Ruby")
49
+ end
50
+
51
+ # How to install and select a Node.js, with the manager in use.
52
+ def node_hint(version, manager)
53
+ {
54
+ nvm: "nvm install #{version} && nvm use #{version}",
55
+ fnm: "fnm install #{version} && fnm use #{version}",
56
+ nodenv: "nodenv install #{version} && nodenv local #{version}",
57
+ asdf: "asdf install nodejs #{version} && asdf set nodejs #{version}",
58
+ mise: "mise use node@#{version}",
59
+ volta: "volta install node@#{version}",
60
+ homebrew: "brew install node@#{version} (or use nvm, fnm, asdf or mise)"
61
+ }.fetch(manager, "install Node.js #{version} (e.g. with nvm, fnm, asdf or mise) and make it the active node")
62
+ end
63
+
64
+ # { version: "22.11.0", path: "/…/bin/node" } or nil when there's no node.
65
+ def node(run = ->(*cmd) { Open3.capture2e(*cmd) })
66
+ out, status = run.call("node", "-p", "process.version + ' ' + process.execPath")
67
+ return nil unless status.success?
68
+
69
+ version, path = out.strip.split(" ", 2)
70
+ { version: version.delete_prefix("v"), path: path }
71
+ rescue SystemCallError
72
+ nil
73
+ end
74
+
75
+ def node_ok?(version) = (version.to_s.split(".").map(&:to_i) <=> MIN_NODE) >= 0
76
+ def ruby_ok?(version = RUBY_VERSION) = Gem::Version.new(version) >= Gem::Version.new(MIN_RUBY)
77
+
78
+ # The Node.js version new apps pin: the one running `gemstack new` when
79
+ # it's new enough, else the current LTS line.
80
+ def pinned_node_version(run = ->(*cmd) { Open3.capture2e(*cmd) })
81
+ found = node(run)
82
+ found && node_ok?(found[:version]) ? found[:version] : LTS_NODE
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gemstack/core"
4
+
5
+ module GemStack
6
+ # Development tooling: the single-origin gateway and the process
7
+ # supervisor behind `gemstack dev` (ARCHITECTURE §5). Nothing here is
8
+ # loaded in production unless explicitly required.
9
+ module Dev
10
+ autoload :FileWatcher, "gemstack/dev/file_watcher"
11
+ autoload :Gateway, "gemstack/dev/gateway"
12
+ autoload :ManagedProcess, "gemstack/dev/managed_process"
13
+ autoload :Ports, "gemstack/dev/ports"
14
+ autoload :Supervisor, "gemstack/dev/supervisor"
15
+ autoload :Terminal, "gemstack/dev/terminal"
16
+ autoload :Toolchain, "gemstack/dev/toolchain"
17
+
18
+ class Config < Settings
19
+ # The single public port.
20
+ setting :port, default: -> { Integer(ENV.fetch("PORT", 3000)) }
21
+ # Interfaces the gateway listens on. Both loopback families, because
22
+ # browsers may resolve "localhost" to either.
23
+ setting :bind, default: %w[127.0.0.1 ::1]
24
+ setting :frontend_dir, default: "frontend"
25
+ setting :api_command, default: %w[bundle exec puma -C config/puma.rb]
26
+ # nil = run the frontend's own `next` binary in dev mode.
27
+ setting :frontend_command, default: nil
28
+ # Changes here restart the Ruby process. app/ and config/routes.rb are
29
+ # reloaded in-process instead and don't need a restart.
30
+ setting :restart_on, default: ["config/**/*.rb", "Gemfile.lock", ".env*"]
31
+ setting :restart_exclude, default: ["config/routes.rb"]
32
+ # Regenerates TypeScript types/clients when backend code changes
33
+ # (only when the app has a frontend). nil disables.
34
+ setting :contract_command, default: %w[bundle exec gemstack contract --quiet]
35
+ setting :contract_watch, default: ["app/**/*.rb", "config/routes.rb"]
36
+ # Background job worker, run when the app uses the :postgres job
37
+ # adapter and has the gemstack_jobs migration. Restarted when app/ changes.
38
+ setting :jobs_command, default: %w[bundle exec gemstack jobs]
39
+ end
40
+ end
41
+
42
+ Config.namespace(:dev, Dev::Config)
43
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ # Minimal `.env` file support.
5
+ #
6
+ # Supported syntax:
7
+ # KEY=value
8
+ # export KEY=value
9
+ # KEY="double quoted, supports \n \t \" escapes"
10
+ # KEY='single quoted, literal'
11
+ # KEY=value # trailing comment (unquoted values only)
12
+ # # full-line comment
13
+ #
14
+ # Variables already present in the environment are never overwritten: the
15
+ # real environment always wins over files.
16
+ module Dotenv
17
+ LINE = /\A\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*(.*)\z/
18
+ ESCAPES = { "n" => "\n", "t" => "\t", "r" => "\r", '"' => '"', "\\" => "\\" }.freeze
19
+
20
+ module_function
21
+
22
+ # Loads the given files (in order; earlier files take precedence) into env.
23
+ # Returns the hash of variables that were actually set.
24
+ def load(*files, env: ENV)
25
+ loaded = {}
26
+ files.flatten.each do |file|
27
+ next unless File.file?(file)
28
+
29
+ parse(File.read(file)).each do |key, value|
30
+ next if env.key?(key) || loaded.key?(key)
31
+
32
+ env[key] = value
33
+ loaded[key] = value
34
+ end
35
+ end
36
+ loaded
37
+ end
38
+
39
+ def parse(source)
40
+ source.each_line.with_object({}) do |raw, vars|
41
+ line = raw.chomp
42
+ next if line.strip.empty? || line.lstrip.start_with?("#")
43
+
44
+ match = LINE.match(line) or next
45
+ vars[match[1]] = parse_value(match[2].strip)
46
+ end
47
+ end
48
+
49
+ def parse_value(value)
50
+ case value[0]
51
+ when '"'
52
+ body = value[1..][/\A((?:[^"\\]|\\.)*)"/, 1] || value[1..]
53
+ body.gsub(/\\(.)/) { ESCAPES.fetch(::Regexp.last_match(1), "\\#{::Regexp.last_match(1)}") }
54
+ when "'"
55
+ value[1..][/\A([^']*)'/, 1] || value[1..]
56
+ else
57
+ value.sub(/\s+#.*\z/, "")
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ # The running environment: development, test, production, or any custom name.
5
+ #
6
+ # GemStack.env.production? # => false
7
+ # GemStack.env.to_s # => "development"
8
+ class Environment
9
+ KNOWN = %w[development test production].freeze
10
+
11
+ attr_reader :name
12
+
13
+ def self.detect(env = ENV)
14
+ new(env["GEMSTACK_ENV"] || env["RACK_ENV"] || "development")
15
+ end
16
+
17
+ def initialize(name)
18
+ @name = name.to_s.strip.downcase
19
+ raise ArgumentError, "environment name cannot be empty" if @name.empty?
20
+ end
21
+
22
+ def development? = name == "development"
23
+ def test? = name == "test"
24
+ def production? = name == "production"
25
+
26
+ # Anything that isn't development or test is treated like production for
27
+ # safety-related defaults (hiding error details, JSON logs, ...).
28
+ def local? = development? || test?
29
+
30
+ def to_s = name
31
+ def to_sym = name.to_sym
32
+ def ==(other) = name == other.to_s
33
+ alias eql? ==
34
+ def hash = name.hash
35
+ def inspect = "#<GemStack::Environment #{name}>"
36
+ end
37
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ # Translates third-party exceptions into GemStack errors, so modules can
5
+ # give library errors an HTTP meaning without depending on the HTTP layer:
6
+ #
7
+ # GemStack::ErrorMapping.register(Sequel::NoMatchingRow) { GemStack::NotFound.new }
8
+ # GemStack::ErrorMapping.register(Stripe::CardError) { |e| GemStack::Error.new(e.message, status: 402, code: "card_declined") }
9
+ #
10
+ # The HTTP error renderer consults this registry before rendering. The most
11
+ # recently registered matching class wins, so applications can override
12
+ # module defaults.
13
+ module ErrorMapping
14
+ @mappings = []
15
+ @mutex = Mutex.new
16
+
17
+ class << self
18
+ def register(exception_class, &translator)
19
+ raise ArgumentError, "ErrorMapping.register needs a block" unless translator
20
+
21
+ @mutex.synchronize { @mappings.unshift([exception_class, translator]) }
22
+ end
23
+
24
+ def unregister(exception_class)
25
+ @mutex.synchronize { @mappings.reject! { |klass, _| klass == exception_class } }
26
+ end
27
+
28
+ # Returns the translated error, or the original exception if no mapping applies.
29
+ def translate(exception)
30
+ _, translator = @mappings.find { |klass, _| exception.is_a?(klass) }
31
+ return exception unless translator
32
+
33
+ translator.call(exception) || exception
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ # Base class for every GemStack error.
5
+ #
6
+ # Errors carry an HTTP-oriented *suggestion* (`status`, `code`) so that any
7
+ # module — including ones that know nothing about HTTP, such as a database
8
+ # adapter — can raise an error that the HTTP layer renders correctly. Any
9
+ # exception that responds to #status and #code is rendered this way, so
10
+ # applications can define their own errors without subclassing these.
11
+ class Error < StandardError
12
+ class << self
13
+ attr_writer :default_status, :default_code, :default_message
14
+
15
+ def default_status = @default_status || inherited_default(:default_status, 500)
16
+ def default_code = @default_code || inherited_default(:default_code, "internal_error")
17
+ def default_message = @default_message || inherited_default(:default_message, "Internal Server Error")
18
+
19
+ # Declares the status/code/message for an error class.
20
+ def status(status, code, message)
21
+ self.default_status = status
22
+ self.default_code = code
23
+ self.default_message = message
24
+ end
25
+
26
+ private
27
+
28
+ def inherited_default(name, fallback) = superclass.respond_to?(name) ? superclass.public_send(name) : fallback
29
+ end
30
+
31
+ attr_reader :status, :code, :details, :headers
32
+
33
+ # details: optional field => [messages] hash, rendered as `errors`.
34
+ # headers: extra response headers (e.g. "allow" for 405, "retry-after" for 429).
35
+ def initialize(message = nil, status: nil, code: nil, details: nil, headers: nil)
36
+ @status = status || self.class.default_status
37
+ @code = code || self.class.default_code
38
+ @details = details
39
+ @headers = headers || {}
40
+ super(message || self.class.default_message)
41
+ end
42
+
43
+ # Whether the message is safe to show to API clients. Server errors are
44
+ # not: their messages may contain internal information.
45
+ def expose_message? = status < 500
46
+ end
47
+
48
+ # Raised for invalid framework or application configuration.
49
+ class ConfigurationError < Error; end
50
+
51
+ class BadRequest < Error
52
+ status 400, "bad_request", "Bad Request"
53
+ end
54
+
55
+ class Unauthorized < Error
56
+ status 401, "unauthorized", "Unauthorized"
57
+ end
58
+
59
+ class Forbidden < Error
60
+ status 403, "forbidden", "Forbidden"
61
+ end
62
+
63
+ class NotFound < Error
64
+ status 404, "not_found", "Not Found"
65
+ end
66
+
67
+ class MethodNotAllowed < Error
68
+ status 405, "method_not_allowed", "Method Not Allowed"
69
+ end
70
+
71
+ class Conflict < Error
72
+ status 409, "conflict", "Conflict"
73
+ end
74
+
75
+ class PayloadTooLarge < Error
76
+ status 413, "payload_too_large", "Payload Too Large"
77
+ end
78
+
79
+ class UnsupportedMediaType < Error
80
+ status 415, "unsupported_media_type", "Unsupported Media Type"
81
+ end
82
+
83
+ class ValidationError < Error
84
+ status 422, "validation_failed", "Validation failed"
85
+
86
+ def initialize(message = nil, errors: {}, **)
87
+ super(message, details: errors, **)
88
+ end
89
+
90
+ def errors = details
91
+ end
92
+
93
+ class TooManyRequests < Error
94
+ status 429, "too_many_requests", "Too Many Requests"
95
+ end
96
+
97
+ class ServiceUnavailable < Error
98
+ status 503, "service_unavailable", "Service Unavailable"
99
+ end
100
+ end