monkrb 0.20.0 → 0.21.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/README.md +7 -6
  4. data/exe/monk +3 -181
  5. data/lib/monk/assets.rb +39 -9
  6. data/lib/monk/auth/errors.rb +3 -0
  7. data/lib/monk/auth/helpers.rb +11 -0
  8. data/lib/monk/auth/send_login_link.rb +36 -0
  9. data/lib/monk/auth.rb +34 -5
  10. data/lib/monk/bin.rb +107 -0
  11. data/lib/monk/cli.rb +277 -0
  12. data/lib/monk/context.rb +17 -0
  13. data/lib/monk/errors.rb +0 -6
  14. data/lib/monk/generator/definition.rb +153 -0
  15. data/lib/monk/generator/result.rb +279 -0
  16. data/lib/monk/generator/run.rb +449 -0
  17. data/lib/monk/generator.rb +48 -0
  18. data/lib/monk/generators/auth.rb +29 -0
  19. data/lib/monk/generators/base.rb +19 -0
  20. data/lib/monk/generators/jobs.rb +21 -0
  21. data/lib/monk/generators/live.rb +26 -0
  22. data/lib/monk/generators/mail.rb +24 -0
  23. data/lib/monk/generators/postgres.rb +23 -0
  24. data/lib/monk/generators/redis.rb +20 -0
  25. data/lib/monk/generators/websocket.rb +24 -0
  26. data/lib/monk/generators.rb +7 -0
  27. data/lib/monk/jobs.rb +7 -0
  28. data/lib/monk/live/helpers.rb +13 -0
  29. data/lib/monk/live.rb +12 -1
  30. data/lib/monk/log.rb +10 -1
  31. data/lib/monk/mail/deliver_later.rb +54 -0
  32. data/lib/monk/mail/later.rb +3 -51
  33. data/lib/monk/mail.rb +4 -0
  34. data/lib/monk/templates/auth/agents.md +29 -0
  35. data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
  36. data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
  37. data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
  38. data/lib/monk/templates/auth/config/auth.rb +8 -8
  39. data/lib/monk/templates/auth/setup.md +37 -0
  40. data/lib/monk/templates/auth/test/auth_test.rb +17 -0
  41. data/lib/monk/templates/base/CLAUDE.md +1 -0
  42. data/lib/monk/templates/base/Dockerfile +4 -3
  43. data/lib/monk/templates/base/Gemfile +7 -2
  44. data/lib/monk/templates/base/Rakefile +8 -0
  45. data/lib/monk/templates/base/agents.md +41 -0
  46. data/lib/monk/templates/base/app/app.rb +5 -0
  47. data/lib/monk/templates/base/app/routes/.keep +0 -0
  48. data/lib/monk/templates/base/app/views/index.erb +1 -1
  49. data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
  50. data/lib/monk/templates/base/config/load.rb +8 -0
  51. data/lib/monk/templates/base/config/settings.rb +4 -5
  52. data/lib/monk/templates/base/setup.md +22 -0
  53. data/lib/monk/templates/base/test/app_test.rb +12 -0
  54. data/lib/monk/templates/base/test/test_helper.rb +18 -0
  55. data/lib/monk/templates/jobs/agents.md +25 -0
  56. data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
  57. data/lib/monk/templates/jobs/bin/jobs +19 -13
  58. data/lib/monk/templates/jobs/setup.md +30 -0
  59. data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
  60. data/lib/monk/templates/live/agents.md +25 -0
  61. data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
  62. data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
  63. data/lib/monk/templates/live/app/routes/live.rb +10 -0
  64. data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
  65. data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
  66. data/lib/monk/templates/live/config/live.rb +30 -32
  67. data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
  68. data/lib/monk/templates/live/setup.md +39 -0
  69. data/lib/monk/templates/live/test/live_test.rb +22 -0
  70. data/lib/monk/templates/mail/agents.md +22 -0
  71. data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
  72. data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
  73. data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
  74. data/lib/monk/templates/mail/config/mail.rb +4 -4
  75. data/lib/monk/templates/mail/setup.md +32 -0
  76. data/lib/monk/templates/mail/test/mail_test.rb +13 -0
  77. data/lib/monk/templates/postgres/agents.md +25 -0
  78. data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
  79. data/lib/monk/templates/postgres/bin/console +3 -1
  80. data/lib/monk/templates/postgres/bin/migrate +23 -17
  81. data/lib/monk/templates/postgres/bin/setup_db +11 -5
  82. data/lib/monk/templates/postgres/config/persistence.rb +1 -0
  83. data/lib/monk/templates/postgres/setup.md +38 -0
  84. data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
  85. data/lib/monk/templates/redis/agents.md +18 -0
  86. data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
  87. data/lib/monk/templates/redis/config/redis.rb +13 -0
  88. data/lib/monk/templates/redis/setup.md +20 -0
  89. data/lib/monk/templates/redis/test/redis_test.rb +12 -0
  90. data/lib/monk/templates/websocket/agents.md +25 -0
  91. data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
  92. data/lib/monk/templates/websocket/bin/websocket_server +58 -0
  93. data/lib/monk/templates/websocket/config/websocket_postgres.rb +24 -0
  94. data/lib/monk/templates/websocket/config/websocket_redis.rb +21 -0
  95. data/lib/monk/templates/websocket/setup.md +51 -0
  96. data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
  97. data/lib/monk/version.rb +1 -1
  98. data/lib/monk/websocket/server.rb +3 -1
  99. metadata +81 -14
  100. data/lib/monk/scaffold.rb +0 -1063
  101. data/lib/monk/templates/base/bin/websocket_server +0 -77
  102. data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
  103. data/lib/monk/templates/live/app/app.rb +0 -22
  104. data/lib/monk/templates/live/app/views/index.erb +0 -18
  105. data/lib/monk/templates/live/bin/websocket_server +0 -50
  106. data/lib/monk/templates/live/config/live_pg.rb +0 -60
  107. data/lib/monk/templates/mail/Gemfile.extra +0 -1
  108. data/lib/monk/templates/postgres/Dockerfile +0 -32
  109. data/lib/monk/templates/postgres/Gemfile.extra +0 -2
  110. data/lib/monk/templates/redis/Gemfile.extra +0 -1
  111. /data/lib/monk/templates/live/app/views/{live → demo}/_hits.erb +0 -0
