monkrb 0.19.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -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/base.rb +20 -0
  11. data/lib/monk/bin.rb +107 -0
  12. data/lib/monk/cli.rb +277 -0
  13. data/lib/monk/context.rb +17 -0
  14. data/lib/monk/errors.rb +0 -6
  15. data/lib/monk/generator/definition.rb +153 -0
  16. data/lib/monk/generator/result.rb +279 -0
  17. data/lib/monk/generator/run.rb +449 -0
  18. data/lib/monk/generator.rb +48 -0
  19. data/lib/monk/generators/auth.rb +29 -0
  20. data/lib/monk/generators/base.rb +19 -0
  21. data/lib/monk/generators/jobs.rb +21 -0
  22. data/lib/monk/generators/live.rb +26 -0
  23. data/lib/monk/generators/mail.rb +24 -0
  24. data/lib/monk/generators/postgres.rb +23 -0
  25. data/lib/monk/generators/redis.rb +20 -0
  26. data/lib/monk/generators/websocket.rb +24 -0
  27. data/lib/monk/generators.rb +7 -0
  28. data/lib/monk/jobs/adapters/pg.rb +3 -1
  29. data/lib/monk/jobs.rb +7 -0
  30. data/lib/monk/live/helpers.rb +13 -0
  31. data/lib/monk/live/policy.rb +39 -4
  32. data/lib/monk/live/session.rb +7 -0
  33. data/lib/monk/live.rb +17 -4
  34. data/lib/monk/log.rb +10 -1
  35. data/lib/monk/mail/deliver_later.rb +54 -0
  36. data/lib/monk/mail/later.rb +3 -51
  37. data/lib/monk/mail.rb +4 -0
  38. data/lib/monk/persistence/errors.rb +32 -0
  39. data/lib/monk/persistence/pg.rb +92 -1
  40. data/lib/monk/persistence/pool.rb +513 -0
  41. data/lib/monk/persistence.rb +196 -7
  42. data/lib/monk/templates/auth/agents.md +29 -0
  43. data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
  44. data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
  45. data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
  46. data/lib/monk/templates/auth/config/auth.rb +14 -8
  47. data/lib/monk/templates/auth/setup.md +37 -0
  48. data/lib/monk/templates/auth/test/auth_test.rb +17 -0
  49. data/lib/monk/templates/base/CLAUDE.md +1 -0
  50. data/lib/monk/templates/base/Dockerfile +4 -3
  51. data/lib/monk/templates/base/Gemfile +7 -2
  52. data/lib/monk/templates/base/Rakefile +8 -0
  53. data/lib/monk/templates/base/agents.md +41 -0
  54. data/lib/monk/templates/base/app/app.rb +5 -0
  55. data/lib/monk/templates/base/app/routes/.keep +0 -0
  56. data/lib/monk/templates/base/app/views/index.erb +1 -1
  57. data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
  58. data/lib/monk/templates/base/config/load.rb +8 -0
  59. data/lib/monk/templates/base/config/settings.rb +4 -5
  60. data/lib/monk/templates/base/setup.md +22 -0
  61. data/lib/monk/templates/base/test/app_test.rb +12 -0
  62. data/lib/monk/templates/base/test/test_helper.rb +18 -0
  63. data/lib/monk/templates/jobs/agents.md +25 -0
  64. data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
  65. data/lib/monk/templates/jobs/bin/jobs +19 -13
  66. data/lib/monk/templates/jobs/setup.md +30 -0
  67. data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
  68. data/lib/monk/templates/live/agents.md +25 -0
  69. data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
  70. data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
  71. data/lib/monk/templates/live/app/routes/live.rb +10 -0
  72. data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
  73. data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
  74. data/lib/monk/templates/live/config/live.rb +30 -32
  75. data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
  76. data/lib/monk/templates/live/setup.md +39 -0
  77. data/lib/monk/templates/live/test/live_test.rb +22 -0
  78. data/lib/monk/templates/mail/agents.md +22 -0
  79. data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
  80. data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
  81. data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
  82. data/lib/monk/templates/mail/config/mail.rb +4 -4
  83. data/lib/monk/templates/mail/setup.md +32 -0
  84. data/lib/monk/templates/mail/test/mail_test.rb +13 -0
  85. data/lib/monk/templates/postgres/agents.md +25 -0
  86. data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
  87. data/lib/monk/templates/postgres/bin/console +3 -1
  88. data/lib/monk/templates/postgres/bin/migrate +23 -17
  89. data/lib/monk/templates/postgres/bin/setup_db +11 -5
  90. data/lib/monk/templates/postgres/config/persistence.rb +1 -0
  91. data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
  92. data/lib/monk/templates/postgres/setup.md +38 -0
  93. data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
  94. data/lib/monk/templates/redis/agents.md +18 -0
  95. data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
  96. data/lib/monk/templates/redis/config/redis.rb +13 -0
  97. data/lib/monk/templates/redis/setup.md +20 -0
  98. data/lib/monk/templates/redis/test/redis_test.rb +12 -0
  99. data/lib/monk/templates/websocket/agents.md +25 -0
  100. data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
  101. data/lib/monk/templates/websocket/bin/websocket_server +58 -0
  102. data/lib/monk/templates/websocket/config/websocket_postgres.rb +24 -0
  103. data/lib/monk/templates/websocket/config/websocket_redis.rb +21 -0
  104. data/lib/monk/templates/websocket/setup.md +51 -0
  105. data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
  106. data/lib/monk/version.rb +1 -1
  107. data/lib/monk/websocket/connection.rb +25 -6
  108. data/lib/monk/websocket/listeners.rb +24 -0
  109. data/lib/monk/websocket/pg_fanout.rb +143 -20
  110. data/lib/monk/websocket/redis_fanout.rb +78 -29
  111. data/lib/monk/websocket/registry.rb +24 -0
  112. data/lib/monk/websocket/server.rb +57 -16
  113. data/lib/monk/websocket.rb +2 -0
  114. data/lib/monk.rb +10 -0
  115. metadata +83 -14
  116. data/lib/monk/scaffold.rb +0 -1061
  117. data/lib/monk/templates/base/bin/websocket_server +0 -68
  118. data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
  119. data/lib/monk/templates/live/app/app.rb +0 -22
  120. data/lib/monk/templates/live/app/views/index.erb +0 -18
  121. data/lib/monk/templates/live/bin/websocket_server +0 -42
  122. data/lib/monk/templates/live/config/live_pg.rb +0 -60
  123. data/lib/monk/templates/mail/Gemfile.extra +0 -1
  124. data/lib/monk/templates/postgres/Dockerfile +0 -32
  125. data/lib/monk/templates/postgres/Gemfile.extra +0 -2
  126. data/lib/monk/templates/redis/Gemfile.extra +0 -1
  127. /data/lib/monk/templates/live/app/views/{live → demo}/_hits.erb +0 -0
