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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +166 -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/base.rb +20 -0
- 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/adapters/pg.rb +3 -1
- data/lib/monk/jobs.rb +7 -0
- data/lib/monk/live/helpers.rb +13 -0
- data/lib/monk/live/policy.rb +39 -4
- data/lib/monk/live/session.rb +7 -0
- data/lib/monk/live.rb +17 -4
- 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/persistence/errors.rb +32 -0
- data/lib/monk/persistence/pg.rb +92 -1
- data/lib/monk/persistence/pool.rb +513 -0
- data/lib/monk/persistence.rb +196 -7
- 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 +14 -8
- data/lib/monk/templates/auth/setup.md +37 -0
- data/lib/monk/templates/auth/test/auth_test.rb +17 -0
- data/lib/monk/templates/base/CLAUDE.md +1 -0
- data/lib/monk/templates/base/Dockerfile +4 -3
- data/lib/monk/templates/base/Gemfile +7 -2
- data/lib/monk/templates/base/Rakefile +8 -0
- data/lib/monk/templates/base/agents.md +41 -0
- data/lib/monk/templates/base/app/app.rb +5 -0
- data/lib/monk/templates/base/app/routes/.keep +0 -0
- data/lib/monk/templates/base/app/views/index.erb +1 -1
- data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
- data/lib/monk/templates/base/config/load.rb +8 -0
- data/lib/monk/templates/base/config/settings.rb +4 -5
- data/lib/monk/templates/base/setup.md +22 -0
- data/lib/monk/templates/base/test/app_test.rb +12 -0
- data/lib/monk/templates/base/test/test_helper.rb +18 -0
- data/lib/monk/templates/jobs/agents.md +25 -0
- data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
- data/lib/monk/templates/jobs/bin/jobs +19 -13
- data/lib/monk/templates/jobs/setup.md +30 -0
- data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
- data/lib/monk/templates/live/agents.md +25 -0
- data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
- data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
- data/lib/monk/templates/live/app/routes/live.rb +10 -0
- data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
- data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
- data/lib/monk/templates/live/config/live.rb +30 -32
- data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
- data/lib/monk/templates/live/setup.md +39 -0
- data/lib/monk/templates/live/test/live_test.rb +22 -0
- data/lib/monk/templates/mail/agents.md +22 -0
- data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
- data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
- data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
- data/lib/monk/templates/mail/config/mail.rb +4 -4
- data/lib/monk/templates/mail/setup.md +32 -0
- data/lib/monk/templates/mail/test/mail_test.rb +13 -0
- data/lib/monk/templates/postgres/agents.md +25 -0
- data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
- data/lib/monk/templates/postgres/bin/console +3 -1
- data/lib/monk/templates/postgres/bin/migrate +23 -17
- data/lib/monk/templates/postgres/bin/setup_db +11 -5
- data/lib/monk/templates/postgres/config/persistence.rb +1 -0
- data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
- data/lib/monk/templates/postgres/setup.md +38 -0
- data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
- data/lib/monk/templates/redis/agents.md +18 -0
- data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
- data/lib/monk/templates/redis/config/redis.rb +13 -0
- data/lib/monk/templates/redis/setup.md +20 -0
- data/lib/monk/templates/redis/test/redis_test.rb +12 -0
- data/lib/monk/templates/websocket/agents.md +25 -0
- data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
- data/lib/monk/templates/websocket/bin/websocket_server +58 -0
- data/lib/monk/templates/websocket/config/websocket_postgres.rb +24 -0
- data/lib/monk/templates/websocket/config/websocket_redis.rb +21 -0
- data/lib/monk/templates/websocket/setup.md +51 -0
- data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/connection.rb +25 -6
- data/lib/monk/websocket/listeners.rb +24 -0
- data/lib/monk/websocket/pg_fanout.rb +143 -20
- data/lib/monk/websocket/redis_fanout.rb +78 -29
- data/lib/monk/websocket/registry.rb +24 -0
- data/lib/monk/websocket/server.rb +57 -16
- data/lib/monk/websocket.rb +2 -0
- data/lib/monk.rb +10 -0
- metadata +83 -14
- data/lib/monk/scaffold.rb +0 -1061
- data/lib/monk/templates/base/bin/websocket_server +0 -68
- 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 -42
- 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
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
|
|
98
|
-
#
|
|
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.
|