data/lib/monk/scaffold.rb DELETED
@@ -1,1063 +0,0 @@
1
- require "fileutils"
2
-
3
- require_relative "errors"
4
-
5
- module Monk
6
- # Writes a new Monk project's skeleton to disk. Templates are static
7
- # files, copied verbatim -- nothing here needs the project's own name
8
- # substituted in, so there's no templating engine involved. See
9
- # docs/history/plan-init.md for the full design.
10
- class Scaffold
11
- TEMPLATES_DIR = File.expand_path("templates", __dir__)
12
-
13
- # The role directories under app/, in config/load.rb's load order.
14
- APP_ROLES = %w[models presenters helpers mailers broadcasts jobs].freeze
15
-
16
- BASE_FILES = {
17
- "Gemfile" => "base/Gemfile",
18
- "config.ru" => "base/config.ru",
19
- "config/settings.rb" => "base/config/settings.rb",
20
- "config/load.rb" => "base/config/load.rb",
21
- "app/app.rb" => "base/app/app.rb",
22
- ".ruby-version" => "base/.ruby-version",
23
- ".gitignore" => "base/.gitignore",
24
- ".dockerignore" => "base/.dockerignore",
25
- "Dockerfile" => "base/Dockerfile",
26
- "bin/server" => "base/bin/server",
27
- "bin/websocket_server" => "base/bin/websocket_server",
28
- "app/views/layouts/app.erb" => "base/app/views/layouts/app.erb",
29
- "app/views/index.erb" => "base/app/views/index.erb",
30
- # Every role directory, whatever the flags (docs/adr/0015): the tree
31
- # shows where each kind of code goes. Flags add files next to these.
32
- **APP_ROLES.to_h { |role| ["app/#{role}/.keep", "base/app/#{role}/.keep"] },
33
- "public/css/app.css" => "base/public/css/app.css",
34
- "public/js/app.js" => "base/public/js/app.js",
35
- }.freeze
36
-
37
- POSTGRES_FILES = {
38
- "config/persistence.rb" => "postgres/config/persistence.rb",
39
- "bin/console" => "postgres/bin/console",
40
- "bin/setup_db" => "postgres/bin/setup_db",
41
- "bin/migrate" => "postgres/bin/migrate",
42
- # Kept by git when empty, like app/'s role directories: a fresh clone
43
- # still shows where migrations go. The migrator reads only *.sql.
44
- "db/migrate/.keep" => "postgres/db/migrate/.keep",
45
- }.freeze
46
-
47
- # The pg gem's native extension needs libpq -- the base Dockerfile has
48
- # no system packages at all (kino is a precompiled platform gem), so
49
- # --postgres/--auth swap in a Dockerfile that adds libpq-dev/libpq5
50
- # instead of patching one image for every combination.
51
- POSTGRES_OVERRIDES = {
52
- "Dockerfile" => "postgres/Dockerfile",
53
- }.freeze
54
-
55
- AUTH_FILES = {
56
- "config/auth.rb" => "auth/config/auth.rb",
57
- "db/migrate/00000000000001_create_auth_tables.up.sql" => "auth/db/migrate/00000000000001_create_auth_tables.up.sql",
58
- "db/migrate/00000000000001_create_auth_tables.down.sql" => "auth/db/migrate/00000000000001_create_auth_tables.down.sql",
59
- # The magic link's sender, Monk::Auth's deliver: hook -- config/auth.rb
60
- # requires it -- and the HTML part it renders and sends through
61
- # Monk::Mail (MAIL_FILES, implied by --auth).
62
- "app/mailers/app_mailer.rb" => "auth/app/mailers/app_mailer.rb",
63
- "app/views/mail/magic_link.erb" => "auth/app/views/mail/magic_link.erb",
64
- }.freeze
65
-
66
- # --mail, or implied by --auth: Monk::Mail's config. Its Gemfile line
67
- # (net-smtp) and MAIL_* env vars are added by #write! / #write_env_files!.
68
- MAIL_FILES = {
69
- "config/mail.rb" => "mail/config/mail.rb",
70
- }.freeze
71
-
72
- # --jobs: Monk::Jobs. The migration is the canonical one under
73
- # templates/jobs/db/migrate -- the same file Monk's own tests apply --
74
- # numbered after auth's so the two sort in order when both are present.
75
- JOBS_FILES = {
76
- "config/jobs.rb" => "jobs/config/jobs.rb",
77
- "app/jobs/hello_job.rb" => "jobs/app/jobs/hello_job.rb",
78
- "bin/jobs" => "jobs/bin/jobs",
79
- "db/migrate/00000000000002_create_jobs_tables.up.sql" => "jobs/db/migrate/00000000000002_create_jobs_tables.up.sql",
80
- "db/migrate/00000000000002_create_jobs_tables.down.sql" =>
81
- "jobs/db/migrate/00000000000002_create_jobs_tables.down.sql",
82
- }.freeze
83
-
84
- # --auth --jobs: the magic link is created and sent inside a job, so the
85
- # raw token is never stored (docs/adr/0014).
86
- JOBS_AUTH_FILES = {
87
- "app/jobs/send_login_link.rb" => "jobs/app/jobs/send_login_link.rb",
88
- }.freeze
89
-
90
- # --mail --jobs (or --auth --jobs): Monk::Mail.deliver_later, loaded in
91
- # config/jobs.rb right after Monk::Jobs itself.
92
- JOBS_REQUIRE_ANCHOR = %(require "monk/jobs"\n).freeze
93
- JOBS_MAIL_REQUIRE = %(require "monk/mail/later"\n).freeze
94
-
95
- # --jobs's demo route, added right after this line in app/app.rb (the
96
- # base and the --live one both end their routes with it), so the
97
- # round trip -- enqueue from a request, run in bin/jobs -- works out of
98
- # the box.
99
- JOBS_ROUTE_ANCHOR = %( get("/api/hello") { json(message: "hello from monk") }\n).freeze
100
- JOBS_ROUTE = <<~RUBY.gsub(/^(?!$)/, " ").freeze
101
-
102
- # Enqueues the demo job (app/jobs/hello_job.rb) for bin/jobs to run:
103
- # curl -X POST "http://localhost:9292/jobs/hello?name=Ann"
104
- post("/jobs/hello") { json(enqueued: HelloJob.enqueue(params[:name] || "world")) }
105
- RUBY
106
-
107
- # --live: Monk::Live's demo (a counter whose open tabs update when
108
- # another request changes it). These replace three base files outright
109
- # rather than patching them -- a live app and index are different
110
- # files, not one line of diff -- and add two. config.ru stays the base
111
- # one: config/load.rb is where --live's config gets wired in.
112
- LIVE_OVERRIDES = {
113
- "app/app.rb" => "live/app/app.rb",
114
- "bin/websocket_server" => "live/bin/websocket_server",
115
- "app/views/index.erb" => "live/app/views/index.erb",
116
- }.freeze
117
-
118
- # config/live.rb itself isn't here -- #write_live! picks between
119
- # LIVE_CONFIG_TEMPLATES[@live_transport] instead, since which one gets
120
- # written depends on --redis vs --postgres, not a fixed mapping.
121
- LIVE_FILES = {
122
- "app/views/live/_hits.erb" => "live/app/views/live/_hits.erb",
123
- }.freeze
124
-
125
- LIVE_CONFIG_TEMPLATES = {
126
- redis: "live/config/live.rb",
127
- postgres: "live/config/live_pg.rb",
128
- }.freeze
129
-
130
- # Where the client runtime lands in the app's public root (an app serves
131
- # it like any other static file; its files import each other by relative
132
- # path, so they stay together in one directory).
133
- LIVE_CLIENT_DIR = "public/js/monk_live".freeze
134
-
135
- LIVE_HEAD = <<~HTML.gsub(/^/, " ").freeze
136
- <meta name="monk-live-url" content="<%= settings[:live_ws_url] %>">
137
- <script type="module" src="<%= asset_path "/js/monk_live/monk_live.js" %>"></script>
138
- HTML
139
-
140
- EXECUTABLE_FILES = %w[bin/server bin/websocket_server bin/console bin/setup_db bin/migrate bin/jobs].freeze
141
-
142
- # auth: implies postgres -- Monk::Auth is Postgres-only (lib/monk/auth.rb
143
- # subclasses Monk::Persistence::Pg::Model), so there's no combination
144
- # where an app gets Auth without also getting the persistence scaffold.
145
- #
146
- # redis: is unrelated to both -- it doesn't imply, and isn't implied by,
147
- # postgres or auth. Unlike them it adds no files of its own, just a
148
- # Gemfile line: bin/websocket_server (always present, see BASE_FILES
149
- # above -- WebSocket needs no external service, so unlike Postgres/Redis
150
- # it isn't gated behind a flag at all) checks ENV["REDIS_URL"] at boot
151
- # and only then requires "redis", so the gem has to already be in the
152
- # bundle for that to work.
153
- #
154
- # live: bin/server and bin/websocket_server are always separate
155
- # processes, so *something* has to carry a publish between them --
156
- # --redis (Monk::WebSocket::RedisFanout) or --postgres
157
- # (Monk::WebSocket::PgFanout, reusing --postgres's own DB_* settings,
158
- # no Redis to run). --live no longer silently defaults to one: with
159
- # neither flag, there's no way to tell whether this app has Postgres
160
- # available at all, so guessing would be as likely to hand back a
161
- # template that can't connect as one that can. --live --redis wins
162
- # over --postgres if both are passed (an explicit ask beats an
163
- # implied default); --live --postgres alone (or via --auth, which
164
- # implies --postgres) picks PgFanout; --live with neither raises.
165
- # See docs/design/live-pg-fanout.md / docs/history/plan-live-pg-fanout.md
166
- # for the design this decision comes from.
167
- #
168
- # mail: Monk::Mail on its own, no Postgres needed. auth: implies it, the
169
- # same way it implies postgres: a magic link has to reach someone.
170
- #
171
- # jobs: implies postgres, since the queue lives there, and nothing else.
172
- def initialize(dir, postgres: false, auth: false, redis: false, live: false, mail: false, jobs: false)
173
- @dir = dir
174
- # As passed, before anything is implied -- #summary reports the difference.
175
- @requested = { postgres: postgres, auth: auth, mail: mail, jobs: jobs, redis: redis, live: live }
176
- @auth = auth
177
- @jobs = jobs
178
- @postgres = postgres || auth || jobs
179
- @mail = mail || auth
180
- @live = live
181
-
182
- if @live
183
- @redis = redis || false
184
- @live_transport = if redis
185
- :redis
186
- elsif @postgres
187
- :postgres
188
- else
189
- raise Monk::AmbiguousLiveTransportError,
190
- "monk new --live needs --redis or --postgres to pick Monk::Live's cross-process " \
191
- "transport (bin/server and bin/websocket_server are always separate processes) -- " \
192
- "pass one explicitly, e.g. `--live --postgres` (docs/guides/live.md's " \
193
- "\"Without Redis\" section has the tradeoffs)"
194
- end
195
- else
196
- @redis = redis
197
- end
198
- end
199
-
200
- # The flags as resolved, for `monk new` to print: which flags were
201
- # given, which ones they implied and why, and which combinations change
202
- # what gets generated. The rule behind it, stated in `monk --help`: a
203
- # flag is implied when there's only one right answer (--auth can only
204
- # use Postgres), and required when there's a real choice (--live's
205
- # transport).
206
- def summary
207
- given = FLAG_ORDER.select { |flag| @requested[flag] }
208
- return ["Flags: none (the base skeleton)."] if given.empty?
209
-
210
- implied = implied_flags
211
- flags = given.map { |flag| "--#{flag}" }.join(" ")
212
- lines = implied.empty? ? ["Flags: #{flags}."] : ["Flags: #{flags}, which also turned on:"]
213
- implied.each do |flag, sources|
214
- lines << " #{"--#{flag}".ljust(12)}(needed by #{sources.map { |source| "--#{source}" }.join(", ")})"
215
- end
216
-
217
- together = combinations
218
- lines << "Together they also generate:" unless together.empty?
219
- width = together.map { |pair, _| pair.length }.max
220
- lines.concat(together.map { |pair, what| " #{pair.ljust(width)} #{what}" })
221
- end
222
-
223
- def write!
224
- raise Monk::ScaffoldExistsError, "#{@dir} already exists" if File.exist?(@dir)
225
-
226
- FileUtils.mkdir_p(@dir)
227
- base_files = BASE_FILES
228
- base_files = base_files.merge(LIVE_OVERRIDES) if @live
229
- base_files = base_files.merge(POSTGRES_OVERRIDES) if @postgres
230
- base_files.each { |relative, template| write_file(relative, template, executable: EXECUTABLE_FILES.include?(relative)) }
231
- write_live! if @live
232
-
233
- if @postgres
234
- POSTGRES_FILES.each { |relative, template| write_file(relative, template, executable: EXECUTABLE_FILES.include?(relative)) }
235
- append_gemfile_extra("postgres/Gemfile.extra")
236
- end
237
-
238
- if @mail
239
- MAIL_FILES.each { |relative, template| write_file(relative, template) }
240
- append_gemfile_extra("mail/Gemfile.extra")
241
- end
242
-
243
- if @jobs
244
- JOBS_FILES.each { |relative, template| write_file(relative, template, executable: EXECUTABLE_FILES.include?(relative)) }
245
- JOBS_AUTH_FILES.each { |relative, template| write_file(relative, template) } if @auth
246
- add_jobs_route!
247
- add_deliver_later! if @mail
248
- end
249
-
250
- wire_load! if @postgres || @mail || @live
251
- append_gemfile_extra("redis/Gemfile.extra") if @redis
252
-
253
- # --postgres, --redis and --mail are the flags with anything worth
254
- # putting in an env file (a database, a REDIS_URL, MAIL_*) -- the
255
- # base skeleton alone has nothing to configure this way.
256
- if @postgres || @redis || @mail
257
- write_env_files!
258
- uncomment_dotenv!
259
- end
260
-
261
- AUTH_FILES.each { |relative, template| write_file(relative, template) } if @auth
262
-
263
- # Every combination of flags gets a SETUP.md, not just --postgres --
264
- # even the base skeleton has a dev step (bin/server) and an
265
- # unscaffolded test framework to set up.
266
- write_setup_md!
267
- end
268
-
269
- FLAG_ORDER = %i[postgres auth mail jobs redis live].freeze
270
-
271
- # Only these two imply anything, and only one flag each can need.
272
- IMPLIED_BY = { postgres: %i[auth jobs], mail: %i[auth] }.freeze
273
-
274
- private
275
-
276
- def implied_flags
277
- resolved = { postgres: @postgres, mail: @mail }
278
- IMPLIED_BY.filter_map do |flag, sources|
279
- next if @requested[flag] || !resolved[flag]
280
-
281
- [flag, sources.select { |source| @requested[source] }]
282
- end
283
- end
284
-
285
- def combinations
286
- pairs = []
287
- pairs << ["--auth + --jobs", "app/jobs/send_login_link.rb: login links are sent from a job"] if @auth && @jobs
288
- if @mail && @jobs
289
- pairs << ["--mail + --jobs",
290
- "config/jobs.rb loads Monk::Mail.deliver_later; JOBS_QUEUES serves mailers first",]
291
- end
292
- pairs << live_combination if @live
293
- pairs
294
- end
295
-
296
- def live_combination
297
- if @live_transport == :redis
298
- both = @postgres ? "; --redis wins over --postgres" : ""
299
- ["--live + --redis", "config/live.rb fans out over Redis (Monk::WebSocket::RedisFanout#{both})"]
300
- else
301
- ["--live + --postgres", "config/live.rb fans out over Postgres (Monk::WebSocket::PgFanout)"]
302
- end
303
- end
304
-
305
- def write_live!
306
- write_file("config/live.rb", LIVE_CONFIG_TEMPLATES.fetch(@live_transport))
307
- LIVE_FILES.each { |relative, template| write_file(relative, template) }
308
- copy_live_client!
309
- add_live_to_layout!
310
- end
311
-
312
- # The runtime ships inside the gem (Monk::Live.client_dir); copied, not
313
- # duplicated under templates/, so there is one source of truth.
314
- def copy_live_client!
315
- source = File.join(__dir__, "live/client")
316
- target = File.join(@dir, LIVE_CLIENT_DIR)
317
- FileUtils.mkdir_p(target)
318
- FileUtils.cp(Dir.children(source).map { |name| File.join(source, name) }, target)
319
- end
320
-
321
- def add_live_to_layout!
322
- path = File.join(@dir, "app/views/layouts/app.erb")
323
- content = File.read(path)
324
- raise "layout wiring failed: </head> not found" unless content.include?(" </head>\n")
325
-
326
- File.write(path, content.sub(" </head>\n", "#{LIVE_HEAD} </head>\n"))
327
- end
328
-
329
- def append_gemfile_extra(template_path)
330
- extra = File.read(File.join(TEMPLATES_DIR, template_path))
331
- File.write(File.join(@dir, "Gemfile"), "\n#{extra}", mode: "a")
332
- end
333
-
334
- # --postgres/--redis write a real .env (see write_env_files!) -- if the
335
- # dotenv gem stays commented out, as it does in the base skeleton,
336
- # config/settings.rb's `require "dotenv/load"` never runs, so nothing
337
- # ever actually loads them: bin/setup_db (or bin/websocket_server's
338
- # REDIS_URL check) silently falls back to config/persistence.rb's own
339
- # ENV.fetch defaults, or no REDIS_URL at all, instead of the
340
- # app-specific values .env was written to provide.
341
- DOTENV_COMMENTED_LINE = %(# gem "dotenv" # uncomment to load a local .env file (config/settings.rb)\n).freeze
342
- DOTENV_LINE = %(gem "dotenv" # loads .env/.env.test -- see config/settings.rb\n).freeze
343
-
344
- def uncomment_dotenv!
345
- path = File.join(@dir, "Gemfile")
346
- content = File.read(path)
347
- raise "Gemfile wiring failed: #{DOTENV_COMMENTED_LINE.inspect} not found" unless content.include?(DOTENV_COMMENTED_LINE)
348
-
349
- File.write(path, content.sub(DOTENV_COMMENTED_LINE, DOTENV_LINE))
350
- end
351
-
352
- # config/load.rb ships in BASE_FILES unconditionally (config.ru and the
353
- # tests require it), so the flags can't gate whether it exists, only
354
- # which module configs it requires. Unlike BASE_FILES/POSTGRES_FILES/
355
- # AUTH_FILES, this is a post-write edit rather than a verbatim copy --
356
- # the alternative (a second, fuller load.rb template per flag
357
- # combination) would duplicate the whole file for a few lines of diff.
358
- def wire_load!
359
- path = File.join(@dir, "config/load.rb")
360
- settings_require = %(require_relative "settings"\n)
361
- requires = +""
362
- if @postgres
363
- target = @auth ? "auth" : "persistence" # config/auth.rb itself require_relative "persistence"
364
- requires << "require_relative \"#{target}\"\n"
365
- end
366
- # config/mail.rb from config/load.rb only, never from config/auth.rb:
367
- # bin/websocket_server loads config/auth.rb too and never sends mail,
368
- # so it shouldn't need MAIL_URL to boot.
369
- requires << "require_relative \"mail\"\n" if @mail
370
- requires << "require_relative \"jobs\"\n" if @jobs
371
- requires << "require_relative \"live\"\n" if @live
372
-
373
- content = File.read(path)
374
- raise "config/load.rb wiring failed: #{settings_require.inspect} not found" unless content.include?(settings_require)
375
-
376
- File.write(path, content.sub(settings_require, "#{settings_require}#{requires}"))
377
- end
378
-
379
- def add_jobs_route!
380
- path = File.join(@dir, "app/app.rb")
381
- content = File.read(path)
382
- raise "app/app.rb wiring failed: #{JOBS_ROUTE_ANCHOR.inspect} not found" unless content.include?(JOBS_ROUTE_ANCHOR)
383
-
384
- File.write(path, content.sub(JOBS_ROUTE_ANCHOR, "#{JOBS_ROUTE_ANCHOR}#{JOBS_ROUTE}"))
385
- end
386
-
387
- def add_deliver_later!
388
- path = File.join(@dir, "config/jobs.rb")
389
- content = File.read(path)
390
- raise "config/jobs.rb wiring failed: #{JOBS_REQUIRE_ANCHOR.inspect} not found" unless content.include?(JOBS_REQUIRE_ANCHOR)
391
-
392
- File.write(path, content.sub(JOBS_REQUIRE_ANCHOR, "#{JOBS_REQUIRE_ANCHOR}#{JOBS_MAIL_REQUIRE}"))
393
- end
394
-
395
- # .env/.env.test/.env.example are the other deliberate exception to
396
- # "templates are static files, copied verbatim" (see the class comment
397
- # above): DB_NAME needs this app's own directory name in it -- the one
398
- # piece of scaffold-wide content that's actually per-project -- so
399
- # these are composed here instead of read from disk. .env/.env.test are
400
- # gitignored (see base/.gitignore); .env.example is the tracked
401
- # placeholder that file explicitly carves out.
402
- def write_env_files!
403
- app_name = File.basename(@dir)
404
-
405
- dev = @postgres ? pg_env_lines("#{app_name}_development") : []
406
- test = @postgres ? pg_env_lines("#{app_name}_test") : []
407
- example = @postgres ? pg_env_lines("#{app_name}_development") : []
408
-
409
- if @auth
410
- dev << "AUTH_SECRET=change-me-dev-secret"
411
- test << "AUTH_SECRET=change-me-test-secret"
412
- example << "AUTH_SECRET=change-me"
413
- end
414
-
415
- if @mail
416
- # MAIL_URL is left out of .env on purpose: unset in development
417
- # means log://, the message printed to the console. Tests boot
418
- # outside development, where an unset one raises, hence log:// there.
419
- dev << %(MAIL_FROM="#{app_name} <no-reply@localhost>")
420
- test << "MAIL_URL=log://"
421
- test << %(MAIL_FROM="#{app_name} <no-reply@localhost>")
422
- example << "MAIL_URL=smtp://user:password@smtp.example.com:587"
423
- example << %(MAIL_FROM="#{app_name} <no-reply@example.com>")
424
- end
425
-
426
- # bin/jobs's settings. Not in .env.test: tests run jobs with
427
- # Monk::Jobs.drain!, never a job process.
428
- # With mail, the mailers queue is served first, so a backlog of other
429
- # work never delays a login email (docs/adr/0014).
430
- if @jobs
431
- queues = @mail ? "mailers,default" : "default"
432
- [dev, example].each { |lines| lines.push("JOBS_WORKERS=2", "JOBS_QUEUES=#{queues}") }
433
- end
434
-
435
- # REDIS_URL is absent from .env.test for --redis alone -- only a test
436
- # that actually exercises RedisFanout needs it. --live --redis is
437
- # different: config/live.rb raises without it, and the test helper
438
- # loads config/live.rb through config/load.rb. Building the fanout
439
- # connects to nothing, so tests still need no Redis running unless
440
- # one publishes.
441
- if @redis
442
- dev << "REDIS_URL=redis://localhost:6379/0"
443
- example << "REDIS_URL=redis://localhost:6379/0"
444
- test << "REDIS_URL=redis://localhost:6379/0" if live_over_redis?
445
- end
446
-
447
- write_lines(".env", dev)
448
- write_lines(".env.example", example)
449
- # --redis alone (no --postgres) has nothing to put in .env.test --
450
- # skip it rather than write an empty, pointless file.
451
- write_lines(".env.test", test) unless test.empty?
452
- end
453
-
454
- def pg_env_lines(dbname)
455
- [
456
- "DB_HOST=127.0.0.1",
457
- "DB_PORT=5432",
458
- "DB_USER=postgres",
459
- "DB_PASSWORD=postgres",
460
- "DB_NAME=#{dbname}",
461
- ]
462
- end
463
-
464
- def write_lines(relative_path, lines)
465
- File.write(File.join(@dir, relative_path), "#{lines.join("\n")}\n")
466
- end
467
-
468
- def write_setup_md!
469
- File.write(File.join(@dir, "SETUP.md"), setup_md_content)
470
- File.write(File.join(@dir, "SETUP.md"), live_setup_md_content, mode: "a") if @live
471
- end
472
-
473
- def live_setup_md_content
474
- @live_transport == :postgres ? live_setup_md_content_postgres : live_setup_md_content_redis
475
- end
476
-
477
- def live_setup_md_content_redis
478
- <<~MARKDOWN
479
-
480
- ## Live updates (Monk::Live)
481
-
482
- `monk new --live --redis` wired a demo: a counter whose open tabs
483
- update by themselves. It needs three things running, each in its own
484
- terminal:
485
-
486
- ```bash
487
- docker run --rm -d -p 6379:6379 --name #{File.basename(@dir)}_redis redis:7 # skip if one is already up
488
- bin/server # the HTTP app on :9292, which publishes updates
489
- bin/websocket_server # the sockets on :9293, which deliver them
490
- ```
491
-
492
- Open http://localhost:9292 in two tabs and press the button in one.
493
-
494
- `bin/server` and `bin/websocket_server` are separate processes, so Redis
495
- (`REDIS_URL`, already in `.env`) is what carries an update from one to
496
- the other. Where things are:
497
-
498
- - `config/live.rb` -- the Redis wiring (`Monk::WebSocket::RedisFanout`)
499
- and the subscribe rules (nothing is allowed unless a rule says so).
500
- - `app/app.rb` -- `POST /hit` changes state and calls `Monk::Live.patch`.
501
- - `app/views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
502
- - `app/views/live/_hits.erb` -- the fragment that gets pushed. Partials
503
- used this way see only their locals (`locals[:hits]`), never
504
- `params` or the session.
505
- - `bin/websocket_server` -- calls `Monk::Live.listen!` at boot: the
506
- only process that listens for updates. `bin/server`#{@jobs ? " and `bin/jobs`" : ""} only
507
- publish.
508
- - `public/js/monk_live/` -- the browser runtime, copied from the gem.
509
- The layout points at it and at `LIVE_WS_URL` (default
510
- `ws://localhost:9293`; use `wss://` in production).
511
-
512
- `WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
513
- origin your pages are served from, or the browser's socket is refused.
514
- #{live_auth_setup_paragraph}
515
- MARKDOWN
516
- end
517
-
518
- def live_setup_md_content_postgres
519
- <<~MARKDOWN
520
-
521
- ## Live updates (Monk::Live)
522
-
523
- `monk new --live --postgres` wired a demo: a counter whose open tabs
524
- update by themselves. It needs two things running, each in its own
525
- terminal -- no Redis, since Postgres `LISTEN`/`NOTIFY` carries the
526
- update instead:
527
-
528
- ```bash
529
- bin/server # the HTTP app on :9292, which publishes updates
530
- bin/websocket_server # the sockets on :9293, which deliver them
531
- ```
532
-
533
- Open http://localhost:9292 in two tabs and press the button in one.
534
-
535
- `bin/server` and `bin/websocket_server` are separate processes, so
536
- Postgres `LISTEN`/`NOTIFY` (the same `DB_*` settings already in `.env`)
537
- is what carries an update from one to the other. Where things are:
538
-
539
- - `config/live.rb` -- the Postgres wiring (`Monk::WebSocket::PgFanout`)
540
- and the subscribe rules (nothing is allowed unless a rule says so).
541
- - `app/app.rb` -- `POST /hit` changes state and calls `Monk::Live.patch`.
542
- - `app/views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
543
- - `app/views/live/_hits.erb` -- the fragment that gets pushed. Partials
544
- used this way see only their locals (`locals[:hits]`), never
545
- `params` or the session.
546
- - `bin/websocket_server` -- calls `Monk::Live.listen!` at boot: the
547
- only process that listens for updates. `bin/server`#{@jobs ? " and `bin/jobs`" : ""} only
548
- publish.
549
- - `public/js/monk_live/` -- the browser runtime, copied from the gem.
550
- The layout points at it and at `LIVE_WS_URL` (default
551
- `ws://localhost:9293`; use `wss://` in production).
552
-
553
- `WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
554
- origin your pages are served from, or the browser's socket is refused.
555
- #{live_auth_setup_paragraph}
556
- A single broadcast is capped at just under 8000 bytes
557
- (`Monk::WebSocket::PgFanout::MAX_NOTIFY_PAYLOAD_BYTES`, Postgres's own
558
- `NOTIFY` limit) -- far more than this demo ever sends, but worth
559
- knowing before pushing much larger partials this way. Regenerate with
560
- `--redis` instead of `--postgres` if that becomes a real constraint.
561
- MARKDOWN
562
- end
563
-
564
- def setup_md_content
565
- @postgres ? postgres_setup_md_content : base_setup_md_content
566
- end
567
-
568
- # No Postgres, so no database/containers/migrations -- but still a
569
- # real dev-then-test walkthrough: bin/server needs nothing external,
570
- # and "no test framework scaffolded" is just as true here as it is
571
- # with --postgres.
572
- def base_setup_md_content
573
- app_name = File.basename(@dir)
574
-
575
- <<~MARKDOWN
576
- # Setting up #{app_name} (dev, then test)
577
-
578
- #{base_setup_md_intro(app_name)}
579
-
580
- ## Dev environment, first run
581
-
582
- ```bash
583
- bundle install
584
- bin/server # HTTP app on :9292 -> http://localhost:9292/hello
585
- bin/websocket_server # WS chat process on :9293, in another terminal
586
- ```
587
- #{redis_only_note}#{mail_setup_note}
588
- ## Test environment
589
-
590
- `monk new` scaffolds no test framework at all -- this is the minimum to
591
- get `bundle exec rake test` working, using Minitest (matches `monk`'s
592
- own suite):
593
-
594
- **Gemfile** -- add:
595
-
596
- ```ruby
597
- group :test do
598
- gem "minitest"
599
- gem "rake"
600
- end
601
- ```
602
-
603
- **test/test_helper.rb**:
604
-
605
- ```ruby
606
- $LOAD_PATH.unshift(File.expand_path("..", __dir__))
607
-
608
- ENV["MONK_ENV"] ||= "test"
609
- #{base_test_dotenv_lines}
610
- #{test_helper_app_lines}
611
- ```
612
-
613
- **Rakefile**:
614
-
615
- ```ruby
616
- require "rake/testtask"
617
-
618
- Rake::TestTask.new do |t|
619
- t.libs << "test"
620
- t.pattern = "test/**/*_test.rb"
621
- end
622
-
623
- task default: :test
624
- ```
625
-
626
- #{app_test_sample}
627
-
628
- Then:
629
-
630
- ```bash
631
- bundle install
632
- bundle exec rake test
633
- ```
634
- MARKDOWN
635
- end
636
-
637
- def base_setup_md_intro(app_name)
638
- unless @redis
639
- return "No external services are scaffolded here (`monk new #{app_name}`, no " \
640
- "`--postgres`/`--auth`/`--redis`) -- `bin/server`/`bin/websocket_server` need " \
641
- "nothing external to run."
642
- end
643
-
644
- "No database is scaffolded here (`monk new #{app_name} --redis`, no `--postgres`/" \
645
- "`--auth`) -- `bin/server` needs nothing external, but `bin/websocket_server`'s " \
646
- "cross-process fan-out needs a reachable Redis once `REDIS_URL` (already set in " \
647
- "the generated `.env`) is visible to it."
648
- end
649
-
650
- def redis_only_note
651
- return "" unless @redis
652
-
653
- app_name = File.basename(@dir)
654
- "\n`.env` already sets `REDIS_URL=redis://localhost:6379/0`, turning on " \
655
- "`bin/websocket_server`'s cross-process fan-out -- unset (or missing entirely), it " \
656
- "runs in-process only. Check `docker ps` first in case a Redis container is already " \
657
- "running from another project; otherwise start one: `docker run --rm -d -p 6379:6379 " \
658
- "--name #{app_name}_redis redis:7`.\n"
659
- end
660
-
661
- def postgres_setup_md_content
662
- app_name = File.basename(@dir)
663
-
664
- <<~MARKDOWN
665
- # Setting up #{app_name} (dev, then test)
666
-
667
- `config/load.rb`, `.env`, and `.env.test` are already wired up by `monk new`
668
- -- `config/load.rb` requires `config/#{@auth ? "auth" : "persistence"}` (and every other config
669
- you enabled), then loads `app/`, and
670
- `.env`/`.env.test` are pre-filled with a database name derived from
671
- this project's directory (`#{app_name}_development` / `#{app_name}_test`), not the
672
- generic `app_development` fallback baked into `config/persistence.rb`
673
- itself. Adjust `DB_HOST`/`DB_USER`/`DB_PASSWORD` in both files if your
674
- local Postgres doesn't use the `postgres`/`postgres` defaults.
675
-
676
- `MONK_ENV` and the database name are two separate, unlinked knobs --
677
- setting `MONK_ENV=test` does not by itself change which database you
678
- connect to. Only `DB_NAME` (here, via `.env`/`.env.test`) does that.
679
-
680
- ## Dev environment, first run
681
-
682
- ```bash
683
- bundle install
684
- ```
685
-
686
- ### 1. Start Postgres#{@redis ? " and Redis" : ""}
687
-
688
- Check first whether a Postgres#{@redis ? "/Redis" : ""} container is already running from another
689
- project -- starting a second one bound to the same host port fails with
690
- `port is already allocated`:
691
-
692
- ```bash
693
- docker ps
694
- ```
695
-
696
- If something's already listening on 5432#{@redis ? "/6379" : ""}, reuse it instead of starting a new
697
- one -- point `.env`/`.env.test`'s `DB_HOST`/`DB_PORT`#{@redis ? "/`REDIS_URL`" : ""} at it, and use its
698
- actual `DB_USER`/`DB_PASSWORD` rather than the generated defaults.
699
- Otherwise, start #{@redis ? "fresh containers" : "a fresh container"}:
700
-
701
- ```bash
702
- docker run --rm -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres --name #{app_name}_pg postgres:16
703
- #{"docker run --rm -d -p 6379:6379 --name #{app_name}_redis redis:7\n" if @redis}```
704
-
705
- ### 2. Create the dev database
706
-
707
- Plain `createdb` connects over the local Unix socket by default, which a
708
- Docker container never provides -- use `-h`/`-p` to force a TCP
709
- connection, or run `createdb` from inside the container instead to avoid
710
- passing a password on the command line:
711
-
712
- ```bash
713
- # via TCP (adjust -h/-p/-U to match whatever's actually running):
714
- PGPASSWORD=postgres createdb -h 127.0.0.1 -p 5432 -U postgres #{app_name}_development
715
-
716
- # or, from inside an existing Postgres container, as the postgres user:
717
- docker exec -u postgres <container_name> createdb #{app_name}_development
718
- ```
719
-
720
- ### 3. Run migrations
721
-
722
- ```bash
723
- bin/setup_db
724
- ```
725
-
726
- ### 4. Run it
727
-
728
- ```bash
729
- bin/server # HTTP app on :9292
730
- bin/websocket_server # WS chat process on :9293, in another terminal
731
- #{"bin/jobs # background jobs (app/jobs/), in a third terminal\n" if @jobs}```
732
- #{auth_or_redis_confirmation_note}#{mail_setup_note}#{jobs_setup_note}
733
- ## Test environment
734
-
735
- ### 1. Create the test database
736
-
737
- ```bash
738
- PGPASSWORD=postgres createdb -h 127.0.0.1 -p 5432 -U postgres #{app_name}_test
739
-
740
- # or, from inside an existing Postgres container, as the postgres user:
741
- docker exec -u postgres <container_name> createdb #{app_name}_test
742
- ```
743
-
744
- ### 2. Migrate the test database
745
-
746
- ```bash
747
- DB_NAME=#{app_name}_test bin/setup_db
748
- ```
749
-
750
- ### 3. Add a test framework and run it
751
-
752
- `monk new` scaffolds no test framework at all -- this is the minimum to
753
- get `bundle exec rake test` working, using Minitest (matches `monk`'s
754
- own suite):
755
-
756
- **Gemfile** -- add:
757
-
758
- ```ruby
759
- group :test do
760
- gem "minitest"
761
- gem "rake"
762
- end
763
- ```
764
-
765
- **test/test_helper.rb** -- loads `.env.test` explicitly (not the default
766
- dotenv-in-`config/settings.rb` path, which only loads plain `.env`), then
767
- loads and boots the app the same way `config.ru` does:
768
-
769
- ```ruby
770
- $LOAD_PATH.unshift(File.expand_path("..", __dir__))
771
-
772
- ENV["MONK_ENV"] ||= "test"
773
-
774
- require "dotenv"
775
- Dotenv.load(File.expand_path(".env.test", __dir__ + "/.."))
776
-
777
- #{test_helper_app_lines}
778
- ```
779
-
780
- **Rakefile**:
781
-
782
- ```ruby
783
- require "rake/testtask"
784
-
785
- Rake::TestTask.new do |t|
786
- t.libs << "test"
787
- t.pattern = "test/**/*_test.rb"
788
- end
789
-
790
- task default: :test
791
- ```
792
-
793
- **test/persistence_test.rb** -- a real smoke test, not just a
794
- connectivity check:
795
-
796
- ```ruby
797
- require_relative "test_helper"
798
-
799
- class PersistenceTest < Minitest::Test
800
- #{sample_test_body}
801
- end
802
- ```
803
-
804
- #{app_test_sample}
805
- #{jobs_test_sample}
806
- Then:
807
-
808
- ```bash
809
- bundle install
810
- bundle exec rake test
811
- ```
812
-
813
- `test/test_helper.rb` sets `MONK_ENV=test` itself (so `Monk.env.test?`
814
- reads correctly if the app ever branches on it) and loads `.env.test` for
815
- the actual connection details -- but per the note at the top, those are
816
- two separate knobs: `MONK_ENV` doesn't affect `DB_NAME` on its own,
817
- `.env.test`'s `DB_NAME=#{app_name}_test` is what actually points tests at
818
- the right database.
819
- MARKDOWN
820
- end
821
-
822
- def auth_or_redis_confirmation_note
823
- return live_auth_confirmation_note if @live
824
- return "" unless @auth || @redis
825
-
826
- flags = [("authenticate: true" if @auth), ("redis fan-out: on" if @redis)].compact.join(", ")
827
- visible = if @auth && @redis
828
- "both `AUTH_SECRET` and `REDIS_URL` are"
829
- else
830
- @auth ? "`AUTH_SECRET` is" : "`REDIS_URL` is"
831
- end
832
-
833
- "\n`bin/websocket_server`'s startup line should print `#{flags}` once " \
834
- "#{visible} visible to it -- that confirms everything's actually wired up.\n"
835
- end
836
-
837
- # Who gets which topics once there are users. Interpolated into a
838
- # squiggly heredoc, so no indentation of its own.
839
- def live_auth_setup_paragraph
840
- return "" unless @auth
841
-
842
- <<~MARKDOWN
843
-
844
- **Visitors and logged-in users.** A logged-in user's socket carries
845
- their identity (the session cookie reaches `:9293` too); a visitor's
846
- comes in anonymous. Each topic's rule in `config/live.rb` decides:
847
- `anonymous: true` opens it to visitors (the demo's `hits`), and a
848
- rule without it is for logged-in users only, e.g. one topic per user.
849
- `monk new` writes no login routes: see `docs/guides/auth.md` in the
850
- monk repo, and `docs/guides/live.md`, "Who may subscribe".
851
- MARKDOWN
852
- end
853
-
854
- # The --live bin/websocket_server prints only `authenticate:`, and with
855
- # --auth it's :optional (a visitor's socket is anonymous, not refused).
856
- def live_auth_confirmation_note
857
- return "" unless @auth
858
-
859
- "\n`bin/websocket_server`'s startup line should print `authenticate: optional` once " \
860
- "`AUTH_SECRET` is visible to it: a logged-in user's socket carries their identity, and a " \
861
- "visitor's comes in anonymous, so it gets only the topics `config/live.rb` opens with " \
862
- "`anonymous: true`.\n"
863
- end
864
-
865
- # Interpolated into a squiggly heredoc, so no leading indentation of
866
- # its own (interpolated text isn't dedented).
867
- def mail_setup_note
868
- return "" unless @mail
869
-
870
- <<~MARKDOWN
871
-
872
- ### Email
873
-
874
- `config/mail.rb` configures `Monk::Mail`; send with
875
- `Monk::Mail.deliver(to:, subject:, text:, html:)`.#{auth_mail_sentence} In
876
- development `MAIL_URL` is unset, so messages are printed to the
877
- console instead of sent. Outside
878
- development set `MAIL_URL` (e.g. `smtp://user:pass@smtp.provider.com:587`)
879
- and `MAIL_FROM` to a sender on a domain you've verified with your
880
- provider -- an unset `MAIL_URL` fails the boot there. Provider
881
- examples: `docs/guides/mail.md` in the monk repo.
882
- MARKDOWN
883
- end
884
-
885
- # Interpolated into a squiggly heredoc, so no indentation of its own.
886
- def jobs_setup_note
887
- return "" unless @jobs
888
-
889
- <<~MARKDOWN
890
-
891
- ### Background jobs
892
-
893
- `config/jobs.rb` configures `Monk::Jobs` on the app's own database --
894
- its tables come from `db/migrate/00000000000002_create_jobs_tables`,
895
- which `bin/setup_db` already applied. The job classes live in
896
- `app/jobs/`, which `config/load.rb` loads. With `bin/server` and
897
- `bin/jobs` both running, enqueue the demo job and watch it run:
898
-
899
- ```bash
900
- curl -X POST "http://localhost:9292/jobs/hello?name=Ann"
901
- tail -f log/development.log # ... INFO Hello, Ann, from a background job
902
- ```
903
-
904
- `JOBS_WORKERS` and `JOBS_QUEUES` (in `.env`) set how many worker Ractors
905
- `bin/jobs` runs and which queues they take jobs from, in order. A job
906
- may run more than once (its process was killed mid-job, say), so make
907
- each one safe to repeat. `TERM` or Ctrl-C lets the jobs in hand finish
908
- before `bin/jobs` exits.#{jobs_mail_note}#{jobs_auth_note}
909
- MARKDOWN
910
- end
911
-
912
- # Interpolated into a squiggly heredoc, so no indentation of its own.
913
- def jobs_mail_note
914
- return "" unless @mail
915
-
916
- <<~MARKDOWN.chomp
917
-
918
- To send an email from a job instead of the request, use
919
- `Monk::Mail.deliver_later` -- the same arguments as `deliver` (render
920
- HTML first with `Monk::Mail.render`), plus `wait:`/`at:`, or `conn:`
921
- to enqueue inside your own transaction. It goes on the `mailers`
922
- queue, which `JOBS_QUEUES` serves first. Temporary failures are
923
- retried; a refusal the mail server made final is not.
924
- MARKDOWN
925
- end
926
-
927
- # Interpolated into a squiggly heredoc, so no indentation of its own.
928
- def jobs_auth_note
929
- return "" unless @auth
930
-
931
- <<~MARKDOWN.chomp
932
-
933
- Magic links go through a job too, `app/jobs/send_login_link.rb`: it
934
- creates the token and sends the link inside the job, so the raw
935
- token is never stored, not even in the queue. Your login route,
936
- after its own per-email rate limit, just enqueues it:
937
-
938
- ```ruby
939
- post("/auth/request") do
940
- SendLoginLink.enqueue(params[:email])
941
- json(sent: true)
942
- end
943
- ```
944
-
945
- Don't point `config/auth.rb`'s `deliver:` at `deliver_later`: that
946
- would store the link, token included, in the queue until it's sent.
947
- MARKDOWN
948
- end
949
-
950
- # Interpolated into a squiggly heredoc, so no indentation of its own.
951
- def jobs_test_sample
952
- return "" unless @jobs
953
-
954
- <<~MARKDOWN
955
-
956
- **test/jobs_test.rb** -- `Monk::Jobs.drain!` runs every enqueued job
957
- right in the test (each in its own Ractor, as `bin/jobs` would, so a
958
- job that only works outside one fails here too), and
959
- `Monk::Jobs.clear!` empties the queue between tests:
960
-
961
- ```ruby
962
- require_relative "test_helper"
963
-
964
- class JobsTest < Minitest::Test
965
- def teardown
966
- Monk::Jobs.clear!
967
- end
968
-
969
- def test_the_demo_job_runs
970
- HelloJob.enqueue("test")
971
-
972
- assert_equal 1, Monk::Jobs.drain!
973
- end
974
- end
975
- ```
976
- MARKDOWN
977
- end
978
-
979
- # The no-Postgres test helper normally has nothing to load from
980
- # .env.test -- but with --mail it has MAIL_URL=log://, which tests need
981
- # (they boot outside development, where an unset MAIL_URL raises), and
982
- # with --live --redis it has REDIS_URL, which config/live.rb needs.
983
- # Interpolated into a squiggly heredoc, so no indentation of its own.
984
- def base_test_dotenv_lines
985
- return "" unless @mail || live_over_redis?
986
-
987
- %(\nrequire "dotenv"\nDotenv.load(File.expand_path(".env.test", __dir__ + "/.."))\n)
988
- end
989
-
990
- def auth_mail_sentence
991
- return "" unless @auth
992
-
993
- " `AppMailer::MAGIC_LINK` (`app/mailers/app_mailer.rb`) sends each login link through it " \
994
- "(HTML part: `app/views/mail/magic_link.erb`)."
995
- end
996
-
997
- def sample_test_body
998
- if @auth
999
- <<~RUBY.chomp.gsub(/^/, " ")
1000
- def test_connects_to_the_test_database_and_sees_the_auth_tables
1001
- Monk::Persistence::Pg.checkout(:primary) do |conn|
1002
- result = conn.exec("SELECT to_regclass('login_tokens') IS NOT NULL AS present")
1003
- assert_equal true, result[0]["present"]
1004
- end
1005
- end
1006
- RUBY
1007
- else
1008
- <<~RUBY.chomp.gsub(/^/, " ")
1009
- def test_connects_to_the_test_database
1010
- Monk::Persistence::Pg.checkout(:primary) do |conn|
1011
- assert_equal 1, conn.exec("SELECT 1").getvalue(0, 0)
1012
- end
1013
- end
1014
- RUBY
1015
- end
1016
- end
1017
-
1018
- def live_over_redis? = @live && @live_transport == :redis
1019
-
1020
- # The end of every SETUP.md test helper: the app loaded the way
1021
- # config.ru loads it (config/load.rb, then app/app.rb), booted once.
1022
- # Interpolated into a squiggly heredoc, so no indentation of its own.
1023
- def test_helper_app_lines
1024
- <<~RUBY.chomp
1025
- require "minitest/autorun"
1026
- require_relative "../config/load"
1027
- require_relative "../app/app"
1028
-
1029
- # Booted once, as config.ru does: request tests call APP.
1030
- APP = Monk.boot(App)
1031
- RUBY
1032
- end
1033
-
1034
- # A request through the booted app. Every generated app, --live's
1035
- # included, has GET /hello.
1036
- def app_test_sample
1037
- <<~MARKDOWN.chomp
1038
- **test/app_test.rb** -- a request through the booted app:
1039
-
1040
- ```ruby
1041
- require_relative "test_helper"
1042
- require "rack/mock_request"
1043
-
1044
- class AppTest < Minitest::Test
1045
- def test_hello
1046
- status, _headers, body = APP.call(Rack::MockRequest.env_for("/hello"))
1047
-
1048
- assert_equal 200, status
1049
- assert_equal "hello from monk", body.join
1050
- end
1051
- end
1052
- ```
1053
- MARKDOWN
1054
- end
1055
-
1056
- def write_file(relative_path, template_path, executable: false)
1057
- destination = File.join(@dir, relative_path)
1058
- FileUtils.mkdir_p(File.dirname(destination))
1059
- FileUtils.cp(File.join(TEMPLATES_DIR, template_path), destination)
1060
- File.chmod(0o755, destination) if executable
1061
- end
1062
- end
1063
- end