data/lib/monk/auth.rb CHANGED
@@ -34,13 +34,18 @@ module Monk
34
34
  freeze_rqrcode!
35
35
  end
36
36
 
37
+ # callback_path: is where the app's own callback route redeems a
38
+ # token (GET "#{callback_path}/:token"). Monk doesn't define that
39
+ # route, but builds links to it: login_link below, and so
40
+ # Monk::Auth::SendLoginLink.
37
41
  def configure(
38
42
  db_name: nil, secret: nil, login_ttl: nil, session_ttl: nil, redirect_allowlist: [], secure: true,
39
- deliver: nil
43
+ deliver: nil, callback_path: "/auth/callback"
40
44
  )
41
45
  config = {
42
46
  db_name: db_name, secret: secret, login_ttl: login_ttl, session_ttl: session_ttl,
43
47
  redirect_allowlist: redirect_allowlist, secure: secure, deliver: deliver,
48
+ callback_path: checked_callback_path(callback_path),
44
49
  }
45
50
 
46
51
  REQUIRED_CONFIG_KEYS.each do |key|
@@ -81,6 +86,17 @@ module Monk
81
86
  raw
82
87
  end
83
88
 
89
+ # The magic link for a raw token: the app's public_url
90
+ # (config/settings.rb, a trusted origin -- never request headers like
91
+ # X-Forwarded-Host, which any direct client can spoof), then
92
+ # callback_path:, then the token.
93
+ #
94
+ # Monk::Auth.login_link(token) # => "https://app.example/auth/callback/<token>"
95
+ def login_link(token)
96
+ ensure_configured!
97
+ "#{Monk::Settings[:public_url]}#{config[:callback_path]}/#{token}"
98
+ end
99
+
84
100
  # The single place an app's login-request handler calls to get a
85
101
  # magic link to its owner: uses the `deliver:` callable passed to
