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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -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 +4 -3
- 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 +8 -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 +25 -0
- data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
- data/lib/monk/templates/websocket/bin/websocket_server +58 -0
- data/lib/monk/templates/websocket/config/websocket_postgres.rb +24 -0
- data/lib/monk/templates/websocket/config/websocket_redis.rb +21 -0
- data/lib/monk/templates/websocket/setup.md +51 -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
|
@@ -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
|