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
@@ -0,0 +1,279 @@
1
+ require "json"
2
+
3
+ module Monk
4
+ module Generator
5
+ # What one `monk add` did, or would do: printed as text for a person
6
+ # (decision 25), as one JSON object with --json (decision 27), and
7
+ # turned into the exit code (decision 28) -- all from the same data.
8
+ class Result
9
+ EXIT_CODES = { ok: 0, usage_error: 1, missing_choice: 2, conflict: 3 }.freeze
10
+
11
+ # The text form's widest path column.
12
+ COLUMN = 30
13
+ # The text form's numbered steps; JSON keeps them all.
14
+ MAX_STEPS = 3
15
+
16
+ attr_reader :status, :written, :dry_run, :requested, :modules, :files, :skipped, :env, :examples, :services,
17
+ :next_steps, :docs, :closing, :message, :suggestion, :choice, :conflicts, :command
18
+
19
+ def initialize(status:, written:, dry_run: false, requested: [], modules: [], files: [], skipped: [], env: [],
20
+ examples: [], services: [], next_steps: [], docs: [], closing: nil, message: nil, suggestion: nil,
21
+ choice: nil, conflicts: [], command: nil)
22
+ @status = status
23
+ @written = written
24
+ @dry_run = dry_run
25
+ @requested = requested
26
+ @modules = modules
27
+ @files = files
28
+ @skipped = skipped
29
+ @env = env
30
+ @examples = examples
31
+ @services = services
32
+ @next_steps = next_steps
33
+ @docs = docs
34
+ @closing = closing
35
+ @message = message
36
+ @suggestion = suggestion
37
+ @choice = choice
38
+ @conflicts = conflicts
39
+ @command = command
40
+ end
41
+
42
+ def ok? = status == :ok
43
+ def exit_code = EXIT_CODES.fetch(status)
44
+
45
+ def to_h
46
+ hash = { status: status.to_s, written: written, requested: requested.map(&:to_s) }
47
+ if ok?
48
+ hash.merge!(
49
+ modules: modules.map { |entry| stringify(entry) },
50
+ files: files.map { |entry| file_json(entry) },
51
+ skipped: skipped.map { |entry| stringify(entry) },
52
+ env: env.map { |entry| stringify(entry.except(:module)) },
53
+ examples: examples.map { |entry| stringify(entry.except(:module)) },
54
+ services: services.map { |entry| stringify(entry) },
55
+ next: next_steps.map { |entry| stringify(entry) },
56
+ docs: docs,
57
+ )
58
+ hash[:dry_run] = true if dry_run
59
+ else
60
+ hash[:message] = message
61
+ hash[:suggestion] = suggestion if suggestion
62
+ hash[:choice] = stringify(choice.except(:describe, :command)) if choice
63
+ hash[:conflicts] = conflicts.map { |entry| stringify(entry) } unless conflicts.empty?
64
+ end
65
+ stringify(hash)
66
+ end
67
+
68
+ def to_json(*) = JSON.pretty_generate(to_h)
69
+
70
+ def to_text
71
+ case status
72
+ when :ok then ok_text
73
+ when :missing_choice then missing_choice_text
74
+ when :conflict then conflict_text
75
+ else "#{message}#{"\n #{suggestion}" if suggestion}\n\nNothing was written.\n"
76
+ end
77
+ end
78
+
79
+ # `monk new`'s text: the app created, its base files by directory, then
80
+ # each module added on top of it, as `monk add` shows them.
81
+ def new_text(app)
82
+ return to_text unless ok?
83
+
84
+ added = modules.select { |entry| entry[:action] == :added && entry[:name] != :base }
85
+ lines = [new_header(app, added), "", *base_summary]
86
+ added.each { |entry| lines.push("", entry[:name].to_s, *module_lines(entry[:name])) }
87
+ lines.concat(production_text)
88
+ lines.push("", "Next:", " cd #{app}", *step_lines)
89
+ lines << " then: monk add --list the modules you can add" if added.empty?
90
+ lines.push("", closing) if closing && !dry_run
91
+ "#{lines.join("\n")}\n"
92
+ end
93
+
94
+ private
95
+
96
+ def new_header(app, added)
97
+ verb = dry_run ? "Would create" : "Created"
98
+ return "#{verb} #{app}/ (bare Monk app)#{" — nothing written" if dry_run}." if added.empty?
99
+
100
+ requested_added = added.reject { |entry| entry[:reason] }.map { |entry| entry[:name] }
101
+ dependencies = added.select { |entry| entry[:reason] }.map { |entry| entry[:name] }
102
+ line = "#{verb} #{app}/ with #{names(requested_added)}"
103
+ if dependencies.any?
104
+ line += ", and #{names(dependencies)}, which #{requested_added.size > 1 ? "they need" : "it needs"}"
105
+ end
106
+ "#{line}#{" — nothing written" if dry_run}."
107
+ end
108
+
109
+ # The base app's files, one line per top-level directory.
110
+ def base_summary
111
+ base = files.select { |entry| entry[:module] == :base && entry[:action] == :created }
112
+ paths = base.map { |entry| entry[:path] }
113
+ top, nested = paths.partition { |path| !path.include?("/") }
114
+ lines = top.empty? ? [] : [" + #{top.join(", ")}"]
115
+ nested.group_by { |path| path.split("/").first }.each do |dir, inside|
116
+ children = inside.map { |path| path.split("/")[1] + (path.count("/") > 1 ? "/" : "") }.uniq
117
+ lines << " + #{"#{dir}/".ljust(9)}#{children.join(", ")}"
118
+ end
119
+ sections = files.select { |entry| entry[:sections] }.map { |entry| entry[:path] }
120
+ lines << " + #{sections.join(", ")}" unless sections.empty?
121
+ lines
122
+ end
123
+
124
+ def ok_text
125
+ added = modules.select { |entry| entry[:action] == :added }
126
+ if added.empty?
127
+ return "#{names(requested)} #{requested.size > 1 ? "are" : "is"} already installed — nothing to do.\n"
128
+ end
129
+
130
+ lines = [header(added), ""]
131
+ added.each do |entry|
132
+ body = module_lines(entry[:name])
133
+ lines << entry[:name].to_s if added.size > 1
134
+ lines.concat(body)
135
+ lines << "" if added.size > 1
136
+ end
137
+ lines.pop if lines.last == "" && added.size == 1
138
+ sections = files.select { |entry| entry[:sections] }
139
+ if sections.any?
140
+ names = sections.flat_map { |entry| entry[:items] }.uniq
141
+ label = names.size > 1 ? "sections" : "section"
142
+ lines << row("~", sections.map { |entry| entry[:path] }.join(", "), "#{label}: #{names.join(", ")}")
143
+ elsif lines.last == ""
144
+ lines.pop
145
+ end
146
+ lines.concat(production_text, next_text)
147
+ lines.push("", closing) if closing && !dry_run
148
+ "#{lines.join("\n")}\n"
149
+ end
150
+
151
+ def header(added)
152
+ to_add = added.reject { |entry| entry[:reason] }.map { |entry| entry[:name] }
153
+ dependencies = added.select { |entry| entry[:reason] }.map { |entry| entry[:name] }
154
+ installed = modules.select { |entry| entry[:action] == :already_installed }.map { |entry| entry[:name] }
155
+ verb = dry_run ? "Would add" : "Adding"
156
+ line = "#{verb} #{names(to_add)}"
157
+ if dependencies.any?
158
+ line += ", which #{to_add.size > 1 ? "need" : "needs"} #{names(dependencies)} — adding " \
159
+ "#{dependencies.size > 1 ? "those" : "it"} first"
160
+ end
161
+ already = "#{names(installed)} already installed" if installed.any? && dependencies.empty?
162
+ return "#{line} (#{["nothing written", already].compact.join("; ")}):" if dry_run
163
+
164
+ already ? "#{line} (#{already})." : "#{line}."
165
+ end
166
+
167
+ def module_lines(name)
168
+ own = files.select { |entry| entry[:module] == name }
169
+ created = own.select { |entry| entry[:action] == :created }
170
+ lines = migrations_grouped(created).map { |entry| row("+", entry[:path], created_note(entry)) }
171
+ lines += skipped.select { |entry| entry[:module] == name }.map do |entry|
172
+ row("!", entry[:path], "exists and differs — left as is")
173
+ end
174
+ lines + appended_grouped(own.select { |entry| entry[:action] == :appended && !entry[:sections] })
175
+ end
176
+
177
+ def created_note(entry)
178
+ return "example: #{entry[:examples].map(&:first).uniq.join(", ")} (commented)" if entry[:examples]&.any?
179
+
180
+ "demo, development only" if entry[:role] == :demo
181
+ end
182
+
183
+ # db/migrate/..._name.up.sql and .down.sql as one line.
184
+ def migrations_grouped(created)
185
+ created.each_with_object([]) do |entry, rows|
186
+ if entry[:path].end_with?(".down.sql") && rows.last && rows.last[:path] == entry[:path].sub(/\.down\.sql\z/,
187
+ ".up.sql",)
188
+ rows[-1] = rows.last.merge(path: entry[:path].sub(/\.down\.sql\z/, ".{up,down}.sql"))
189
+ else
190
+ rows << entry
191
+ end
192
+ end
193
+ end
194
+
195
+ # The files that got the same lines (the .env files, say), as one line.
196
+ def appended_grouped(appended)
197
+ appended.group_by { |entry| compact_keys(entry[:items]) }.map do |items, entries|
198
+ row("~", entries.map { |entry| entry[:path] }.join(", "), items)
199
+ end
200
+ end
201
+
202
+ # DB_HOST, DB_NAME... as DB_*.
203
+ def compact_keys(items)
204
+ prefix = items.first.to_s[/\A[A-Z]+_/]
205
+ return "#{prefix}*" if prefix && items.size > 1 && items.all? { |item| item.start_with?(prefix) }
206
+
207
+ items.join(", ")
208
+ end
209
+
210
+ def production_text
211
+ return [] if env.empty?
212
+
213
+ width = env.map { |entry| entry[:key].size }.max
214
+ ["", "Set before production:", *env.map { |entry| " #{entry[:key].ljust(width)} #{entry[:note]}" }]
215
+ end
216
+
217
+ def next_text = ["", "Next:", *step_lines]
218
+
219
+ # At most three numbered steps, in the order they're done. Past three,
220
+ # bin/setup_db joins the tests' line, the first two and that line are
221
+ # numbered, and what's left is named on one more line each, with the
222
+ # SETUP.md section that explains it.
223
+ def step_lines
224
+ steps = next_steps.dup
225
+ if steps.size > MAX_STEPS && (migrate = steps.find { |step| step[:run] == "bin/setup_db" })
226
+ steps.delete(migrate)
227
+ steps[-1] = steps.last.merge(run: "bin/setup_db && #{steps.last[:run]}")
228
+ end
229
+ rest = []
230
+ if steps.size > MAX_STEPS
231
+ rest = steps[(MAX_STEPS - 1)...-1]
232
+ steps = steps.first(MAX_STEPS - 1) + [steps.last]
233
+ end
234
+ lines = steps.each_with_index.map { |step, index| " #{index + 1}. #{step_text(step)}" }
235
+ lines + rest.map { |step| " … also: #{step_text(step)}#{" (SETUP.md › #{step[:module]})" if step[:module]}" }
236
+ end
237
+
238
+ def step_text(step)
239
+ [step[:run] || step[:do], step[:note]].compact.join(" ")
240
+ end
241
+
242
+ def missing_choice_text
243
+ lines = ["#{command}: #{message} Pass one:", ""]
244
+ width = choice[:values].map(&:size).max
245
+ choice[:values].each do |value|
246
+ described = choice[:describe]&.fetch(value, nil)
247
+ lines << " #{command} --#{choice[:option]}=#{value.ljust(width)}#{" #{described}" if described}"
248
+ end
249
+ "#{lines.join("\n")}\n\nNothing was written.\n"
250
+ end
251
+
252
+ def conflict_text
253
+ lines = [message]
254
+ conflicts.each { |entry| lines << " Monk's version of #{entry[:path]}: #{entry[:show]}" }
255
+ "#{lines.join("\n")}\n\nNothing was written.\n"
256
+ end
257
+
258
+ def row(mark, path, note = nil)
259
+ note ? " #{mark} #{path.ljust(COLUMN)} #{note}" : " #{mark} #{path}"
260
+ end
261
+
262
+ def names(list)
263
+ list = list.map(&:to_s)
264
+ list.size <= 1 ? list.join : "#{list[0..-2].join(", ")} and #{list.last}"
265
+ end
266
+
267
+ def file_json(entry)
268
+ json = entry.slice(:path, :module, :role, :action, :items)
269
+ json[:examples] = entry[:examples].map(&:first).uniq if entry[:examples]&.any?
270
+ json[:module] = nil if entry[:sections]
271
+ stringify(json.compact)
272
+ end
273
+
274
+ def stringify(hash)
275
+ hash.to_h { |key, value| [key.to_s, value.is_a?(Symbol) ? value.to_s : value] }
276
+ end
277
+ end
278
+ end
279
+ end