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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -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 +10 -5
  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 +14 -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 +28 -0
  91. data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
  92. data/lib/monk/templates/websocket/bin/websocket_server +62 -0
  93. data/lib/monk/templates/websocket/config/websocket_postgres.rb +38 -0
  94. data/lib/monk/templates/websocket/config/websocket_redis.rb +26 -0
  95. data/lib/monk/templates/websocket/setup.md +54 -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
@@ -0,0 +1,449 @@
1
+ require "fileutils"
2
+
3
+ module Monk
4
+ module Generator
5
+ # One `monk add`: works out every module to add (dependencies first,
6
+ # options inferred or missing), checks every file against the app,
7
+ # and only then writes -- or writes nothing, on a usage error, a missing
8
+ # choice, a conflict on a wiring file, or a dry run
9
+ # (docs/history/plan-scaffold.md decisions 2-5, 25-28).
10
+ class Run
11
+ # A choice the user has to make before anything can be planned.
12
+ MissingChoice = Class.new(StandardError) do
13
+ attr_reader :choice
14
+
15
+ def initialize(choice)
16
+ @choice = choice
17
+ super(choice[:message])
18
+ end
19
+ end
20
+
21
+ UsageError = Class.new(StandardError) do
22
+ attr_reader :suggestion
23
+
24
+ def initialize(message, suggestion: nil)
25
+ @suggestion = suggestion
26
+ super(message)
27
+ end
28
+ end
29
+
30
+ def initialize(registry:, dir:, requested:, options: {}, dry_run: false, now: Time.now, command: nil)
31
+ @registry = registry
32
+ @dir = File.expand_path(dir)
33
+ @requested = requested.map(&:to_sym)
34
+ @options = options.transform_keys(&:to_sym).transform_values(&:to_s)
35
+ @dry_run = dry_run
36
+ @now = now
37
+ @command = command || "monk add #{@requested.join(" ")}"
38
+ end
39
+
40
+ def call
41
+ validate!
42
+ @installed = @registry.definitions.select { |definition| installed?(definition) }.map(&:name)
43
+ @plan = resolve
44
+ @files = Files.new(@dir)
45
+ @migration_count = 0
46
+ entries = @plan.flat_map { |definition, options| entries_for(definition, options) }
47
+ conflicts = entries.select { |entry| entry[:action] == :conflict && entry[:role] == :wiring }
48
+ return conflict_result(conflicts) unless conflicts.empty?
49
+
50
+ result(entries)
51
+ rescue UsageError => e
52
+ error_result(:usage_error, e.message, suggestion: e.suggestion)
53
+ rescue MissingChoice => e
54
+ error_result(:missing_choice, e.message, choice: e.choice.except(:message))
55
+ end
56
+
57
+ private
58
+
59
+ def validate!
60
+ raise UsageError.new("Nothing to add.", suggestion: "monk add --list") if @requested.empty?
61
+
62
+ unknown = @requested - @registry.names
63
+ unless unknown.empty?
64
+ raise UsageError.new(
65
+ "Unknown module#{"s" if unknown.size > 1}: #{unknown.join(", ")}. Modules: #{@registry.names.join(", ")}.",
66
+ suggestion: "monk add --list",
67
+ )
68
+ end
69
+
70
+ @registry.definitions.flat_map(&:options).each do |option|
71
+ value = @options[option.name]
72
+ next if value.nil? || option.values.include?(value)
73
+
74
+ raise UsageError, "--#{option.name}=#{value} isn't one of #{option.values.join(", ")}."
75
+ end
76
+ end
77
+
78
+ def installed?(definition)
79
+ File.exist?(File.join(@dir, definition.installed_if))
80
+ end
81
+
82
+ # [definition, options] for every module to add, dependencies first.
83
+ # Options are inferred from the modules installed or being added in
84
+ # this same command (decision 13), so `--with jobs,live` picks
85
+ # Postgres for websocket's transport.
86
+ def resolve
87
+ available = (@installed + static_closure).map(&:to_s)
88
+ plan = []
89
+ visiting = []
90
+ visit = lambda do |name, needed_by|
91
+ @needed_by[name] |= [needed_by] if needed_by
92
+ return if @installed.include?(name) || plan.any? { |definition, _| definition.name == name }
93
+ raise UsageError, "Modules depend on each other in a cycle: #{(visiting + [name]).join(" -> ")}." if
94
+ visiting.include?(name)
95
+
96
+ definition = @registry.fetch(name)
97
+ options = resolve_options(definition, available)
98
+ visiting.push(name)
99
+ definition.dependencies(options).each { |dependency| visit.call(dependency, name) }
100
+ visiting.pop
101
+ plan << [definition, options]
102
+ end
103
+ @needed_by = Hash.new { |hash, key| hash[key] = [] }
104
+ @requested.each { |name| visit.call(name, nil) }
105
+ plan
106
+ end
107
+
108
+ def static_closure
109
+ seen = []
110
+ queue = @requested.dup
111
+ until queue.empty?
112
+ name = queue.shift
113
+ next if seen.include?(name)
114
+
115
+ seen << name
116
+ queue.concat(@registry.fetch(name).dependencies)
117
+ end
118
+ seen
119
+ end
120
+
121
+ def resolve_options(definition, available)
122
+ definition.options.to_h do |option|
123
+ given = @options[option.name] || option.default
124
+ next [option.name, given] if given
125
+
126
+ candidates = option.infer_from & available
127
+ next [option.name, candidates.first] if candidates.size == 1
128
+
129
+ raise MissingChoice, missing_choice(definition, option, candidates)
130
+ end
131
+ end
132
+
133
+ def missing_choice(definition, option, candidates)
134
+ reason = if candidates.empty?
135
+ "this app has none of #{option.infer_from.join(", ")}"
136
+ else
137
+ "this app has both #{candidates.join(" and ")}"
138
+ end
139
+ {
140
+ module: definition.name.to_s, option: option.name.to_s, values: option.values, reason: reason,
141
+ describe: option.describe, command: @command,
142
+ message: "#{definition.name} needs a #{option.name}, and #{reason}.",
143
+ }
144
+ end
145
+
146
+ def entries_for(definition, options)
147
+ copies = definition.copies.select { |copy| copy.condition.nil? || copy.condition.call(options) }
148
+ entries = copies.map { |copy| copy_entry(definition, copy, options) }
149
+ entries.concat(definition.migrations.flat_map { |migration| migration_entries(definition, migration) })
150
+ entries.concat(gem_entries(definition))
151
+ entries.concat(env_entries(definition))
152
+ entries.concat(ignore_entries(definition))
153
+ entries.compact
154
+ end
155
+
156
+ def copy_entry(definition, copy, options)
157
+ from = copy.from.respond_to?(:call) ? copy.from.call(options) : copy.from
158
+ content = File.binread(template(from || "#{definition.name}/#{copy.path}"))
159
+ entry = { path: copy.path, module: definition.name, role: copy.role }
160
+
161
+ case @files.compare(copy.path, content)
162
+ when :missing
163
+ @files.create(copy.path, content, executable: copy.executable)
164
+ entry.merge(action: :created, examples: example_tags(content))
165
+ when :same then nil
166
+ else entry.merge(action: :conflict)
167
+ end
168
+ end
169
+
170
+ def migration_entries(definition, migration)
171
+ target_dir = "db/migrate"
172
+ return [] unless @files.glob("#{target_dir}/*_#{migration.name}.up.sql").empty?
173
+
174
+ stamp = (@now + @migration_count).strftime("%Y%m%d%H%M%S")
175
+ @migration_count += 1
176
+ %w[up down].map do |direction|
177
+ pattern = File.join(@registry.templates_dir, definition.name.to_s, target_dir,
178
+ "*_#{migration.name}.#{direction}.sql",)
179
+ source = Dir[pattern].first || raise(ArgumentError, "no template migration matches #{pattern}")
180
+ path = "#{target_dir}/#{stamp}_#{migration.name}.#{direction}.sql"
181
+ @files.create(path, File.binread(source))
182
+ { path: path, module: definition.name, role: :wiring, action: :created, examples: [] }
183
+ end
184
+ end
185
+
186
+ def gem_entries(definition)
187
+ names = definition.gems.filter_map do |line|
188
+ name = line[/\Agem\s+["']([^"']+)/, 1]
189
+ next if @files.read("Gemfile").match?(/^\s*gem\s+["']#{Regexp.escape(name)}["']/)
190
+
191
+ @files.append("Gemfile", "#{line}\n")
192
+ name
193
+ end
194
+ names.empty? ? [] : [appended("Gemfile", definition, names)]
195
+ end
196
+
197
+ def env_entries(definition)
198
+ definition.envs.group_by(&:file).filter_map do |file, envs|
199
+ path = Definition::ENV_FILES.fetch(file)
200
+ keys = envs.filter_map do |env|
201
+ next if @files.read(path).match?(/^#?\s*#{Regexp.escape(env.key)}=/)
202
+
203
+ value = env.value.respond_to?(:call) ? env.value.call(app_name) : env.value
204
+ @files.append(path, "#{"# " if env.commented}#{env.key}=#{value}\n")
205
+ env.key
206
+ end
207
+ appended(path, definition, keys) unless keys.empty?
208
+ end
209
+ end
210
+
211
+ def ignore_entries(definition)
212
+ %w[.gitignore .dockerignore].filter_map do |path|
213
+ lines = definition.ignores.reject { |line| @files.read(path).lines.map(&:chomp).include?(line) }
214
+ lines.each { |line| @files.append(path, "#{line}\n") }
215
+ appended(path, definition, lines) unless lines.empty?
216
+ end
217
+ end
218
+
219
+ def appended(path, definition, items)
220
+ { path: path, module: definition.name, action: :appended, items: items }
221
+ end
222
+
223
+ # SETUP.md and AGENTS.md: each added module's section, after the
224
+ # others, marked so it's never added twice.
225
+ def section_entries
226
+ { setup: "SETUP.md", agents: "AGENTS.md" }.filter_map do |kind, path|
227
+ names = @plan.filter_map do |definition, _|
228
+ section = definition.sections[kind]
229
+ marker = "<!-- monk:module #{definition.name} -->"
230
+ next if section.nil? || @files.read(path).include?(marker)
231
+
232
+ body = File.read(template(section)).gsub("{{app}}", app_name)
233
+ @files.append(path, "#{"\n" unless @files.read(path).empty?}#{marker}\n#{body}")
234
+ definition.name
235
+ end
236
+ { path: path, action: :appended, items: names, sections: true } unless names.empty?
237
+ end
238
+ end
239
+
240
+ # [tag, line number] for each `# monk:example <tag>` in a file.
241
+ def example_tags(content)
242
+ content.each_line.with_index(1).filter_map do |line, number|
243
+ tag = line[/#\s*monk:example\s+(\S+)/, 1]
244
+ [tag, number] if tag
245
+ end
246
+ end
247
+
248
+ def result(entries)
249
+ entries += section_entries
250
+ @files.write! unless @dry_run
251
+ conflicts, done = entries.partition { |entry| entry[:action] == :conflict }
252
+ Result.new(
253
+ status: :ok, written: !@dry_run && !done.empty?, dry_run: @dry_run, requested: @requested,
254
+ modules: module_entries, files: done, skipped: conflicts.map { |entry| skipped(entry) },
255
+ env: production_entries, examples: examples(entries), services: services, next_steps: next_steps(entries),
256
+ docs: docs(entries), closing: closing,
257
+ command: @command,
258
+ )
259
+ end
260
+
261
+ def module_entries
262
+ added = @plan.map do |definition, options|
263
+ entry = { name: definition.name, action: :added }
264
+ needed_by = @needed_by[definition.name]
265
+ entry[:reason] = "needed by #{needed_by.join(", ")}" unless @requested.include?(definition.name)
266
+ entry[:options] = options unless options.empty?
267
+ entry
268
+ end
269
+ already = (@requested & @installed).map { |name| { name: name, action: :already_installed } }
270
+ needed = @plan.flat_map { |definition, options| definition.dependencies(options) }.uniq
271
+ dependencies_installed = ((needed & @installed) - @requested).map do |name|
272
+ { name: name, action: :already_installed, reason: "needed by #{needers(name).join(", ")}" }
273
+ end
274
+ already + dependencies_installed + added
275
+ end
276
+
277
+ def needers(name)
278
+ @plan.filter_map { |definition, options| definition.name if definition.dependencies(options).include?(name) }
279
+ end
280
+
281
+ def skipped(entry)
282
+ entry.slice(:path, :module, :role).merge(show: "monk help #{entry[:module]} --file #{entry[:path]}")
283
+ end
284
+
285
+ def production_entries
286
+ @plan.flat_map do |definition, _|
287
+ definition.productions.map do |production|
288
+ files = definition.envs.select { |env| env.key == production.key }.map { |env| Definition::ENV_FILES.fetch(env.file) }
289
+ { key: production.key, module: definition.name, files: files, placeholder: production.placeholder,
290
+ required_in: %w[staging production], note: production.note, }
291
+ end
292
+ end
293
+ end
294
+
295
+ def examples(entries)
296
+ all = (@installed + @plan.map { |definition, _| definition.name }).map(&:to_s)
297
+ entries.select { |entry| entry[:action] == :created }.flat_map do |entry|
298
+ definition = @registry.fetch(entry[:module])
299
+ entry[:examples].map do |tag, line|
300
+ todo = definition.examples.find { |example| example.tag == tag }&.todo
301
+ todo = todo.call(all) if todo.respond_to?(:call)
302
+ { tag: tag, path: entry[:path], line: line, module: entry[:module], todo: todo }.compact
303
+ end
304
+ end
305
+ end
306
+
307
+ def services
308
+ @plan.flat_map { |definition, _| definition.services }.uniq(&:name)
309
+ .map { |service| { name: service.name, check: service.check, setup: service.setup } }
310
+ end
311
+
312
+ # Every step, in the order they're done: bundle install when gems were
313
+ # added, what to do first (start a service), bin/setup_db when a
314
+ # migration was added, each module's own commands (bin/jobs), and the
315
+ # tests last.
316
+ def next_steps(entries)
317
+ all_services = (@installed.map { |name| @registry.fetch(name) } + @plan.map(&:first))
318
+ .flat_map(&:services).map(&:name).uniq
319
+ steps = []
320
+ gems_added = entries.any? { |entry| entry[:path] == "Gemfile" && %i[appended created].include?(entry[:action]) }
321
+ steps << { run: "bundle install" } if gems_added
322
+ own = @plan.flat_map do |definition, options|
323
+ definition.steps.select { |step| step.condition.nil? || step.condition.call(options) }
324
+ .map { |step| step_entry(step, definition.name) }
325
+ end
326
+ commands, todos = own.partition { |step| step.key?(:run) }
327
+ steps.concat(todos)
328
+ migrating = entries.select { |entry| entry[:path].end_with?(".up.sql") && entry[:action] == :created }
329
+ unless migrating.empty?
330
+ needed = services_of(migrating.map { |entry| entry[:module] }.uniq)
331
+ steps << { run: "bin/setup_db", needs_service: (needed.join(",") unless needed.empty?) }.compact
332
+ end
333
+ steps.concat(commands)
334
+ steps << { run: "bundle exec rake test",
335
+ needs_service: (all_services.join(",") unless all_services.empty?), }.compact
336
+ steps.uniq
337
+ end
338
+
339
+ # The services these modules need, theirs or their dependencies'.
340
+ def services_of(names)
341
+ seen = []
342
+ queue = names.dup
343
+ until queue.empty?
344
+ name = queue.shift
345
+ next if seen.include?(name)
346
+
347
+ seen << name
348
+ options = @plan.find { |definition, _| definition.name == name }&.last || {}
349
+ queue.concat(@registry.fetch(name).dependencies(options))
350
+ end
351
+ seen.flat_map { |name| @registry.fetch(name).services }.map(&:name).uniq
352
+ end
353
+
354
+ # The last added module's closing line that applies.
355
+ def closing
356
+ @plan.filter_map do |definition, options|
357
+ text = definition.closing
358
+ text.respond_to?(:call) ? text.call(options) : text
359
+ end.last
360
+ end
361
+
362
+ def step_entry(step, module_name)
363
+ key = step.text.match?(%r{\A(bin/|bundle |ruby |curl )}) ? :run : :do
364
+ { key => step.text, note: step.note, needs_service: step.needs_service, module: module_name }.compact
365
+ end
366
+
367
+ def docs(entries)
368
+ entries.select { |entry| entry[:sections] }.flat_map do |entry|
369
+ entry[:items].map { |name| "#{entry[:path]}##{name}" }
370
+ end
371
+ end
372
+
373
+ def conflict_result(conflicts)
374
+ first = conflicts.first
375
+ message = "#{first[:path]} exists and differs, and #{first[:module]} needs it to run."
376
+ message = "#{conflicts.size} files #{first[:module]} needs exist and differ." if conflicts.size > 1
377
+ Result.new(
378
+ status: :conflict, written: false, dry_run: @dry_run, requested: @requested, message: message,
379
+ conflicts: conflicts.map { |entry| skipped(entry) }, command: @command,
380
+ )
381
+ end
382
+
383
+ def error_result(status, message, suggestion: nil, choice: nil)
384
+ Result.new(status: status, written: false, dry_run: @dry_run, requested: @requested, message: message,
385
+ suggestion: suggestion, choice: choice, command: @command,)
386
+ end
387
+
388
+ def template(path)
389
+ File.join(@registry.templates_dir, path)
390
+ end
391
+
392
+ def app_name = File.basename(@dir)
393
+ end
394
+
395
+ # The app's files as the run will leave them: read from disk once, then
396
+ # changed in memory, and written only by write!.
397
+ class Files
398
+ def initialize(dir)
399
+ @dir = dir
400
+ @contents = {}
401
+ @changed = {}
402
+ @executable = []
403
+ end
404
+
405
+ def read(path)
406
+ @contents.fetch(path) do
407
+ full = File.join(@dir, path)
408
+ @contents[path] = File.exist?(full) ? File.binread(full) : +""
409
+ end
410
+ end
411
+
412
+ def compare(path, content)
413
+ return :missing unless exists?(path)
414
+
415
+ read(path) == content ? :same : :different
416
+ end
417
+
418
+ def exists?(path) = @changed.key?(path) || File.exist?(File.join(@dir, path))
419
+
420
+ def glob(pattern)
421
+ on_disk = Dir.glob(pattern, base: @dir)
422
+ on_disk + @changed.keys.select { |path| File.fnmatch?(pattern, path) }
423
+ end
424
+
425
+ def create(path, content, executable: false)
426
+ @contents[path] = content.dup
427
+ @changed[path] = true
428
+ @executable << path if executable
429
+ end
430
+
431
+ def append(path, text)
432
+ current = read(path)
433
+ current = "#{current}\n" unless current.empty? || current.end_with?("\n")
434
+ @contents[path] = current + text
435
+ @changed[path] = true
436
+ end
437
+
438
+ def write!
439
+ @changed.each_key do |path|
440
+ full = File.join(@dir, path)
441
+ FileUtils.mkdir_p(File.dirname(full))
442
+ File.binwrite(full, @contents.fetch(path))
443
+ File.chmod(0o755, full) if @executable.include?(path)
444
+ end
445
+ @changed.clear
446
+ end
447
+ end
448
+ end
449
+ end
@@ -0,0 +1,48 @@
1
+ require_relative "generator/definition"
2
+ require_relative "generator/run"
3
+ require_relative "generator/result"
4
+
5
+ module Monk
6
+ # `monk add`: one generator per module, run on any app, new or existing
7
+ # (docs/adr/0017, docs/history/plan-scaffold.md). A generator only creates files
8
+ # and appends lines; Run plans and checks everything before writing.
9
+ module Generator
10
+ TEMPLATES_DIR = File.expand_path("templates", __dir__)
11
+
12
+ # The modules `monk add` knows, by name. Monk's own live in
13
+ # lib/monk/generators/; tests build their own registry, over their own
14
+ # templates.
15
+ class Registry
16
+ attr_reader :templates_dir
17
+
18
+ def initialize(templates_dir:)
19
+ @templates_dir = templates_dir
20
+ @definitions = {}
21
+ end
22
+
23
+ def define(name, &)
24
+ definition = Definition.new(name)
25
+ definition.instance_eval(&)
26
+ @definitions[definition.name] = definition
27
+ end
28
+
29
+ def fetch(name) = @definitions.fetch(name.to_sym)
30
+ def names = @definitions.keys
31
+ def definitions = @definitions.values
32
+
33
+ # Adds `modules` (names) to the app in `dir`. Returns a Result, never
34
+ # raises for a usage error, a missing choice or a conflict.
35
+ def add(dir, modules, **)
36
+ Run.new(registry: self, dir: dir, requested: modules, **).call
37
+ end
38
+ end
39
+
40
+ class << self
41
+ def registry
42
+ @registry ||= Registry.new(templates_dir: TEMPLATES_DIR)
43
+ end
44
+
45
+ def define(name, &) = registry.define(name, &)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,29 @@
1
+ # Passwordless login by email link (Monk::Auth).
2
+ Monk::Generator.define(:auth) do
3
+ summary "Passwordless magic-link login"
4
+ installed_if "config/auth.rb"
5
+ depends_on :postgres, :mail
6
+
7
+ copy "config/auth.rb", "app/mailers/auth_mailer.rb", role: :wiring
8
+ copy "app/views/mail/magic_link.erb", "app/views/auth/login.erb", role: :view
9
+ copy "app/routes/auth.rb", role: :example
10
+ copy "test/auth_test.rb", role: :test
11
+ migration "create_auth_tables"
12
+
13
+ env :development, { "AUTH_SECRET" => "change-me-dev-secret" }
14
+ env :test, { "AUTH_SECRET" => "change-me-test-secret" }
15
+ env :example, { "AUTH_SECRET" => "change-me" }
16
+
17
+ setup_section "auth/setup.md"
18
+ agents_section "auth/agents.md"
19
+ set_before_production "AUTH_SECRET", "placeholder in .env — replace it", placeholder: true
20
+ example "auth-routes", todo: lambda { |installed|
21
+ if installed.include?("jobs")
22
+ "uncomment, keep the rate limit; send with Monk::Auth::SendLoginLink (jobs is installed)"
23
+ else
24
+ "uncomment, keep the rate limit, and list your redirects in config/auth.rb"
25
+ end
26
+ }
27
+ closing 'Then turn on the login routes: app/routes/auth.rb, block "auth-routes" ' \
28
+ "(keep the rate limit — AGENTS.md, auth, says why)."
29
+ end
@@ -0,0 +1,19 @@
1
+ # The bare app `monk new` writes: every other module is added on top of it.
2
+ Monk::Generator.define(:base) do
3
+ summary "The bare app: routes, views, static files, tests, SETUP.md and AGENTS.md"
4
+ installed_if "config/load.rb"
5
+
6
+ copy "Gemfile", "config.ru", "config/settings.rb", "config/load.rb", "app/app.rb", ".ruby-version",
7
+ ".gitignore", ".dockerignore", "Dockerfile", "public/css/app.css", "public/js/app.js", "Rakefile",
8
+ "CLAUDE.md", role: :wiring
9
+ copy "bin/server", role: :wiring, executable: true
10
+ copy "app/views/layouts/app.erb", "app/views/index.erb", role: :view
11
+ # Every role directory, empty, so the tree shows where each kind of code
12
+ # goes (docs/adr/0015), and app/routes/ for the routes modules add.
13
+ copy(*%w[models presenters helpers mailers broadcasts jobs routes].map { |role| "app/#{role}/.keep" }, role: :wiring)
14
+ copy "test/test_helper.rb", "test/app_test.rb", role: :test
15
+
16
+ setup_section "base/setup.md"
17
+ agents_section "base/agents.md"
18
+ next_step "bin/server", note: "http://localhost:9292"
19
+ end
@@ -0,0 +1,21 @@
1
+ # Background jobs on Postgres (Monk::Jobs), run by bin/jobs.
2
+ Monk::Generator.define(:jobs) do
3
+ summary "Background jobs on Postgres"
4
+ installed_if "config/jobs.rb"
5
+ depends_on :postgres
6
+
7
+ copy "config/jobs.rb", role: :wiring
8
+ copy "bin/jobs", role: :wiring, executable: true
9
+ copy "app/jobs/hello_job.rb", "app/routes/jobs.rb", role: :example
10
+ copy "test/jobs_test.rb", role: :test
11
+ migration "create_jobs_tables"
12
+
13
+ queues = { "JOBS_WORKERS" => "2", "JOBS_QUEUES" => "mailers,default" }
14
+ env :development, queues
15
+ env :example, queues
16
+
17
+ setup_section "jobs/setup.md"
18
+ agents_section "jobs/agents.md"
19
+ next_step "bin/jobs", note: "beside bin/server"
20
+ example "jobs-enqueue", todo: "uncomment, or enqueue your own jobs from any route"
21
+ end
@@ -0,0 +1,26 @@
1
+ # Server-pushed HTML updates (Monk::Live), over the app's WebSocket.
2
+ Monk::Generator.define(:live) do
3
+ summary "Server-pushed HTML updates"
4
+ installed_if "config/live.rb"
5
+ depends_on :websocket
6
+ option :demo, values: %w[on off], default: "on"
7
+
8
+ demo = ->(options) { options[:demo] == "on" }
9
+ copy "config/live.rb", role: :wiring,
10
+ from: ->(options) { demo.call(options) ? "live/config/live.rb" : "live/config/live_without_demo.rb" }
11
+ copy "app/broadcasts/greeting.rb", "app/routes/live.rb", role: :example
12
+ copy "app/views/live/_greeting.erb", role: :view
13
+ copy "app/routes/demo_live.rb", "app/views/demo/live.erb", "app/views/demo/_hits.erb", role: :demo, if: demo
14
+ copy "test/live_test.rb", role: :test
15
+
16
+ setup_section "live/setup.md"
17
+ agents_section "live/agents.md"
18
+ set_before_production "LIVE_WS_URL", "defaults to /ws under PUBLIC_URL (wss:// for https)"
19
+ next_step "open http://localhost:9292/demo/live in two tabs, press +1", if: demo
20
+ example "live-rule", todo: "uncomment and adapt: nothing can be subscribed to until a rule allows it"
21
+ example "live-broadcast", todo: "uncomment, add a rule for its topic, and put the live_topic element in a page"
22
+ closing lambda { |options|
23
+ "Remove the demo when done: app/routes/demo_live.rb, app/views/demo/, and its lines in config/live.rb." if
24
+ demo.call(options)
25
+ }
26
+ end
@@ -0,0 +1,24 @@
1
+ # Email over SMTP (Monk::Mail); printed to the console in development.
2
+ Monk::Generator.define(:mail) do
3
+ summary "Email over SMTP (log:// in development)"
4
+ installed_if "config/mail.rb"
5
+
6
+ copy "config/mail.rb", role: :wiring
7
+ copy "app/mailers/app_mailer.rb", "app/routes/mail.rb", role: :example
8
+ copy "app/views/mail/welcome.erb", role: :view
9
+ copy "test/mail_test.rb", role: :test
10
+
11
+ gem %(gem "net-smtp", "~> 0.5" # Monk::Mail's smtp:// transport)
12
+ env :development, { "MAIL_FROM" => ->(app) { %("#{app} <no-reply@localhost>") } }
13
+ env :test, { "MAIL_URL" => "log://", "MAIL_FROM" => ->(app) { %("#{app} <no-reply@localhost>") } }
14
+ env :example, { "MAIL_URL" => "smtp://user:password@smtp.example.com:587",
15
+ "MAIL_FROM" => ->(app) { %("#{app} <no-reply@example.com>") }, }
16
+
17
+ setup_section "mail/setup.md"
18
+ agents_section "mail/agents.md"
19
+ set_before_production "MAIL_URL", "unset = printed to the console in development"
20
+ set_before_production "MAIL_FROM", "a sender on a domain verified with your provider"
21
+ example "mail-welcome", todo: lambda { |installed|
22
+ "uncomment and adapt#{"; send it with deliver_later, jobs is installed" if installed.include?("jobs")}"
23
+ }
24
+ end
@@ -0,0 +1,23 @@
1
+ # Postgres persistence and migrations (Monk::Persistence::Pg).
2
+ Monk::Generator.define(:postgres) do
3
+ summary "Postgres persistence and migrations"
4
+ installed_if "config/persistence.rb"
5
+
6
+ copy "config/persistence.rb", "db/migrate/.keep", role: :wiring
7
+ copy "bin/console", "bin/setup_db", "bin/migrate", role: :wiring, executable: true
8
+ copy "app/routes/postgres.rb", role: :example
9
+ copy "test/persistence_test.rb", role: :test
10
+
11
+ gem %(gem "pg", "~> 1.6" # 1.6+ ships precompiled, libpq included: no system packages)
12
+ gem %(gem "irb")
13
+ database = { "DB_HOST" => "127.0.0.1", "DB_PORT" => "5432", "DB_USER" => "postgres", "DB_PASSWORD" => "postgres" }
14
+ env :development, database.merge("DB_NAME" => ->(app) { "#{app}_development" })
15
+ env :test, database.merge("DB_NAME" => ->(app) { "#{app}_test" })
16
+ env :example, database.merge("DB_NAME" => ->(app) { "#{app}_development" })
17
+
18
+ service :postgres, check: "pg_isready -h 127.0.0.1 -p 5432", setup: "SETUP.md#postgres"
19
+ next_step "start Postgres and create the databases (SETUP.md › postgres)"
20
+ setup_section "postgres/setup.md"
21
+ agents_section "postgres/agents.md"
22
+ example "postgres-model", todo: "uncomment, then move the table to a migration and the model to app/models/"
23
+ end
@@ -0,0 +1,20 @@
1
+ # The app's Redis connection setting: a cache, rate limits, locks, or
2
+ # WebSocket's transport.
3
+ Monk::Generator.define(:redis) do
4
+ summary "Redis connection setting, for caching or as a WebSocket transport"
5
+ installed_if "config/redis.rb"
6
+
7
+ copy "config/redis.rb", role: :wiring
8
+ copy "app/routes/redis.rb", role: :example
9
+ copy "test/redis_test.rb", role: :test
10
+
11
+ gem %(gem "redis", "~> 5.0")
12
+ %i[development test example].each { |file| env file, { "REDIS_URL" => "redis://localhost:6379/0" } }
13
+
14
+ service :redis, check: "redis-cli -p 6379 ping", setup: "SETUP.md#redis"
15
+ next_step "start Redis (SETUP.md › redis)"
16
+ setup_section "redis/setup.md"
17
+ agents_section "redis/agents.md"
18
+ set_before_production "REDIS_URL", "required outside development and test"
19
+ example "redis-cache", todo: "uncomment and adapt; open a client per request, and close it"
20
+ end