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