86
102
  # `configure` (e.g. wrapping a mailer or a provider's HTTP API) when
@@ -94,10 +110,8 @@ module Monk
94
110
  # that Monk::Mail exists (docs/adr/0012-minimal-built-in-mailer.md):
95
111
  # it's usually a one-line Monk::Mail.deliver, but can be any channel.
96
112
  #
97
- # `link` is the app's own job to build (Monk doesn't own routing, so
98
- # it can't know the callback path) -- from a trusted origin, e.g.
99
- # Monk::Settings[:public_url], never from request headers like
100
- # X-Forwarded-Proto/Host, which any direct client can spoof.
113
+ # `link` is usually login_link(token), above. An app may build its
114
+ # own instead, from a trusted origin, never from request headers.
101
115
  # `token` is passed through for a deliver: that wants it (an SMS
102
116
  # body instead of a link, say); most won't need it.
103
117
  def deliver_link(email:, link:, token: nil)
@@ -254,6 +268,17 @@ module Monk
254
268
  "Monk::Auth is not configured -- call Monk::Auth.configure first" unless @config
255
269
  end
256
270
 
271
+ # An absolute path, kept without a trailing slash so login_link
272
+ # adds exactly one before the token.
273
+ def checked_callback_path(path)
274
+ unless path.is_a?(String) && path.start_with?("/")
275
+ raise Monk::InvalidAuthConfigError,
276
+ "Monk::Auth.configure(callback_path:) must be an absolute path like \"/auth/callback\", got #{path.inspect}"
277
+ end
278
+
279
+ path.chomp("/").freeze
280
+ end
281
+
257
282
  def hash_token(raw)
258
283
  Digest::SHA256.hexdigest(raw)
259
284
  end
@@ -299,3 +324,7 @@ module Monk
299
324
  Monk.freeze_hooks << self
300
325
  end
301
326
  end
327
+
328
+ # Monk::Auth::SendLoginLink, when jobs are loaded too -- either order works
329
+ # (lib/monk/jobs.rb ends the same way, docs/adr/0017).
330
+ require_relative "auth/send_login_link" if Monk.const_defined?(:Jobs)
data/lib/monk/base.rb CHANGED
@@ -12,6 +12,11 @@ module Monk
12
12
  EMPTY_ARRAY = [].freeze
13
13
  private_constant :EMPTY_ARRAY
14
14
 
15
+ # How much of an unhandled exception's backtrace #log_exception
16
+ # writes -- the same depth Monk::Jobs keeps for a failed job.
17
+ BACKTRACE_LINES = 10
18
+ private_constant :BACKTRACE_LINES
19
+
15
20
  # Action name -> path suffix (appended to the resource path) and verb(s)
16
21
  # it dispatches on, for #resources. :update registers both PATCH and PUT --
17
22
  # PATCH is the canonical partial-update verb, PUT accepted too since
@@ -162,6 +167,7 @@ module Monk
162
167
  [200, context.headers, [body]]
163
168
  rescue StandardError => e
164
169
  handler = error_handlers.find { |matcher, _| matcher.is_a?(Class) && e.is_a?(matcher) }
170
+ log_exception(env, e, handled: !handler.nil?)
165
171
  if handler
166
172
  context.status = 500
167
173
  body = context.instance_exec(context, &handler.last)
@@ -173,6 +179,20 @@ module Monk
173
179
  end
174
180
  end
175
181
 
182
+ # The request line alone ("GET /x -> 500") says nothing about why.
183
+ # Unhandled: an error, with the first backtrace lines. Handled by an
184
+ # `error` block: the app chose the response, so one info line --
185
+ # an error(NotFound) { halt 404 } shouldn't fill the log with ERRORs.
186
+ def log_exception(env, error, handled:)
187
+ line = "#{env["REQUEST_METHOD"]} #{env["PATH_INFO"]} raised #{error.class}: #{error.message}"
188
+ if handled
189
+ Monk::Log.info("#{line} (handled)")
190
+ else
191
+ trace = Array(error.backtrace).first(BACKTRACE_LINES).map { |frame| "\n #{frame}" }
192
+ Monk::Log.error("#{line}#{trace.join}")
193
+ end
194
+ end
195
+
176
196
  def log_request(env, status, start)
177
197
  duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(1)
178
198
  line = "#{Monk::Log.timestamp} #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]} -> #{status} (#{duration_ms}ms)"
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