monkrb 0.20.0 → 0.21.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -0
- data/README.md +7 -6
- data/exe/monk +3 -181
- data/lib/monk/assets.rb +39 -9
- data/lib/monk/auth/errors.rb +3 -0
- data/lib/monk/auth/helpers.rb +11 -0
- data/lib/monk/auth/send_login_link.rb +36 -0
- data/lib/monk/auth.rb +34 -5
- data/lib/monk/bin.rb +107 -0
- data/lib/monk/cli.rb +277 -0
- data/lib/monk/context.rb +17 -0
- data/lib/monk/errors.rb +0 -6
- data/lib/monk/generator/definition.rb +153 -0
- data/lib/monk/generator/result.rb +279 -0
- data/lib/monk/generator/run.rb +449 -0
- data/lib/monk/generator.rb +48 -0
- data/lib/monk/generators/auth.rb +29 -0
- data/lib/monk/generators/base.rb +19 -0
- data/lib/monk/generators/jobs.rb +21 -0
- data/lib/monk/generators/live.rb +26 -0
- data/lib/monk/generators/mail.rb +24 -0
- data/lib/monk/generators/postgres.rb +23 -0
- data/lib/monk/generators/redis.rb +20 -0
- data/lib/monk/generators/websocket.rb +24 -0
- data/lib/monk/generators.rb +7 -0
- data/lib/monk/jobs.rb +7 -0
- data/lib/monk/live/helpers.rb +13 -0
- data/lib/monk/live.rb +12 -1
- data/lib/monk/log.rb +10 -1
- data/lib/monk/mail/deliver_later.rb +54 -0
- data/lib/monk/mail/later.rb +3 -51
- data/lib/monk/mail.rb +4 -0
- data/lib/monk/templates/auth/agents.md +29 -0
- data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
- data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
- data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
- data/lib/monk/templates/auth/config/auth.rb +8 -8
- data/lib/monk/templates/auth/setup.md +37 -0
- data/lib/monk/templates/auth/test/auth_test.rb +17 -0
- data/lib/monk/templates/base/CLAUDE.md +1 -0
- data/lib/monk/templates/base/Dockerfile +10 -5
- data/lib/monk/templates/base/Gemfile +7 -2
- data/lib/monk/templates/base/Rakefile +8 -0
- data/lib/monk/templates/base/agents.md +41 -0
- data/lib/monk/templates/base/app/app.rb +5 -0
- data/lib/monk/templates/base/app/routes/.keep +0 -0
- data/lib/monk/templates/base/app/views/index.erb +1 -1
- data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
- data/lib/monk/templates/base/config/load.rb +14 -0
- data/lib/monk/templates/base/config/settings.rb +4 -5
- data/lib/monk/templates/base/setup.md +22 -0
- data/lib/monk/templates/base/test/app_test.rb +12 -0
- data/lib/monk/templates/base/test/test_helper.rb +18 -0
- data/lib/monk/templates/jobs/agents.md +25 -0
- data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
- data/lib/monk/templates/jobs/bin/jobs +19 -13
- data/lib/monk/templates/jobs/setup.md +30 -0
- data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
- data/lib/monk/templates/live/agents.md +25 -0
- data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
- data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
- data/lib/monk/templates/live/app/routes/live.rb +10 -0
- data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
- data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
- data/lib/monk/templates/live/config/live.rb +30 -32
- data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
- data/lib/monk/templates/live/setup.md +39 -0
- data/lib/monk/templates/live/test/live_test.rb +22 -0
- data/lib/monk/templates/mail/agents.md +22 -0
- data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
- data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
- data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
- data/lib/monk/templates/mail/config/mail.rb +4 -4
- data/lib/monk/templates/mail/setup.md +32 -0
- data/lib/monk/templates/mail/test/mail_test.rb +13 -0
- data/lib/monk/templates/postgres/agents.md +25 -0
- data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
- data/lib/monk/templates/postgres/bin/console +3 -1
- data/lib/monk/templates/postgres/bin/migrate +23 -17
- data/lib/monk/templates/postgres/bin/setup_db +11 -5
- data/lib/monk/templates/postgres/config/persistence.rb +1 -0
- data/lib/monk/templates/postgres/setup.md +38 -0
- data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
- data/lib/monk/templates/redis/agents.md +18 -0
- data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
- data/lib/monk/templates/redis/config/redis.rb +13 -0
- data/lib/monk/templates/redis/setup.md +20 -0
- data/lib/monk/templates/redis/test/redis_test.rb +12 -0
- data/lib/monk/templates/websocket/agents.md +28 -0
- data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
- data/lib/monk/templates/websocket/bin/websocket_server +62 -0
- data/lib/monk/templates/websocket/config/websocket_postgres.rb +38 -0
- data/lib/monk/templates/websocket/config/websocket_redis.rb +26 -0
- data/lib/monk/templates/websocket/setup.md +54 -0
- data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/server.rb +3 -1
- metadata +81 -14
- data/lib/monk/scaffold.rb +0 -1063
- data/lib/monk/templates/base/bin/websocket_server +0 -77
- data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
- data/lib/monk/templates/live/app/app.rb +0 -22
- data/lib/monk/templates/live/app/views/index.erb +0 -18
- data/lib/monk/templates/live/bin/websocket_server +0 -50
- data/lib/monk/templates/live/config/live_pg.rb +0 -60
- data/lib/monk/templates/mail/Gemfile.extra +0 -1
- data/lib/monk/templates/postgres/Dockerfile +0 -32
- data/lib/monk/templates/postgres/Gemfile.extra +0 -2
- data/lib/monk/templates/redis/Gemfile.extra +0 -1
- /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
|