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
|
@@ -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
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Monk
|
|
4
|
+
module Generator
|
|
5
|
+
# What one `monk add` did, or would do: printed as text for a person
|
|
6
|
+
# (decision 25), as one JSON object with --json (decision 27), and
|
|
7
|
+
# turned into the exit code (decision 28) -- all from the same data.
|
|
8
|
+
class Result
|
|
9
|
+
EXIT_CODES = { ok: 0, usage_error: 1, missing_choice: 2, conflict: 3 }.freeze
|
|
10
|
+
|
|
11
|
+
# The text form's widest path column.
|
|
12
|
+
COLUMN = 30
|
|
13
|
+
# The text form's numbered steps; JSON keeps them all.
|
|
14
|
+
MAX_STEPS = 3
|
|
15
|
+
|
|
16
|
+
attr_reader :status, :written, :dry_run, :requested, :modules, :files, :skipped, :env, :examples, :services,
|
|
17
|
+
:next_steps, :docs, :closing, :message, :suggestion, :choice, :conflicts, :command
|
|
18
|
+
|
|
19
|
+
def initialize(status:, written:, dry_run: false, requested: [], modules: [], files: [], skipped: [], env: [],
|
|
20
|
+
examples: [], services: [], next_steps: [], docs: [], closing: nil, message: nil, suggestion: nil,
|
|
21
|
+
choice: nil, conflicts: [], command: nil)
|
|
22
|
+
@status = status
|
|
23
|
+
@written = written
|
|
24
|
+
@dry_run = dry_run
|
|
25
|
+
@requested = requested
|
|
26
|
+
@modules = modules
|
|
27
|
+
@files = files
|
|
28
|
+
@skipped = skipped
|
|
29
|
+
@env = env
|
|
30
|
+
@examples = examples
|
|
31
|
+
@services = services
|
|
32
|
+
@next_steps = next_steps
|
|
33
|
+
@docs = docs
|
|
34
|
+
@closing = closing
|
|
35
|
+
@message = message
|
|
36
|
+
@suggestion = suggestion
|
|
37
|
+
@choice = choice
|
|
38
|
+
@conflicts = conflicts
|
|
39
|
+
@command = command
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ok? = status == :ok
|
|
43
|
+
def exit_code = EXIT_CODES.fetch(status)
|
|
44
|
+
|
|
45
|
+
def to_h
|
|
46
|
+
hash = { status: status.to_s, written: written, requested: requested.map(&:to_s) }
|
|
47
|
+
if ok?
|
|
48
|
+
hash.merge!(
|
|
49
|
+
modules: modules.map { |entry| stringify(entry) },
|
|
50
|
+
files: files.map { |entry| file_json(entry) },
|
|
51
|
+
skipped: skipped.map { |entry| stringify(entry) },
|
|
52
|
+
env: env.map { |entry| stringify(entry.except(:module)) },
|
|
53
|
+
examples: examples.map { |entry| stringify(entry.except(:module)) },
|
|
54
|
+
services: services.map { |entry| stringify(entry) },
|
|
55
|
+
next: next_steps.map { |entry| stringify(entry) },
|
|
56
|
+
docs: docs,
|
|
57
|
+
)
|
|
58
|
+
hash[:dry_run] = true if dry_run
|
|
59
|
+
else
|
|
60
|
+
hash[:message] = message
|
|
61
|
+
hash[:suggestion] = suggestion if suggestion
|
|
62
|
+
hash[:choice] = stringify(choice.except(:describe, :command)) if choice
|
|
63
|
+
hash[:conflicts] = conflicts.map { |entry| stringify(entry) } unless conflicts.empty?
|
|
64
|
+
end
|
|
65
|
+
stringify(hash)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_json(*) = JSON.pretty_generate(to_h)
|
|
69
|
+
|
|
70
|
+
def to_text
|
|
71
|
+
case status
|
|
72
|
+
when :ok then ok_text
|
|
73
|
+
when :missing_choice then missing_choice_text
|
|
74
|
+
when :conflict then conflict_text
|
|
75
|
+
else "#{message}#{"\n #{suggestion}" if suggestion}\n\nNothing was written.\n"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# `monk new`'s text: the app created, its base files by directory, then
|
|
80
|
+
# each module added on top of it, as `monk add` shows them.
|
|
81
|
+
def new_text(app)
|
|
82
|
+
return to_text unless ok?
|
|
83
|
+
|
|
84
|
+
added = modules.select { |entry| entry[:action] == :added && entry[:name] != :base }
|
|
85
|
+
lines = [new_header(app, added), "", *base_summary]
|
|
86
|
+
added.each { |entry| lines.push("", entry[:name].to_s, *module_lines(entry[:name])) }
|
|
87
|
+
lines.concat(production_text)
|
|
88
|
+
lines.push("", "Next:", " cd #{app}", *step_lines)
|
|
89
|
+
lines << " then: monk add --list the modules you can add" if added.empty?
|
|
90
|
+
lines.push("", closing) if closing && !dry_run
|
|
91
|
+
"#{lines.join("\n")}\n"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def new_header(app, added)
|
|
97
|
+
verb = dry_run ? "Would create" : "Created"
|
|
98
|
+
return "#{verb} #{app}/ (bare Monk app)#{" — nothing written" if dry_run}." if added.empty?
|
|
99
|
+
|
|
100
|
+
requested_added = added.reject { |entry| entry[:reason] }.map { |entry| entry[:name] }
|
|
101
|
+
dependencies = added.select { |entry| entry[:reason] }.map { |entry| entry[:name] }
|
|
102
|
+
line = "#{verb} #{app}/ with #{names(requested_added)}"
|
|
103
|
+
if dependencies.any?
|
|
104
|
+
line += ", and #{names(dependencies)}, which #{requested_added.size > 1 ? "they need" : "it needs"}"
|
|
105
|
+
end
|
|
106
|
+
"#{line}#{" — nothing written" if dry_run}."
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The base app's files, one line per top-level directory.
|
|
110
|
+
def base_summary
|
|
111
|
+
base = files.select { |entry| entry[:module] == :base && entry[:action] == :created }
|
|
112
|
+
paths = base.map { |entry| entry[:path] }
|
|
113
|
+
top, nested = paths.partition { |path| !path.include?("/") }
|
|
114
|
+
lines = top.empty? ? [] : [" + #{top.join(", ")}"]
|
|
115
|
+
nested.group_by { |path| path.split("/").first }.each do |dir, inside|
|
|
116
|
+
children = inside.map { |path| path.split("/")[1] + (path.count("/") > 1 ? "/" : "") }.uniq
|
|
117
|
+
lines << " + #{"#{dir}/".ljust(9)}#{children.join(", ")}"
|
|
118
|
+
end
|
|
119
|
+
sections = files.select { |entry| entry[:sections] }.map { |entry| entry[:path] }
|
|
120
|
+
lines << " + #{sections.join(", ")}" unless sections.empty?
|
|
121
|
+
lines
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def ok_text
|
|
125
|
+
added = modules.select { |entry| entry[:action] == :added }
|
|
126
|
+
if added.empty?
|
|
127
|
+
return "#{names(requested)} #{requested.size > 1 ? "are" : "is"} already installed — nothing to do.\n"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
lines = [header(added), ""]
|
|
131
|
+
added.each do |entry|
|
|
132
|
+
body = module_lines(entry[:name])
|
|
133
|
+
lines << entry[:name].to_s if added.size > 1
|
|
134
|
+
lines.concat(body)
|
|
135
|
+
lines << "" if added.size > 1
|
|
136
|
+
end
|
|
137
|
+
lines.pop if lines.last == "" && added.size == 1
|
|
138
|
+
sections = files.select { |entry| entry[:sections] }
|
|
139
|
+
if sections.any?
|
|
140
|
+
names = sections.flat_map { |entry| entry[:items] }.uniq
|
|
141
|
+
label = names.size > 1 ? "sections" : "section"
|
|
142
|
+
lines << row("~", sections.map { |entry| entry[:path] }.join(", "), "#{label}: #{names.join(", ")}")
|
|
143
|
+
elsif lines.last == ""
|
|
144
|
+
lines.pop
|
|
145
|
+
end
|
|
146
|
+
lines.concat(production_text, next_text)
|
|
147
|
+
lines.push("", closing) if closing && !dry_run
|
|
148
|
+
"#{lines.join("\n")}\n"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def header(added)
|
|
152
|
+
to_add = added.reject { |entry| entry[:reason] }.map { |entry| entry[:name] }
|
|
153
|
+
dependencies = added.select { |entry| entry[:reason] }.map { |entry| entry[:name] }
|
|
154
|
+
installed = modules.select { |entry| entry[:action] == :already_installed }.map { |entry| entry[:name] }
|
|
155
|
+
verb = dry_run ? "Would add" : "Adding"
|
|
156
|
+
line = "#{verb} #{names(to_add)}"
|
|
157
|
+
if dependencies.any?
|
|
158
|
+
line += ", which #{to_add.size > 1 ? "need" : "needs"} #{names(dependencies)} — adding " \
|
|
159
|
+
"#{dependencies.size > 1 ? "those" : "it"} first"
|
|
160
|
+
end
|
|
161
|
+
already = "#{names(installed)} already installed" if installed.any? && dependencies.empty?
|
|
162
|
+
return "#{line} (#{["nothing written", already].compact.join("; ")}):" if dry_run
|
|
163
|
+
|
|
164
|
+
already ? "#{line} (#{already})." : "#{line}."
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def module_lines(name)
|
|
168
|
+
own = files.select { |entry| entry[:module] == name }
|
|
169
|
+
created = own.select { |entry| entry[:action] == :created }
|
|
170
|
+
lines = migrations_grouped(created).map { |entry| row("+", entry[:path], created_note(entry)) }
|
|
171
|
+
lines += skipped.select { |entry| entry[:module] == name }.map do |entry|
|
|
172
|
+
row("!", entry[:path], "exists and differs — left as is")
|
|
173
|
+
end
|
|
174
|
+
lines + appended_grouped(own.select { |entry| entry[:action] == :appended && !entry[:sections] })
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def created_note(entry)
|
|
178
|
+
return "example: #{entry[:examples].map(&:first).uniq.join(", ")} (commented)" if entry[:examples]&.any?
|
|
179
|
+
|
|
180
|
+
"demo, development only" if entry[:role] == :demo
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# db/migrate/..._name.up.sql and .down.sql as one line.
|
|
184
|
+
def migrations_grouped(created)
|
|
185
|
+
created.each_with_object([]) do |entry, rows|
|
|
186
|
+
if entry[:path].end_with?(".down.sql") && rows.last && rows.last[:path] == entry[:path].sub(/\.down\.sql\z/,
|
|
187
|
+
".up.sql",)
|
|
188
|
+
rows[-1] = rows.last.merge(path: entry[:path].sub(/\.down\.sql\z/, ".{up,down}.sql"))
|
|
189
|
+
else
|
|
190
|
+
rows << entry
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The files that got the same lines (the .env files, say), as one line.
|
|
196
|
+
def appended_grouped(appended)
|
|
197
|
+
appended.group_by { |entry| compact_keys(entry[:items]) }.map do |items, entries|
|
|
198
|
+
row("~", entries.map { |entry| entry[:path] }.join(", "), items)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# DB_HOST, DB_NAME... as DB_*.
|
|
203
|
+
def compact_keys(items)
|
|
204
|
+
prefix = items.first.to_s[/\A[A-Z]+_/]
|
|
205
|
+
return "#{prefix}*" if prefix && items.size > 1 && items.all? { |item| item.start_with?(prefix) }
|
|
206
|
+
|
|
207
|
+
items.join(", ")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def production_text
|
|
211
|
+
return [] if env.empty?
|
|
212
|
+
|
|
213
|
+
width = env.map { |entry| entry[:key].size }.max
|
|
214
|
+
["", "Set before production:", *env.map { |entry| " #{entry[:key].ljust(width)} #{entry[:note]}" }]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def next_text = ["", "Next:", *step_lines]
|
|
218
|
+
|
|
219
|
+
# At most three numbered steps, in the order they're done. Past three,
|
|
220
|
+
# bin/setup_db joins the tests' line, the first two and that line are
|
|
221
|
+
# numbered, and what's left is named on one more line each, with the
|
|
222
|
+
# SETUP.md section that explains it.
|
|
223
|
+
def step_lines
|
|
224
|
+
steps = next_steps.dup
|
|
225
|
+
if steps.size > MAX_STEPS && (migrate = steps.find { |step| step[:run] == "bin/setup_db" })
|
|
226
|
+
steps.delete(migrate)
|
|
227
|
+
steps[-1] = steps.last.merge(run: "bin/setup_db && #{steps.last[:run]}")
|
|
228
|
+
end
|
|
229
|
+
rest = []
|
|
230
|
+
if steps.size > MAX_STEPS
|
|
231
|
+
rest = steps[(MAX_STEPS - 1)...-1]
|
|
232
|
+
steps = steps.first(MAX_STEPS - 1) + [steps.last]
|
|
233
|
+
end
|
|
234
|
+
lines = steps.each_with_index.map { |step, index| " #{index + 1}. #{step_text(step)}" }
|
|
235
|
+
lines + rest.map { |step| " … also: #{step_text(step)}#{" (SETUP.md › #{step[:module]})" if step[:module]}" }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def step_text(step)
|
|
239
|
+
[step[:run] || step[:do], step[:note]].compact.join(" ")
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def missing_choice_text
|
|
243
|
+
lines = ["#{command}: #{message} Pass one:", ""]
|
|
244
|
+
width = choice[:values].map(&:size).max
|
|
245
|
+
choice[:values].each do |value|
|
|
246
|
+
described = choice[:describe]&.fetch(value, nil)
|
|
247
|
+
lines << " #{command} --#{choice[:option]}=#{value.ljust(width)}#{" #{described}" if described}"
|
|
248
|
+
end
|
|
249
|
+
"#{lines.join("\n")}\n\nNothing was written.\n"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def conflict_text
|
|
253
|
+
lines = [message]
|
|
254
|
+
conflicts.each { |entry| lines << " Monk's version of #{entry[:path]}: #{entry[:show]}" }
|
|
255
|
+
"#{lines.join("\n")}\n\nNothing was written.\n"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def row(mark, path, note = nil)
|
|
259
|
+
note ? " #{mark} #{path.ljust(COLUMN)} #{note}" : " #{mark} #{path}"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def names(list)
|
|
263
|
+
list = list.map(&:to_s)
|
|
264
|
+
list.size <= 1 ? list.join : "#{list[0..-2].join(", ")} and #{list.last}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def file_json(entry)
|
|
268
|
+
json = entry.slice(:path, :module, :role, :action, :items)
|
|
269
|
+
json[:examples] = entry[:examples].map(&:first).uniq if entry[:examples]&.any?
|
|
270
|
+
json[:module] = nil if entry[:sections]
|
|
271
|
+
stringify(json.compact)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def stringify(hash)
|
|
275
|
+
hash.to_h { |key, value| [key.to_s, value.is_a?(Symbol) ? value.to_s : value] }
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|