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
data/lib/monk/bin.rb ADDED
@@ -0,0 +1,107 @@
1
+ module Monk
2
+ # What a scaffolded app's bin/ scripts run their work in:
3
+ #
4
+ # Monk::Bin.run(__FILE__) do
5
+ # require_relative "../config/persistence"
6
+ # ...
7
+ # end
8
+ #
9
+ # An unreachable Postgres or Redis, a missing database, refused
10
+ # credentials or an unset setting end the script with what failed, which
11
+ # setting decides it, and the commands that fix it (the same as SETUP.md),
12
+ # on stderr, exit 1 -- not a backtrace. Anything else is re-raised as it
13
+ # is. Errors are recognized by class name, so this file loads neither pg
14
+ # nor redis (docs/adr/0017).
15
+ module Bin
16
+ class << self
17
+ def run(script)
18
+ yield
19
+ rescue StandardError => e
20
+ message = explain(e, script)
21
+ raise unless message
22
+
23
+ # Not warn: it prints nothing when $VERBOSE is nil (ruby -W0), and
24
+ # this message is the whole point of the failure.
25
+ $stderr.puts message # rubocop:disable Style/StderrPuts
26
+ exit 1
27
+ end
28
+
29
+ # The explanation for an error, or nil when it's not one of the above.
30
+ def explain(error, script)
31
+ context = Context.for(script)
32
+
33
+ case error.class.name
34
+ when "PG::ConnectionBad" then postgres(error, context)
35
+ when "Redis::CannotConnectError" then redis(context)
36
+ when "Monk::WebSocket::ListenError" then fanout(error, context)
37
+ when "Monk::MissingSettingError" then missing_setting(error.message[/ENV\["([^"]+)"\]/, 1], context)
38
+ when "KeyError" then missing_setting(error.key, context) if error.receiver.equal?(ENV)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def postgres(error, context)
45
+ host = error.message[/server at "([^"]+)"/, 1] || ENV.fetch("DB_HOST", "127.0.0.1")
46
+ port = error.message[/port (\d+)/, 1] || ENV.fetch("DB_PORT", "5432")
47
+ at = "Postgres at #{host}:#{port}"
48
+
49
+ if (database = error.message[/database "([^"]+)" does not exist/, 1])
50
+ context.message("database \"#{database}\" doesn't exist on #{at} (DB_NAME in .env).",
51
+ "Create it (it asks for DB_PASSWORD), then run #{context.name} again -- SETUP.md:",
52
+ "createdb -h #{host} -p #{port} -U #{ENV.fetch("DB_USER", "postgres")} #{database}",)
53
+ elsif (user = error.message[/authentication failed for user "([^"]+)"/, 1])
54
+ context.message("#{at} refused user \"#{user}\" (DB_USER/DB_PASSWORD in .env).",
55
+ "Check them, then run #{context.name} again.",)
56
+ elsif error.message.include?("Connection refused")
57
+ context.message("can't connect to #{at} (DB_HOST/DB_PORT in .env).",
58
+ "Start it, then run #{context.name} again -- SETUP.md:",
59
+ "docker run --rm -d -p #{port}:5432 -e POSTGRES_PASSWORD=postgres --name #{context.app}_pg postgres:16",)
60
+ else
61
+ context.message("can't connect to #{at} (DB_* in .env): #{error.message.lines.first.strip}")
62
+ end
63
+ end
64
+
65
+ # The WS process's subscriber connection, opened by listen! at boot.
66
+ def fanout(error, context)
67
+ if error.message.include?("RedisFanout")
68
+ redis(context)
69
+ elsif error.message.include?("PgFanout")
70
+ postgres(error, context)
71
+ end
72
+ end
73
+
74
+ def redis(context)
75
+ url = ENV.fetch("REDIS_URL", "redis://localhost:6379/0")
76
+ port = url[%r{//[^/]*:(\d+)}, 1] || "6379"
77
+ shown = url.sub(%r{//([^@/]*):[^@/]*@}, '//\1:***@') # never the password
78
+ context.message("can't connect to Redis at #{shown} (REDIS_URL in .env).",
79
+ "Start it, then run #{context.name} again -- SETUP.md:",
80
+ "docker run --rm -d -p #{port}:6379 --name #{context.app}_redis redis:7",)
81
+ end
82
+
83
+ def missing_setting(name, context)
84
+ return unless name
85
+
86
+ context.message("#{name} is not set.",
87
+ "Set it in .env (development) or the environment, then run #{context.name} again.",)
88
+ end
89
+ end
90
+
91
+ # The script being run: its name as typed from the app's root
92
+ # (bin/setup_db), and the app's directory name, which SETUP.md's
93
+ # container names use.
94
+ Context = Data.define(:name, :app) do
95
+ def self.for(script)
96
+ path = File.expand_path(script)
97
+ new(name: "bin/#{File.basename(path)}", app: File.basename(File.dirname(path, 2)))
98
+ end
99
+
100
+ # The first line says what failed; the next, what to do; the last,
101
+ # if there's one, is the command to run.
102
+ def message(what, todo = nil, command = nil)
103
+ ["#{name}: #{what}", todo, (" #{command}" if command)].compact.join("\n")
104
+ end
105
+ end
106
+ end
107
+ end
data/lib/monk/cli.rb ADDED
@@ -0,0 +1,277 @@
1
+ require "json"
2
+ require "fileutils"
3
+ require_relative "generators"
4
+
5
+ module Monk
6
+ # What exe/monk runs (docs/history/plan-scaffold.md Phase 5): `monk new`,
7
+ # `monk add`, `monk add --list`, `monk help`. Each answers in text, or
8
+ # as one JSON object with --json, and returns the exit code: 0 done,
9
+ # 1 usage error, 2 a missing choice, 3 a conflict on a wiring file
10
+ # (decision 28). It never prompts without a terminal (decision 23).
11
+ class CLI
12
+ Error = Class.new(StandardError) do
13
+ attr_reader :suggestion
14
+
15
+ def initialize(message, suggestion: nil)
16
+ @suggestion = suggestion
17
+ super(message)
18
+ end
19
+ end
20
+
21
+ # `monk new`'s flags before docs/adr/0017, in the order --with lists
22
+ # them. Each is a usage error now, showing the command to type instead.
23
+ OLD_FLAGS = %w[postgres auth mail jobs redis live].freeze
24
+
25
+ USAGE = <<~TEXT.freeze
26
+ Usage:
27
+ monk new NAME [--with a,b] [options] create an app, with modules
28
+ monk add MODULE... [options] add modules to the app here
29
+ monk add --list the modules, and which are installed
30
+ monk help [MODULE] [--file PATH] this, a module's details, or Monk's version of one of its files
31
+
32
+ Options:
33
+ --transport=postgres|redis websocket's transport (inferred when only one is installed)
34
+ --no-demo live without its demo
35
+ --dry-run say what would be written, and write nothing
36
+ --json answer as one JSON object
37
+ TEXT
38
+
39
+ def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd, registry: Generator.registry,
40
+ now: Time.now)
41
+ @stdin = stdin
42
+ @stdout = stdout
43
+ @stderr = stderr
44
+ @cwd = cwd
45
+ @registry = registry
46
+ @now = now
47
+ end
48
+
49
+ def run(argv)
50
+ command, *args = argv
51
+ @args = parse(args)
52
+ case command
53
+ when nil, "help", "--help", "-h" then help
54
+ when "new" then new_app
55
+ when "add" then @args[:list] ? list : add
56
+ else raise Error.new("Unknown command: #{command}.", suggestion: "monk help")
57
+ end
58
+ rescue Error => e
59
+ report(Generator::Result.new(status: :usage_error, written: false, message: e.message, suggestion: e.suggestion))
60
+ end
61
+
62
+ private
63
+
64
+ def parse(args)
65
+ parsed = { positional: [], options: {}, with: [], old: [] }
66
+ args = args.dup
67
+ until args.empty?
68
+ arg = args.shift
69
+ case arg
70
+ when "--json" then parsed[:json] = true
71
+ when "--dry-run" then parsed[:dry_run] = true
72
+ when "--list" then parsed[:list] = true
73
+ when "--no-demo" then parsed[:options][:demo] = "off"
74
+ when /\A--(with|transport|file)(?:=(.*))?\z/
75
+ value = Regexp.last_match(2) || args.shift || raise(Error, "--#{Regexp.last_match(1)} needs a value.")
76
+ key = Regexp.last_match(1).to_sym
77
+ case key
78
+ when :with then parsed[:with].concat(value.split(",").map(&:strip).reject(&:empty?).map(&:to_sym))
79
+ when :transport then parsed[:options][:transport] = value
80
+ else parsed[:file] = value
81
+ end
82
+ when /\A--(#{OLD_FLAGS.join("|")})\z/ then parsed[:old] << Regexp.last_match(1)
83
+ when /\A-/ then raise Error.new("Unknown option: #{arg}.", suggestion: "monk help")
84
+ else parsed[:positional] << arg
85
+ end
86
+ end
87
+ parsed
88
+ end
89
+
90
+ def new_app
91
+ name = @args[:positional].first || raise(Error.new("monk new needs a name.", suggestion: "monk new my_app"))
92
+ old_flags!(name)
93
+ dir = File.expand_path(name, @cwd)
94
+ raise Error.new("#{name} already exists.", suggestion: "cd #{name} && monk add <module>") if File.exist?(dir)
95
+
96
+ FileUtils.mkdir_p(dir) unless @args[:dry_run]
97
+ command = "monk new #{name}#{" --with #{@args[:with].join(",")}" unless @args[:with].empty?}"
98
+ result = run_generators(dir, [:base, *@args[:with]], command)
99
+ FileUtils.rm_rf(dir) if !result.ok? && !@args[:dry_run]
100
+ report(result, app: name)
101
+ end
102
+
103
+ def add
104
+ modules = @args[:positional].map(&:to_sym)
105
+ raise Error.new("monk add needs a module.", suggestion: "monk add --list") if modules.empty?
106
+ unless monk_app?
107
+ raise Error.new("This isn't a Monk app (no config/load.rb here).",
108
+ suggestion: "monk new my_app, or cd into your app",)
109
+ end
110
+
111
+ report(run_generators(@cwd, modules, "monk add #{modules.join(" ")}"))
112
+ end
113
+
114
+ # A choice the result says is missing is asked for, in a terminal only,
115
+ # then the run starts over with it.
116
+ def run_generators(dir, modules, command)
117
+ options = @args[:options].dup
118
+ loop do
119
+ result = @registry.add(dir, modules, options: options, dry_run: @args[:dry_run], now: @now, command: command)
120
+ return result unless result.status == :missing_choice && interactive?
121
+
122
+ value = ask(result)
123
+ return result unless value
124
+
125
+ options[result.choice[:option].to_sym] = value
126
+ end
127
+ end
128
+
129
+ def interactive?
130
+ !@args[:json] && @stdin.respond_to?(:tty?) && @stdin.tty?
131
+ end
132
+
133
+ def ask(result)
134
+ choice = result.choice
135
+ @stdout.puts result.message, ""
136
+ width = choice[:values].map(&:size).max
137
+ choice[:values].each_with_index do |value, index|
138
+ @stdout.puts " #{index + 1}) #{value.ljust(width)} #{choice[:describe]&.fetch(value, nil)}".rstrip
139
+ end
140
+ @stdout.print "\n#{choice[:option].capitalize} [#{(1..choice[:values].size).to_a.join("/")}]: "
141
+ answer = @stdin.gets&.strip.to_s
142
+ number = answer.match?(/\A\d+\z/) ? answer.to_i : 0
143
+ value = number.between?(1, choice[:values].size) ? choice[:values][number - 1] : nil
144
+ value ||= answer if choice[:values].include?(answer)
145
+ @stdout.puts "# Using --#{choice[:option]}=#{value} skips this question." if value
146
+ value
147
+ end
148
+
149
+ def old_flags!(name)
150
+ return if @args[:old].empty?
151
+
152
+ old = @args[:old]
153
+ transport = (%w[redis postgres] & old).first if old.include?("live")
154
+ with = (OLD_FLAGS & old) - [transport]
155
+ new_form = "monk new #{name} --with #{with.join(",")}#{" --transport=#{transport}" if transport}"
156
+ raise Error.new("monk new no longer takes module flags. The same command is now:", suggestion: new_form)
157
+ end
158
+
159
+ def monk_app? = File.exist?(File.join(@cwd, "config/load.rb"))
160
+
161
+ def list
162
+ installed = monk_app? ? @registry.definitions.select { |d| File.exist?(File.join(@cwd, d.installed_if)) } : []
163
+ rows = @registry.definitions.reject { |definition| definition.name == :base }.map do |definition|
164
+ list_row(definition, installed.include?(definition))
165
+ end
166
+ if @args[:json]
167
+ @stdout.puts JSON.pretty_generate(modules: rows)
168
+ else
169
+ @stdout.puts "Modules#{" (✓ installed)" if monk_app?}", ""
170
+ width = rows.map { |row| row[:name].size }.max
171
+ rows.each do |row|
172
+ needs = row[:needs].empty? ? row[:needs_one_of] : row[:needs]
173
+ needs_text = " (needs #{needs.join(row[:needs].empty? ? " or " : ", ")})" unless needs.empty?
174
+ @stdout.puts " #{row[:installed] ? "✓" : " "} #{row[:name].ljust(width)} #{row[:summary]}#{needs_text}"
175
+ end
176
+ @stdout.puts "", "Add one: monk add <module> Details: monk help <module>"
177
+ end
178
+ 0
179
+ end
180
+
181
+ def list_row(definition, installed)
182
+ dependency_options = definition.options.select { |option| definition.dependency_options.include?(option.name) }
183
+ {
184
+ name: definition.name.to_s, summary: definition.summary, installed: installed,
185
+ needs: definition.dependencies.map(&:to_s), needs_one_of: dependency_options.flat_map(&:values),
186
+ options: definition.options.to_h { |option| [option.name, option.values] },
187
+ }
188
+ end
189
+
190
+ def help
191
+ name = @args[:positional].first
192
+ return general_help unless name
193
+
194
+ definition = @registry.names.include?(name.to_sym) ? @registry.fetch(name) : nil
195
+ raise Error.new("Unknown module: #{name}.", suggestion: "monk add --list") unless definition
196
+ return file_help(definition) if @args[:file]
197
+
198
+ module_help(definition)
199
+ end
200
+
201
+ def general_help
202
+ @stdout.puts USAGE, "Modules:"
203
+ @registry.definitions.reject { |definition| definition.name == :base }.each do |definition|
204
+ @stdout.puts " #{definition.name.to_s.ljust(10)} #{definition.summary}"
205
+ end
206
+ 0
207
+ end
208
+
209
+ def module_help(definition)
210
+ @stdout.puts "#{definition.name}: #{definition.summary}"
211
+ @stdout.puts "Needs: #{definition.dependencies.join(", ")}" unless definition.dependencies.empty?
212
+ definition.options.each do |option|
213
+ default = " (default #{option.default})" if option.default
214
+ @stdout.puts "Option --#{option.name}: #{option.values.join(", ")}#{default}"
215
+ end
216
+ @stdout.puts "", "Writes:"
217
+ definition.copies.each { |copy| @stdout.puts " #{copy.path.ljust(32)} #{copy.role}" }
218
+ definition.migrations.each do |migration|
219
+ @stdout.puts " db/migrate/<timestamp>_#{migration.name}.{up,down}.sql wiring"
220
+ end
221
+ @stdout.puts "", "Monk's version of one of these: monk help #{definition.name} --file PATH"
222
+ 0
223
+ end
224
+
225
+ # Monk's version of one of a module's files, for a file the app already
226
+ # had (a conflict, docs/history/plan-scaffold.md decision 26).
227
+ def file_help(definition)
228
+ path = @args[:file]
229
+ source = migration_template(definition, path) || copy_template(definition, path)
230
+ unless source
231
+ raise Error.new("#{definition.name} doesn't write #{path}.", suggestion: "monk help #{definition.name}")
232
+ end
233
+
234
+ @stdout.print File.read(source)
235
+ 0
236
+ end
237
+
238
+ def migration_template(definition, path)
239
+ migration = definition.migrations.find { |m| path.match?(/_#{Regexp.escape(m.name)}\.(up|down)\.sql\z/) }
240
+ return unless migration
241
+
242
+ pattern = "db/migrate/*_#{migration.name}.#{path[/\.(up|down)\.sql\z/, 1]}.sql"
243
+ Dir[File.join(@registry.templates_dir, definition.name.to_s, pattern)].first
244
+ end
245
+
246
+ def copy_template(definition, path)
247
+ copy = definition.copies.find { |candidate| candidate.path == path }
248
+ File.join(@registry.templates_dir, template_for_copy(definition, copy)) if copy
249
+ end
250
+
251
+ def template_for_copy(definition, copy)
252
+ return "#{definition.name}/#{copy.path}" unless copy.from
253
+ return copy.from unless copy.from.respond_to?(:call)
254
+
255
+ options = definition.options.to_h { |option| [option.name, @args[:options][option.name] || option.default] }
256
+ missing = definition.options.find { |option| options[option.name].nil? }
257
+ if missing
258
+ raise Error.new("#{copy.path} depends on --#{missing.name}.",
259
+ suggestion: "monk help #{definition.name} --file #{copy.path} --#{missing.name}=#{missing.values.first}",)
260
+ end
261
+ copy.from.call(options)
262
+ end
263
+
264
+ def report(result, app: nil)
265
+ if @args&.fetch(:json, false)
266
+ hash = result.to_h
267
+ hash["app"] = app if app
268
+ @stdout.puts JSON.pretty_generate(hash)
269
+ elsif result.ok?
270
+ @stdout.print app ? result.new_text(app) : result.to_text
271
+ else
272
+ @stderr.print result.to_text
273
+ end
274
+ result.exit_code
275
+ end
276
+ end
277
+ end
data/lib/monk/context.rb CHANGED
@@ -4,8 +4,18 @@ require_relative "views"
4
4
  require_relative "assets"
5
5
 
6
6
  module Monk
7
+ # The base of Context#head_tags: no tags. A module's helpers, included
8
+ # into Context after this, add theirs with `def head_tags = super + [...]`
9
+ # (Monk::Live::Helpers does), so a layout's <%= monk_head %> renders
10
+ # every loaded module's tags and adding a module never edits the layout
11
+ # (docs/adr/0017).
12
+ module HeadTags
13
+ def head_tags = []
14
+ end
15
+
7
16
  class Context
8
17
  include Monk::Views::Compiled
18
+ include Monk::HeadTags
9
19
 
10
20
  attr_reader :params, :env, :headers
11
21
  attr_accessor :status
@@ -68,6 +78,13 @@ module Monk
68
78
  Monk::Assets.path_for(path)
69
79
  end
70
80
 
81
+ # Every loaded module's <head> tags, one per line, for the layout:
82
+ # <head> ... <%= monk_head %> </head>
83
+ # Each tag is built by Monk, which escapes what it interpolates.
84
+ def monk_head
85
+ raw(head_tags.join("\n"))
86
+ end
87
+
71
88
  # Same frozen values Monk::Settings[] reads, e.g. `settings[:api_key]`
72
89
  # -- returns the module itself rather than duplicating its #[] here,
73
90
  # so an undeclared key raises the exact same way through either path.
data/lib/monk/errors.rb CHANGED
@@ -31,10 +31,4 @@ module Monk
31
31
 
32
32
  class TemplateSyntaxError < StandardError
33
33
  end
34
-
35
- class ScaffoldExistsError < StandardError
36
- end
37
-
38
- class AmbiguousLiveTransportError < StandardError
39
- end
40
34
  end
@@ -0,0 +1,153 @@
1
+ module Monk
2
+ module Generator
3
+ # One module's generator, as `Monk::Generator.define(:jobs) { ... }`
4
+ # declares it. Only declarations: Run decides what they mean for a given
5
+ # app, and writes nothing until it has checked every file.
6
+ class Definition
7
+ Copy = Data.define(:path, :from, :role, :executable, :condition)
8
+ Migration = Data.define(:name)
9
+ Env = Data.define(:file, :key, :value, :commented)
10
+ Option = Data.define(:name, :values, :infer_from, :describe, :default)
11
+ Service = Data.define(:name, :check, :setup)
12
+ Step = Data.define(:text, :note, :needs_service, :condition)
13
+ Example = Data.define(:tag, :todo)
14
+ Production = Data.define(:key, :note, :placeholder)
15
+
16
+ # What a role means when the file already exists with other content:
17
+ # a wiring file stops the whole command, anything else is skipped
18
+ # (docs/history/plan-scaffold.md decision 26).
19
+ ROLES = %i[wiring example test view demo].freeze
20
+
21
+ # Which .env file each env group appends to.
22
+ ENV_FILES = { development: ".env", test: ".env.test", example: ".env.example" }.freeze
23
+
24
+ attr_reader :name, :copies, :migrations, :gems, :envs, :ignores, :options, :services, :steps,
25
+ :examples, :productions, :sections
26
+
27
+ def initialize(name)
28
+ @name = name.to_sym
29
+ @summary = ""
30
+ @dependencies = []
31
+ @option_dependencies = []
32
+ @installed_if = "config/#{name}.rb"
33
+ @copies = []
34
+ @migrations = []
35
+ @gems = []
36
+ @envs = []
37
+ @ignores = []
38
+ @options = []
39
+ @services = []
40
+ @steps = []
41
+ @examples = []
42
+ @productions = []
43
+ @sections = {}
44
+ @closing = nil
45
+ end
46
+
47
+ def summary(text = nil)
48
+ return @summary if text.nil?
49
+
50
+ @summary = text
51
+ end
52
+
53
+ # Modules this one needs, added first when they're missing.
54
+ def depends_on(*names)
55
+ @dependencies.concat(names.map(&:to_sym))
56
+ end
57
+
58
+ # A choice the user makes (websocket's transport). It's inferred when
59
+ # exactly one of `infer_from` is installed or being added, and the
60
+ # chosen value is a dependency when `dependency: true`. An option with
61
+ # a `default:` (live's demo) is never inferred.
62
+ def option(name, values:, infer_from: values, dependency: false, describe: {}, default: nil)
63
+ @options << Option.new(name: name.to_sym, values: values.map(&:to_s), infer_from: infer_from.map(&:to_s),
64
+ describe: describe.transform_keys(&:to_s), default: default&.to_s,)
65
+ @option_dependencies << name.to_sym if dependency
66
+ end
67
+
68
+ # The file whose existence means this module is installed.
69
+ def installed_if(path = nil)
70
+ return @installed_if if path.nil?
71
+
72
+ @installed_if = path
73
+ end
74
+
75
+ # A file copied verbatim from templates/<module>/<path>, or from
76
+ # `from:`, a template path or a block taking the options. `if:`, a
77
+ # block taking the options, decides whether it's copied at all.
78
+ def copy(*paths, role:, from: nil, executable: false, if: nil)
79
+ raise ArgumentError, "unknown role #{role.inspect}, one of #{ROLES.inspect}" unless ROLES.include?(role)
80
+ raise ArgumentError, "from: needs a single path" if from && paths.size > 1
81
+
82
+ condition = binding.local_variable_get(:if)
83
+ paths.each do |path|
84
+ @copies << Copy.new(path: path, from: from, role: role, executable: executable, condition: condition)
85
+ end
86
+ end
87
+
88
+ # db/migrate/<timestamp>_<name>.{up,down}.sql, from the template
89
+ # migration named *_<name>.{up,down}.sql.
90
+ def migration(name)
91
+ @migrations << Migration.new(name: name)
92
+ end
93
+
94
+ # A line appended to the Gemfile unless that gem is already there.
95
+ def gem(line)
96
+ @gems << line
97
+ end
98
+
99
+ # A KEY=value line appended to one of the .env files unless KEY is
100
+ # already there (the app's own value wins). The value may be a block
101
+ # taking the app's name.
102
+ def env(file, values, commented: false)
103
+ raise ArgumentError, "unknown env file #{file.inspect}" unless ENV_FILES.key?(file)
104
+
105
+ values.each { |key, value| @envs << Env.new(file: file, key: key.to_s, value: value, commented: commented) }
106
+ end
107
+
108
+ # A line appended to .gitignore and .dockerignore unless it's there.
109
+ def ignore(line)
110
+ @ignores << line
111
+ end
112
+
113
+ def service(name, check:, setup:)
114
+ @services << Service.new(name: name.to_s, check: check, setup: setup)
115
+ end
116
+
117
+ # This module's section of SETUP.md or AGENTS.md: a template path.
118
+ def setup_section(path) = @sections[:setup] = path
119
+ def agents_section(path) = @sections[:agents] = path
120
+
121
+ def set_before_production(key, note, placeholder: false)
122
+ @productions << Production.new(key: key, note: note, placeholder: placeholder)
123
+ end
124
+
125
+ # A command (`bin/jobs`) or something to do; `note:` says what for.
126
+ def next_step(text, note: nil, needs_service: nil, if: nil)
127
+ @steps << Step.new(text: text, note: note, needs_service: needs_service&.to_s,
128
+ condition: binding.local_variable_get(:if),)
129
+ end
130
+
131
+ # What to do with an example block (`# monk:example <tag>`). `todo`
132
+ # may be a block taking the names of the installed modules.
133
+ def example(tag, todo:)
134
+ @examples << Example.new(tag: tag, todo: todo)
135
+ end
136
+
137
+ # The one thing that matters most after adding this module: a String,
138
+ # or a block taking the options.
139
+ def closing(text = nil)
140
+ return @closing if text.nil?
141
+
142
+ @closing = text
143
+ end
144
+
145
+ # The options whose value is a module to depend on (websocket's transport).
146
+ def dependency_options = @option_dependencies
147
+
148
+ def dependencies(options = {})
149
+ @dependencies + @option_dependencies.filter_map { |option| options[option]&.to_sym }
150
+ end
151
+ end
152
+ end
153
+ end