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
|
@@ -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
|