brut 0.0.1 → 0.0.2
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/.gitignore +6 -0
- data/Gemfile.lock +66 -1
- data/README.md +36 -0
- data/Rakefile +22 -0
- data/brut.gemspec +7 -0
- data/doc-src/architecture.md +102 -0
- data/doc-src/assets.md +98 -0
- data/doc-src/forms.md +214 -0
- data/doc-src/handlers.md +83 -0
- data/doc-src/javascript.md +265 -0
- data/doc-src/keyword-injection.md +183 -0
- data/doc-src/pages.md +210 -0
- data/doc-src/route-hooks.md +59 -0
- data/docs-todo.md +32 -0
- data/lib/brut/back_end/seed_data.rb +5 -1
- data/lib/brut/back_end/validator.rb +1 -1
- data/lib/brut/back_end/validators/form_validator.rb +31 -6
- data/lib/brut/cli/app.rb +100 -4
- data/lib/brut/cli/app_runner.rb +38 -5
- data/lib/brut/cli/apps/build_assets.rb +4 -6
- data/lib/brut/cli/apps/db.rb +2 -7
- data/lib/brut/cli/apps/scaffold.rb +413 -7
- data/lib/brut/cli/apps/test.rb +14 -1
- data/lib/brut/cli/command.rb +141 -6
- data/lib/brut/cli/error.rb +30 -3
- data/lib/brut/cli/execution_results.rb +44 -6
- data/lib/brut/cli/executor.rb +21 -1
- data/lib/brut/cli/options.rb +29 -2
- data/lib/brut/cli/output.rb +47 -0
- data/lib/brut/cli.rb +26 -0
- data/lib/brut/factory_bot.rb +2 -0
- data/lib/brut/framework/app.rb +38 -5
- data/lib/brut/framework/config.rb +97 -54
- data/lib/brut/framework/container.rb +97 -33
- data/lib/brut/framework/errors/abstract_method.rb +3 -7
- data/lib/brut/framework/errors/missing_parameter.rb +12 -0
- data/lib/brut/framework/errors/no_class_for_path.rb +25 -0
- data/lib/brut/framework/errors/not_found.rb +12 -2
- data/lib/brut/framework/errors/not_implemented.rb +14 -0
- data/lib/brut/framework/errors.rb +18 -0
- data/lib/brut/framework/fussy_type_enforcement.rb +17 -12
- data/lib/brut/framework/mcp.rb +106 -49
- data/lib/brut/framework/project_environment.rb +9 -0
- data/lib/brut/framework.rb +1 -0
- data/lib/brut/front_end/asset_metadata.rb +7 -1
- data/lib/brut/front_end/component.rb +129 -38
- data/lib/brut/front_end/components/constraint_violations.rb +57 -0
- data/lib/brut/front_end/components/form_tag.rb +23 -32
- data/lib/brut/front_end/components/i18n_translations.rb +34 -1
- data/lib/brut/front_end/components/input.rb +3 -0
- data/lib/brut/front_end/components/inputs/csrf_token.rb +2 -0
- data/lib/brut/front_end/components/inputs/radio_button.rb +41 -0
- data/lib/brut/front_end/components/inputs/select.rb +26 -2
- data/lib/brut/front_end/components/inputs/text_field.rb +27 -5
- data/lib/brut/front_end/components/inputs/textarea.rb +24 -4
- data/lib/brut/front_end/components/locale_detection.rb +8 -1
- data/lib/brut/front_end/components/page_identifier.rb +2 -0
- data/lib/brut/front_end/components/time.rb +95 -0
- data/lib/brut/front_end/components/traceparent.rb +22 -0
- data/lib/brut/front_end/download.rb +11 -0
- data/lib/brut/front_end/flash.rb +32 -0
- data/lib/brut/front_end/form.rb +109 -106
- data/lib/brut/front_end/forms/constraint_violation.rb +16 -11
- data/lib/brut/front_end/forms/input.rb +30 -42
- data/lib/brut/front_end/forms/input_declarations.rb +90 -0
- data/lib/brut/front_end/forms/input_definition.rb +45 -30
- data/lib/brut/front_end/forms/radio_button_group_input.rb +46 -0
- data/lib/brut/front_end/forms/radio_button_group_input_definition.rb +29 -0
- data/lib/brut/front_end/forms/select_input.rb +47 -0
- data/lib/brut/front_end/forms/select_input_definition.rb +27 -0
- data/lib/brut/front_end/forms/validity_state.rb +23 -9
- data/lib/brut/front_end/handler.rb +27 -8
- data/lib/brut/front_end/handlers/csp_reporting_handler.rb +4 -2
- data/lib/brut/front_end/handlers/instrumentation_handler.rb +99 -0
- data/lib/brut/front_end/handlers/locale_detection_handler.rb +9 -4
- data/lib/brut/front_end/handlers/missing_handler.rb +9 -0
- data/lib/brut/front_end/handling_results.rb +14 -4
- data/lib/brut/front_end/http_method.rb +13 -1
- data/lib/brut/front_end/http_status.rb +10 -0
- data/lib/brut/front_end/layouts/_internal.html.erb +68 -0
- data/lib/brut/front_end/middleware.rb +5 -0
- data/lib/brut/front_end/middlewares/annotate_brut_owned_paths.rb +15 -0
- data/lib/brut/front_end/middlewares/favicon.rb +16 -0
- data/lib/brut/front_end/middlewares/open_telemetry_span.rb +12 -0
- data/lib/brut/front_end/middlewares/reload_app.rb +27 -9
- data/lib/brut/front_end/page.rb +50 -11
- data/lib/brut/front_end/pages/_missing_page.html.erb +17 -0
- data/lib/brut/front_end/pages/missing_page.rb +36 -0
- data/lib/brut/front_end/request_context.rb +117 -8
- data/lib/brut/front_end/route_hook.rb +43 -1
- data/lib/brut/front_end/route_hooks/age_flash.rb +3 -2
- data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +8 -0
- data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +18 -10
- data/lib/brut/front_end/route_hooks/locale_detection.rb +11 -5
- data/lib/brut/front_end/route_hooks/setup_request_context.rb +7 -4
- data/lib/brut/front_end/routing.rb +138 -31
- data/lib/brut/front_end/session.rb +86 -7
- data/lib/brut/front_end/template.rb +17 -2
- data/lib/brut/front_end/templates/block_filter.rb +4 -3
- data/lib/brut/front_end/templates/erb_parser.rb +1 -1
- data/lib/brut/front_end/templates/escapable_filter.rb +2 -0
- data/lib/brut/front_end/templates/html_safe_string.rb +30 -2
- data/lib/brut/front_end/templates/locator.rb +60 -0
- data/lib/brut/i18n/base_methods.rb +4 -0
- data/lib/brut/i18n.rb +1 -0
- data/lib/brut/instrumentation/logger_span_exporter.rb +75 -0
- data/lib/brut/instrumentation/open_telemetry.rb +107 -0
- data/lib/brut/instrumentation.rb +4 -6
- data/lib/brut/junk_drawer.rb +54 -4
- data/lib/brut/sinatra_helpers.rb +42 -38
- data/lib/brut/spec_support/clock_support.rb +6 -0
- data/lib/brut/spec_support/component_support.rb +53 -26
- data/lib/brut/spec_support/e2e_test_server.rb +82 -0
- data/lib/brut/spec_support/enhanced_node.rb +45 -0
- data/lib/brut/spec_support/general_support.rb +14 -3
- data/lib/brut/spec_support/handler_support.rb +2 -0
- data/lib/brut/spec_support/matcher.rb +6 -3
- data/lib/brut/spec_support/matchers/be_page_for.rb +3 -2
- data/lib/brut/spec_support/matchers/have_constraint_violation.rb +22 -9
- data/lib/brut/spec_support/matchers/have_html_attribute.rb +9 -2
- data/lib/brut/spec_support/matchers/have_i18n_string.rb +24 -0
- data/lib/brut/spec_support/matchers/have_link_to.rb +14 -0
- data/lib/brut/spec_support/matchers/have_redirected_to.rb +30 -0
- data/lib/brut/spec_support/matchers/have_rendered.rb +4 -11
- data/lib/brut/spec_support/matchers/have_returned_http_status.rb +16 -8
- data/lib/brut/spec_support/rspec_setup.rb +182 -0
- data/lib/brut/spec_support.rb +8 -3
- data/lib/brut/version.rb +2 -1
- data/lib/brut.rb +28 -5
- data/lib/sequel/extensions/brut_instrumentation.rb +5 -27
- data/lib/sequel/extensions/brut_migrations.rb +18 -8
- data/lib/sequel/plugins/created_at.rb +2 -0
- data/lib/sequel/plugins/external_id.rb +39 -1
- data/lib/sequel/plugins/find_bang.rb +4 -1
- metadata +140 -13
- data/lib/brut/back_end/action.rb +0 -3
- data/lib/brut/back_end/result.rb +0 -46
- data/lib/brut/front_end/components/timestamp.rb +0 -33
- data/lib/brut/instrumentation/basic.rb +0 -66
- data/lib/brut/instrumentation/event.rb +0 -19
- data/lib/brut/instrumentation/http_event.rb +0 -5
- data/lib/brut/instrumentation/subscriber.rb +0 -41
data/lib/brut/cli/command.rb
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
require "optparse"
|
|
2
|
+
# Base class for subcommands of a {Brut::CLI::App}. You must implement {#execute} to perform whatever action this command must perform.
|
|
2
3
|
class Brut::CLI::Command
|
|
3
4
|
include Brut::CLI::ExecutionResults
|
|
4
5
|
include Brut::I18n::ForCLI
|
|
6
|
+
include Brut::Framework::Errors
|
|
5
7
|
|
|
8
|
+
# Call this to set the one-line description of this command
|
|
9
|
+
#
|
|
10
|
+
# @param new_description [String] When present, sets the description of this command. When omitted, returns the current description.
|
|
11
|
+
# @return [String] the current description (if called with no parameters)
|
|
6
12
|
def self.description(new_description=nil)
|
|
7
13
|
if new_description.nil?
|
|
8
14
|
return @description.to_s
|
|
@@ -10,6 +16,12 @@ class Brut::CLI::Command
|
|
|
10
16
|
@description = new_description
|
|
11
17
|
end
|
|
12
18
|
end
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Call this to set a an additional detailed description of this command. Currently, this should not be formatted and will be shown all on one line. This is shown after the `description`, so this text can follow from that, without having to restate it.
|
|
22
|
+
#
|
|
23
|
+
# @param new_description [String] When present, sets the detailed description of this command. When omitted, returns the current detailed description.
|
|
24
|
+
# @return [String] the current detailed description (if called with no parameters)
|
|
13
25
|
def self.detailed_description(new_description=nil)
|
|
14
26
|
if new_description.nil?
|
|
15
27
|
if @detailed_description.nil?
|
|
@@ -20,6 +32,17 @@ class Brut::CLI::Command
|
|
|
20
32
|
@detailed_description = new_description
|
|
21
33
|
end
|
|
22
34
|
end
|
|
35
|
+
|
|
36
|
+
# Set a description of the command line args this command accepts. Args are any part of the command line that is not a switch or
|
|
37
|
+
# flag. The string you give will be used only for documentation. Typically, you would format it in one a few ways:
|
|
38
|
+
#
|
|
39
|
+
# * `args "some_arg"` indicates that exactly one arg called `some_arg` is required
|
|
40
|
+
# * `args "[some_arg]"` indicates that exactly one arg called `some_arg` is optional
|
|
41
|
+
# * `args "args..."` indicates that
|
|
42
|
+
# * `args "[args...]"` indicates that zero or more args called `args` are accepted
|
|
43
|
+
#
|
|
44
|
+
# @param [String] new_args documentation for this command's args. If omitted, returns the current value.
|
|
45
|
+
# @return [String the current value (if called with no parameters)
|
|
23
46
|
def self.args(new_args=nil)
|
|
24
47
|
if new_args.nil?
|
|
25
48
|
return @args.to_s
|
|
@@ -27,19 +50,67 @@ class Brut::CLI::Command
|
|
|
27
50
|
@args = new_args
|
|
28
51
|
end
|
|
29
52
|
end
|
|
53
|
+
|
|
54
|
+
# Call this for each environment variable this *command* responds to. These would be variables that affect only this command. For
|
|
55
|
+
# app-wide environment variables, see {Brut::CLI::App.env_var}.
|
|
56
|
+
#
|
|
57
|
+
# @param var_name [String] Declares that this command recognizes this environment variable.
|
|
58
|
+
# @param purpose [String] An explanation for how this environment variable affects the command. Used in documentation.
|
|
59
|
+
def self.env_var(var_name,purpose:)
|
|
60
|
+
env_vars[var_name] = purpose
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Access all configured environment variables.
|
|
64
|
+
# @!visibility private
|
|
65
|
+
def self.env_vars
|
|
66
|
+
@env_vars ||= {
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns the name of this command, for use on the command line. By default, returns the "underscorized" name of this class
|
|
71
|
+
# (excluding any module namespaces). For exaple, if your command's class is `MyApp::CLI::Commands::ClearFiles::DryRun`, the command
|
|
72
|
+
# name would be `"dry_run"`. You can override this if you want something different. It is recommended that it not include spaces
|
|
73
|
+
# or other characters meaningful to the shell, but if you like quotes, cool.
|
|
30
74
|
def self.command_name = RichString.new(self.name.split(/::/).last).underscorized
|
|
75
|
+
|
|
76
|
+
# Checks if a given string matches this command name. This exists to allow the use of underscores or dashes as delimiters. Ruby
|
|
77
|
+
# likes underscores, but the shell vibe is often dashes.
|
|
78
|
+
#
|
|
79
|
+
# @param [String] string the command given on the command line
|
|
80
|
+
# @return [true|false] true if `string` is considered an exact match for this command's name.
|
|
31
81
|
def self.name_matches?(string)
|
|
32
82
|
self.command_name == string || self.command_name.to_s.gsub(/_/,"-") == string
|
|
33
83
|
end
|
|
84
|
+
|
|
85
|
+
# Provides access to an `OptionParser` you can use to declare flags and switches that should be accepted only by this command.
|
|
86
|
+
# The way to use this is to call `.on` and provide a description for an option as you would to `OptionParser`.
|
|
87
|
+
# The only difference is that you should not pass a block to this. When the command line is parsed, the results will be placed
|
|
88
|
+
# into a {Brut::CLI::Options} instance made available to your command.
|
|
89
|
+
#
|
|
90
|
+
# @return [OptionParser]
|
|
34
91
|
def self.opts
|
|
35
92
|
self.option_parser
|
|
36
93
|
end
|
|
94
|
+
|
|
95
|
+
# Returns the configured `OptionParser` used to parse the command portion of the command line.
|
|
96
|
+
# If you don't want to call {.opts}, you can create and return
|
|
97
|
+
# a fully-formed `OptionParser` by overriding this method. By default, it will create one with a conventional banner.
|
|
98
|
+
#
|
|
99
|
+
# @return [OptionParser]
|
|
37
100
|
def self.option_parser
|
|
38
101
|
@option_parser ||= OptionParser.new do |opts|
|
|
39
102
|
opts.banner = "%{app} %{global_options} #{command_name} %{command_options} %{args}"
|
|
40
103
|
end
|
|
41
104
|
end
|
|
42
105
|
|
|
106
|
+
# Call this if this command requires a project environment as context for what it does. When called, this will do a few things:
|
|
107
|
+
#
|
|
108
|
+
# * Your command (not app) will recognize `--env=ENVIRONMENT` as a global option
|
|
109
|
+
# * Your command will document that it recognizes the `RACK_ENV` environment variable.
|
|
110
|
+
#
|
|
111
|
+
# @see Brut::CLI::App.requires_project_env
|
|
112
|
+
#
|
|
113
|
+
# @param default [String] name of the environment to use if none was specified. `nil` should be used to require the environment to be specified explicitly.
|
|
43
114
|
def self.requires_project_env(default: "development")
|
|
44
115
|
default_message = if default.nil?
|
|
45
116
|
""
|
|
@@ -49,11 +120,22 @@ class Brut::CLI::Command
|
|
|
49
120
|
opts.on("--env=ENVIRONMENT","Project environment#{default_message}")
|
|
50
121
|
@default_env = default
|
|
51
122
|
@requires_project_env = true
|
|
123
|
+
self.env_var("RACK_ENV",purpose: "default project environment when --env is omitted")
|
|
52
124
|
end
|
|
53
125
|
|
|
126
|
+
# Returns the default project env, based on the logic described in {.requires_project_env}
|
|
54
127
|
def self.default_env = @default_env
|
|
128
|
+
# Returns true if this app requires a project env
|
|
55
129
|
def self.requires_project_env? = @requires_project_env
|
|
56
130
|
|
|
131
|
+
# Creates the command before executing it. Generally you would not call this directly.
|
|
132
|
+
#
|
|
133
|
+
# @param [Brut::CLI::Options] command_options the command options parsed from the command line.
|
|
134
|
+
# @param [Brut::CLI::Options] global_options the global options parsed from the command line.
|
|
135
|
+
# @param [Array] args Any unparsed arguments
|
|
136
|
+
# @param [Brut::CLI::Output] out an IO used to send messages to the standard output
|
|
137
|
+
# @param [Brut::CLI::Output] err an IO used to send messages to the standard error
|
|
138
|
+
# @param [Brut::CLI::Executor] executor used to execute child processes instead of e.g. `system`
|
|
57
139
|
def initialize(command_options:,global_options:, args:,out:,err:,executor:)
|
|
58
140
|
@command_options = command_options
|
|
59
141
|
@global_options = global_options
|
|
@@ -66,8 +148,20 @@ class Brut::CLI::Command
|
|
|
66
148
|
end
|
|
67
149
|
end
|
|
68
150
|
|
|
151
|
+
# Convienince method to call {Brut::CLI::Executor#system!} on the executor given in the constructor.
|
|
152
|
+
# @param (see Brut::CLI::Executor#system!)
|
|
153
|
+
# @return (see Brut::CLI::Executor#system!)
|
|
69
154
|
def system!(*args) = @executor.system!(*args)
|
|
70
155
|
|
|
156
|
+
# Use this inside {#execute} to createa compound command that executes other commands that are a part of your CLI app.
|
|
157
|
+
# Note that each command will be given the same global and commmand options and the same arguments, so these commands must be able
|
|
158
|
+
# to complete as desired in that way.
|
|
159
|
+
#
|
|
160
|
+
# Note that since commands are just classes, you can certianly create them however you like and call `execute` yourself.
|
|
161
|
+
#
|
|
162
|
+
# @param [Enumerable<Class>] command_klasses one or more classes that subclass {Brut::CLI::Command} to delegate to.
|
|
163
|
+
# @return [Brut::CLI::ExecutionResults::Result] the first non successful result is returned and processing is stopped, otherwise returns the
|
|
164
|
+
# result of the last class executed.
|
|
71
165
|
def delegate_to_commands(*command_klasses)
|
|
72
166
|
result = nil
|
|
73
167
|
command_klasses.each do |command_klass|
|
|
@@ -80,28 +174,52 @@ class Brut::CLI::Command
|
|
|
80
174
|
result
|
|
81
175
|
end
|
|
82
176
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
177
|
+
# You must implement this to perform whatever action your command needs to perform. In order to do this, you will have access to:
|
|
178
|
+
#
|
|
179
|
+
# * {#options} - the command options passed on the command line
|
|
180
|
+
# * {#global_options} - the global options passed on the command line
|
|
181
|
+
# * {#args} - the args passed on the command line
|
|
182
|
+
# * {#out} - an IO you should use to print messages to the standard out.
|
|
183
|
+
# * {#err} - on IO you should use to print messages to the standard error.
|
|
184
|
+
# * {#system!} - the method you should use to spawn child processes.
|
|
185
|
+
#
|
|
186
|
+
# @return [Brut::CLI::ExecutionResults::Result] a description of what happened during processing. It is preferable to try to return
|
|
187
|
+
# something instead of raising an exception.
|
|
188
|
+
# @raise [Brurt::CLI::Error] if thrown, this will be caught and handled by {Brut::CLI::AppRunner}.
|
|
189
|
+
# @raise [StandardError] if thrown, this will bubble up and show your user a very sad stack trace that will make them cry. Don't.
|
|
88
190
|
def execute
|
|
89
|
-
|
|
191
|
+
abstract_method!
|
|
90
192
|
end
|
|
91
193
|
|
|
194
|
+
# Called before any execution or bootstrapping happens but after {Brut::CLI::App#before_execute} is called. This will have access
|
|
195
|
+
# to everything {#execute} can access.
|
|
196
|
+
# @raise [Brurt::CLI::Error] if thrown, this will be caught and handled by {Brut::CLI::AppRunner} and execution will be aborted.
|
|
197
|
+
# @raise [StandardError] if thrown, this will bubble up and show your user a very sad stack trace that will make them cry. Don't.
|
|
92
198
|
def before_execute
|
|
93
199
|
end
|
|
94
200
|
|
|
201
|
+
# @!visibility private
|
|
95
202
|
def set_env_if_needed
|
|
96
203
|
if self.class.requires_project_env?
|
|
97
204
|
ENV["RACK_ENV"] = options.env
|
|
98
205
|
end
|
|
99
206
|
end
|
|
100
207
|
|
|
208
|
+
# Called if there is an exception during bootstrapping. By default, it re-raises the exception, which cases the command to abort.
|
|
209
|
+
# The reason you may want to overrid this is that your command line app may exist to handle a bootstrapping exception. For example,
|
|
210
|
+
# the built-in {Brut::CLI::Apps::DB} app will catch various database connection errors and then create or migrate the database.
|
|
211
|
+
#
|
|
212
|
+
# Yes, I realize this means we are using exceptions for control flow. It's fine.
|
|
213
|
+
#
|
|
214
|
+
# @param [StandardError] ex whichever exception was caught during bootstrapping
|
|
215
|
+
# @return [void] ignored
|
|
216
|
+
# @raise [Brurt::CLI::Error] if thrown, this will be caught and handled by {Brut::CLI::AppRunner} and execution will be aborted.
|
|
217
|
+
# @raise [StandardError] if thrown, this will bubble up and show your user a very sad stack trace that will make them cry. Don't.
|
|
101
218
|
def handle_bootstrap_exception(ex)
|
|
102
219
|
raise ex
|
|
103
220
|
end
|
|
104
221
|
|
|
222
|
+
# @!visibility private
|
|
105
223
|
def bootstrap!(project_root:, configure_only:)
|
|
106
224
|
require "bundler"
|
|
107
225
|
Bundler.require(:default, ENV["RACK_ENV"].to_sym)
|
|
@@ -116,15 +234,32 @@ class Brut::CLI::Command
|
|
|
116
234
|
|
|
117
235
|
private
|
|
118
236
|
|
|
237
|
+
# @!visibility public
|
|
238
|
+
# @return [Brut::CLI::Options] the command options parsed on the command line
|
|
119
239
|
def options = @command_options
|
|
240
|
+
# @!visibility public
|
|
241
|
+
# @return [Brut::CLI::Options] the global options parsed on the command line
|
|
120
242
|
def global_options = @global_options
|
|
243
|
+
# @!visibility public
|
|
244
|
+
# @return [Array<String>] the arguments parsed from the command line
|
|
121
245
|
def args = @args
|
|
246
|
+
# @!visibility public
|
|
247
|
+
# @return [Brut::CLI::Output] IO to use for sending messages to the standard output
|
|
122
248
|
def out = @out
|
|
249
|
+
# @!visibility public
|
|
250
|
+
# @return [Brut::CLI::Output] IO to use for sending messages to the standard error
|
|
123
251
|
def err = @err
|
|
124
252
|
|
|
253
|
+
# Exists to warn users not to use `puts`
|
|
125
254
|
def puts(...)
|
|
126
255
|
warn("Your CLI apps should use out and err to produce terminal output, not puts", uplevel: 1)
|
|
127
256
|
Kernel.puts(...)
|
|
128
257
|
end
|
|
129
258
|
|
|
259
|
+
def delegate_to_command(command_klass)
|
|
260
|
+
command = command_klass.new(command_options: options, global_options:, args:, out:, err:, executor: @executor)
|
|
261
|
+
as_execution_result(command.execute)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
|
|
130
265
|
end
|
data/lib/brut/cli/error.rb
CHANGED
|
@@ -1,12 +1,39 @@
|
|
|
1
|
-
#
|
|
2
|
-
#
|
|
1
|
+
# All errors from a Brut CLI should extend this. Any error that does will be caught and handled without showing the user a stack
|
|
2
|
+
# trace.
|
|
3
3
|
class Brut::CLI::Error < StandardError
|
|
4
4
|
end
|
|
5
|
+
# Raised when a child process executed by {Brut::CLI::Executor} returns a nonzero exit status.
|
|
6
|
+
#
|
|
7
|
+
# @see Brut::CLI::Executor#system!
|
|
5
8
|
class Brut::CLI::SystemExecError < Brut::CLI::Error
|
|
6
|
-
|
|
9
|
+
# @return [String|Array] command line invocation that caused the error
|
|
10
|
+
attr_reader :command
|
|
11
|
+
# @return [Integer] exit status of the command
|
|
12
|
+
attr_reader :exit_status
|
|
13
|
+
# @param [String|Array] command or args passed to {Brut::CLI::Executor#system!}.
|
|
14
|
+
# @param [Integer] exit_status the exit status of the command.
|
|
7
15
|
def initialize(command,exit_status)
|
|
8
16
|
super("#{command} failed - exited #{exit_status}")
|
|
9
17
|
@command = command
|
|
10
18
|
@exit_status = exit_status
|
|
11
19
|
end
|
|
12
20
|
end
|
|
21
|
+
|
|
22
|
+
# Raised when an `OptionParser::ParseError` is caught to provide a more useful
|
|
23
|
+
# error message given the global/command optiona dichotomy.
|
|
24
|
+
class Brut::CLI::InvalidOption < Brut::CLI::Error
|
|
25
|
+
def initialize(option_parser_parse_error, context:)
|
|
26
|
+
args = option_parser_parse_error.args
|
|
27
|
+
count_message = if option_parser_parse_error.args.length == 1
|
|
28
|
+
"isn't a valid option"
|
|
29
|
+
else
|
|
30
|
+
"aren't valid options"
|
|
31
|
+
end
|
|
32
|
+
type_message = if context.kind_of?(Class)
|
|
33
|
+
"for the '#{context.command_name}' command"
|
|
34
|
+
else
|
|
35
|
+
"for the app globally"
|
|
36
|
+
end
|
|
37
|
+
super("#{args.join(", ")} #{count_message} #{type_message}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -1,30 +1,43 @@
|
|
|
1
1
|
module Brut
|
|
2
2
|
module CLI
|
|
3
|
+
# Included in commands to allow convienient ways to return structured information about what happened during command
|
|
4
|
+
# execution.
|
|
3
5
|
module ExecutionResults
|
|
6
|
+
# Base class for all results.
|
|
4
7
|
class Result
|
|
8
|
+
# @return [Strting] any message provided in the results. Generally `nil` for successful results.
|
|
5
9
|
attr_reader :message
|
|
10
|
+
|
|
11
|
+
# Create a new result
|
|
12
|
+
#
|
|
13
|
+
# @param exit_status [Integer] exit status desired for this command. 0 is treated as success by any command that called your
|
|
14
|
+
# command. All other values have command-defined meanings.
|
|
15
|
+
# @param message [String|nil] a message to explain the results, if relevant.
|
|
6
16
|
def initialize(exit_status:,message:nil)
|
|
7
17
|
@exit_status = exit_status
|
|
8
18
|
@message = message
|
|
9
19
|
end
|
|
10
20
|
|
|
11
|
-
#
|
|
21
|
+
# @return [true|false] true if execution internal to the command should stop
|
|
12
22
|
def stop? = @exit_status != 0
|
|
13
|
-
#
|
|
23
|
+
# @return [true|false] true if the execution of the command succeeded or didn't error
|
|
14
24
|
def ok? = @exit_status == 0
|
|
15
|
-
#
|
|
25
|
+
# @return [Integer] the exit status
|
|
16
26
|
def to_i = @exit_status
|
|
27
|
+
# @return [true|false] true if the result means that the user should be show CLI usage in addition to any messaging.
|
|
17
28
|
def show_usage? = false
|
|
18
29
|
end
|
|
19
30
|
|
|
20
|
-
#
|
|
31
|
+
# @!visibility private
|
|
21
32
|
class Stop < Result
|
|
22
33
|
def initialize
|
|
23
34
|
super(exit_status: 0)
|
|
24
35
|
end
|
|
36
|
+
# @return [true] true
|
|
25
37
|
def stop? = true
|
|
26
38
|
end
|
|
27
39
|
|
|
40
|
+
# @!visibility private
|
|
28
41
|
class ShowCLIUsage < Stop
|
|
29
42
|
attr_reader :command_klass
|
|
30
43
|
def initialize(command_klass:)
|
|
@@ -34,14 +47,14 @@ module Brut
|
|
|
34
47
|
def show_usage? = true
|
|
35
48
|
end
|
|
36
49
|
|
|
37
|
-
#
|
|
50
|
+
# @!visibility private
|
|
38
51
|
class Continue < Result
|
|
39
52
|
def initialize
|
|
40
53
|
super(exit_status: 0)
|
|
41
54
|
end
|
|
42
55
|
end
|
|
43
56
|
|
|
44
|
-
#
|
|
57
|
+
# @!visibility private
|
|
45
58
|
class Abort < Result
|
|
46
59
|
def initialize(exit_status:1,message:nil)
|
|
47
60
|
if exit_status == 0
|
|
@@ -50,6 +63,7 @@ module Brut
|
|
|
50
63
|
super(exit_status:,message:)
|
|
51
64
|
end
|
|
52
65
|
end
|
|
66
|
+
# @!visibility private
|
|
53
67
|
class CLIUsageError < Abort
|
|
54
68
|
def initialize(message:)
|
|
55
69
|
super(message:,exit_status:65)
|
|
@@ -57,12 +71,36 @@ module Brut
|
|
|
57
71
|
def show_usage? = true
|
|
58
72
|
end
|
|
59
73
|
|
|
74
|
+
# Return this from {Brut::CLI::Command#execute} to stop execution, without signaling an error
|
|
60
75
|
def stop_execution = Stop.new
|
|
76
|
+
# Return this from {Brut::CLI::Command#execute} to indicate any other execution may continue. This is mostly useful when one
|
|
77
|
+
# command calls another e.g. with {Brut::CLI::Command#delegate_to_commands}.
|
|
61
78
|
def continue_execution = Continue.new
|
|
79
|
+
# Return this from {Brut::CLI::Command#execute} to stop execution and signal an error
|
|
80
|
+
#
|
|
81
|
+
# @param [String] message the error message
|
|
82
|
+
# @param [Integer] exit_status the exit status (should ideally not be 0)
|
|
62
83
|
def abort_execution(message,exit_status:1) = Abort.new(message:,exit_status:)
|
|
84
|
+
# Return this from {Brut::CLI::Command#execute} to stop execution because the user messed up the CLI invocation.
|
|
85
|
+
#
|
|
86
|
+
# @param [String] message the error message
|
|
63
87
|
def cli_usage_error(message) = CLIUsageError.new(message:)
|
|
88
|
+
# Return this from {Brut::CLI::Command#execute} to stop execution, and show the user the CLI help, optionally for a specific
|
|
89
|
+
# command.
|
|
90
|
+
#
|
|
91
|
+
# @param [Class] command_klass if given, this it he class whose help will be shown.
|
|
64
92
|
def show_cli_usage(command_klass=nil) = ShowCLIUsage.new(command_klass:)
|
|
65
93
|
|
|
94
|
+
# Coerce a value to the appropriate {Brut::CLI::ExecutionResults::Result}. Currently works as follows:
|
|
95
|
+
#
|
|
96
|
+
# 1. If `exit_status_or_execution_result` is nil or true, returns a successful result
|
|
97
|
+
# 2. If `exit_status_or_execution_result` is an integer, returns a result with no message and that value as the exit status
|
|
98
|
+
# 3. If `exit_status_or_execution_result` is false, returns {#abort_execution}.
|
|
99
|
+
# 4. If `exit_status_or_execution_result` is a {Brut::CLI::ExecutionResults::Result}, returns that
|
|
100
|
+
# 5. Otherwise, raises `ArgumentError`
|
|
101
|
+
#
|
|
102
|
+
# @param [nil|true|false|Brut::CLI::ExecutionResults::Result|Integer] exit_status_or_execution_result the value to coerce
|
|
103
|
+
# @return [Brut::CLI::ExecutionResults::Result] appropriate for the parameter
|
|
66
104
|
def as_execution_result(exit_status_or_execution_result)
|
|
67
105
|
if exit_status_or_execution_result.kind_of?(Numeric) || exit_status_or_execution_result.nil?
|
|
68
106
|
Result.new(exit_status: exit_status_or_execution_result.to_i)
|
data/lib/brut/cli/executor.rb
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
require "open3"
|
|
2
|
+
# Abstracts the invocation of child processes, but includes useful output and exception handling that you'd need to be transparent to
|
|
3
|
+
# the user. In particular:
|
|
4
|
+
#
|
|
5
|
+
# * Outputs the command being executed to the standard error (via {Brut::CLI::Output}).
|
|
6
|
+
# * Outputs the command's standard error and standard output in real time. So, this should be usable for spawned commands that
|
|
7
|
+
# produce real time output like test runners.
|
|
8
|
+
# * Assumes spawned commands should succeed, raising an error if they do not.
|
|
2
9
|
class Brut::CLI::Executor
|
|
10
|
+
# Create the executor
|
|
11
|
+
#
|
|
12
|
+
# @param [Brut::CLI::Output] out an IO used to send messages to the standard output
|
|
13
|
+
# @param [Brut::CLI::Output] err an IO used to send messages to the standard error
|
|
3
14
|
def initialize(out:,err:)
|
|
4
15
|
@out = out
|
|
5
16
|
@err = err
|
|
6
17
|
end
|
|
18
|
+
|
|
19
|
+
# Execute a command, logging it to the standard output and outputing the commands output and error to the standard output and error,
|
|
20
|
+
# respecitively
|
|
21
|
+
#
|
|
22
|
+
# @see https://docs.ruby-lang.org/en/3.3/Open3.html#method-c-popen3
|
|
23
|
+
#
|
|
24
|
+
# @param [String|Array] args Whatever you would give to `Kernel#system` or `Open3.popen3`.
|
|
25
|
+
# @raise Brut::CLI::Error::SystemExecError if the spawed command exits nonzer
|
|
26
|
+
# @return [true]
|
|
7
27
|
def system!(*args)
|
|
8
28
|
@out.puts "Executing #{args}"
|
|
9
29
|
wait_thread = Open3.popen3(*args) do |_stdin,stdout,stderr,wait_thread|
|
|
@@ -30,7 +50,7 @@ class Brut::CLI::Executor
|
|
|
30
50
|
if wait_thread.value.success?
|
|
31
51
|
@out.puts "#{args} succeeded"
|
|
32
52
|
else
|
|
33
|
-
raise Brut::CLI::SystemExecError.new(
|
|
53
|
+
raise Brut::CLI::SystemExecError.new(args,wait_thread.value.exitstatus)
|
|
34
54
|
end
|
|
35
55
|
true
|
|
36
56
|
end
|
data/lib/brut/cli/options.rb
CHANGED
|
@@ -1,19 +1,46 @@
|
|
|
1
|
-
#
|
|
2
|
-
# parsed to be a bit more accessible
|
|
1
|
+
# Wraps parsed command line options to provide a method-like interface instead of simple `Hash` acceess. Also allows specifying default values when an option wasn't given on the command line, as well as boolean coercion for switches. Allows accessing options via snake_case in code, even if specified via kebab-case on the command line.
|
|
3
2
|
class Brut::CLI::Options
|
|
4
3
|
def initialize(parsed_options)
|
|
5
4
|
@parsed_options = parsed_options
|
|
6
5
|
@defaults = {}
|
|
7
6
|
end
|
|
8
7
|
|
|
8
|
+
# Returns the parsed options as a `Hash`
|
|
9
|
+
# @return [Hash]
|
|
9
10
|
def to_h = @parsed_options
|
|
10
11
|
|
|
12
|
+
# Access an options value directly.
|
|
13
|
+
# @param [String] key the key to use. This must be the exact name you used when calling `opts.on` or when creating the `OptionParser` for your app or command. Generally, use the {#method_missing}-provided version instead of this.
|
|
11
14
|
def [](key) = @parsed_options[key]
|
|
15
|
+
# Check if `key` was provided on the command line.
|
|
16
|
+
# @param [String] key the key to use. This must be the exact name you used when calling `opts.on` or when creating the `OptionParser` for your app or command.
|
|
12
17
|
def key?(key) = @parsed_options.key?(key)
|
|
18
|
+
|
|
19
|
+
# Set a default value for an option when {#method_missing} is used to access it and that flag or switch was not used on the command
|
|
20
|
+
# line.
|
|
21
|
+
# @param [Symbol] sym the symbol of the option, either in kebab-case or snake_case.
|
|
22
|
+
# @param [Object] default_value the value to return if that option was not used on the command line
|
|
13
23
|
def set_default(sym,default_value)
|
|
14
24
|
@defaults[sym] = default_value
|
|
15
25
|
end
|
|
16
26
|
|
|
27
|
+
# Dynamically creates methods for each command-line option. Note that this doesn't know what options could've been provided, so it
|
|
28
|
+
# will respond to any method that either takes no arguments or where the only argument is `default:`.
|
|
29
|
+
#
|
|
30
|
+
# @param [Symbol] sym kebab or snake case value of the option. A question mark should be used if the option is to be coerced to a booealn.
|
|
31
|
+
# @param [Array|Hash] args if the first arg is a Hash with the key `default:`, this value will be used if `sym` has no value
|
|
32
|
+
#
|
|
33
|
+
# @return [true|false|nil|Object] the value of the option used on the command line, or the default used in `default:`, or the
|
|
34
|
+
# preconfigured default, or `nil`. If `sym` ended in a question mark, true or false will be returned (never nil).
|
|
35
|
+
#
|
|
36
|
+
# @example
|
|
37
|
+
#
|
|
38
|
+
# # Suppose the user provided `--log-level=debug --dry-run` on the command line
|
|
39
|
+
# options.log_level # => "debug"
|
|
40
|
+
# options.verbose # => nil
|
|
41
|
+
# options.verbose(default: "yes") # => "yes"
|
|
42
|
+
# options.dry_run? # => true
|
|
43
|
+
# options.slow? # => false
|
|
17
44
|
def method_missing(sym,*args,&block)
|
|
18
45
|
boolean = false
|
|
19
46
|
if sym.to_s =~ /\?$/
|
data/lib/brut/cli/output.rb
CHANGED
|
@@ -1,14 +1,41 @@
|
|
|
1
|
+
# An `IO`-like class that provides user-helpful output, to be used in place of `puts`. This is not strictly an `IO` and is intended to provide only a few options for sending output to a human.
|
|
2
|
+
#
|
|
3
|
+
# Command line apps that a human runs are often executed in the context of other apps or themselves spawn child processes. Thus, it
|
|
4
|
+
# can be hard to know what output is coming from where. This class addresses that by prefixing every line with the name of the
|
|
5
|
+
# command line app:
|
|
6
|
+
#
|
|
7
|
+
# ```
|
|
8
|
+
# > bin/my_app doit
|
|
9
|
+
# [ bin/my_app ] About to do sometohing
|
|
10
|
+
# Cannot connect to house
|
|
11
|
+
# [ bin/my_app ] Problem connecting
|
|
12
|
+
# ```
|
|
13
|
+
#
|
|
14
|
+
# The prefix allows the human to know what output was generated by the app they ran and what not. This can be extremely helpful for
|
|
15
|
+
# debugging or just understanding what is going on.
|
|
1
16
|
class Brut::CLI::Output
|
|
17
|
+
# Create a wrapper for the given IO that will use the given prefix
|
|
18
|
+
#
|
|
19
|
+
# @param [IO] io an IO where output should be sent, for example `$stdout`.
|
|
20
|
+
# @param [String] prefix a prefix to be placed in front of all messages (unless a `_no_prefix` version is called)
|
|
2
21
|
def initialize(io:, prefix:)
|
|
3
22
|
@io = io
|
|
4
23
|
@prefix = prefix
|
|
5
24
|
@sync_status = @io.sync
|
|
6
25
|
end
|
|
7
26
|
|
|
27
|
+
# Calls `puts` without the prefix. This is useful if the output is going to be obvious to the human user (e.g. CLI help), or if
|
|
28
|
+
# it's intended to be piped into another app.
|
|
29
|
+
#
|
|
30
|
+
# @see https://ruby-doc.org/3.3.6/Kernel.html#method-i-puts
|
|
8
31
|
def puts_no_prefix(*objects)
|
|
9
32
|
@io.puts(*objects)
|
|
10
33
|
end
|
|
11
34
|
|
|
35
|
+
# Calls `puts`, adding a prefix to each of the objects in `*objects`. This is useful for sending messages that a human may want to
|
|
36
|
+
# read.
|
|
37
|
+
#
|
|
38
|
+
# @see https://ruby-doc.org/3.3.6/Kernel.html#method-i-puts
|
|
12
39
|
def puts(*objects)
|
|
13
40
|
if objects.empty?
|
|
14
41
|
objects << ""
|
|
@@ -19,10 +46,30 @@ class Brut::CLI::Output
|
|
|
19
46
|
nil
|
|
20
47
|
end
|
|
21
48
|
|
|
49
|
+
# Calls `print` without any prefix. In theory, this is the escape hatch to sending arbitrary data to the underlying `IO` without
|
|
50
|
+
# expose said `IO`
|
|
51
|
+
#
|
|
52
|
+
# @see https://ruby-doc.org/3.3.6/Kernel.html#method-i-print
|
|
22
53
|
def print(*objects)
|
|
23
54
|
@io.print(*objects)
|
|
24
55
|
end
|
|
25
56
|
|
|
57
|
+
# Prints a string via `printf`, using the prefix. This is useful for communciating to a human, but you need more power for
|
|
58
|
+
# formatting than is afforded by {#puts}.
|
|
59
|
+
#
|
|
60
|
+
# @see https://ruby-doc.org/3.3.6/Kernel.html#method-i-printf
|
|
61
|
+
def printf(format_string,*objects)
|
|
62
|
+
@io.printf(@prefix + format_string,*objects)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Prints a string via `printf`, without the prefix.
|
|
66
|
+
#
|
|
67
|
+
# @see https://ruby-doc.org/3.3.6/Kernel.html#method-i-printf
|
|
68
|
+
def printf_no_prefix(format_string,*objects)
|
|
69
|
+
@io.printf(format_string,*objects)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Flush the underlying `IO`.
|
|
26
73
|
def flush
|
|
27
74
|
@io.flush
|
|
28
75
|
self
|
data/lib/brut/cli.rb
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
module Brut
|
|
2
|
+
# Brut provides a basic CLI framework for building CLIs that have access to your Brut app's innerworkings.
|
|
3
|
+
# This is an alternative to Rake tasks which suffer from poor usability and testability.
|
|
4
|
+
#
|
|
5
|
+
# To create a CLI, you will subclass {Brut::CLI::App}. That class will define your UI as well as any subcommands
|
|
6
|
+
# that your CLI will respond to. See {Brut::CLI::app}.
|
|
2
7
|
module CLI
|
|
3
8
|
|
|
9
|
+
# Execute your CLI based on its command line invocation. You would call this method inside the executable file placed in `bin/`
|
|
10
|
+
# in your project. For example, if you have `YourApp::CLI::CleanOldFiles` and you wish to execute it via `bin/clean-files`, you'd
|
|
11
|
+
# create `bin/clean-files` like so:
|
|
12
|
+
#
|
|
13
|
+
# ```
|
|
14
|
+
# #!/usr/bin/env ruby
|
|
15
|
+
#
|
|
16
|
+
# require "bundler"
|
|
17
|
+
# Bundler.require
|
|
18
|
+
# require "pathname"
|
|
19
|
+
# require "brut/cli/apps/db"
|
|
20
|
+
#
|
|
21
|
+
# exit Brut::CLI.app(YourApp::CLI::CleanOldFiles,
|
|
22
|
+
# project_root: Pathname($0).dirname / "..")
|
|
23
|
+
# ```
|
|
24
|
+
#
|
|
25
|
+
# @param app_klass [Class] your CLI app's class.
|
|
26
|
+
# @param project_root [Pathname] the path to the root of your project. This is needed before the Brut framework is initialized so
|
|
27
|
+
# it must be specified explicitly.
|
|
4
28
|
def self.app(app_klass, project_root:)
|
|
5
29
|
Brut::CLI::AppRunner.new(app_klass:,project_root:).run!
|
|
6
30
|
end
|
|
@@ -8,11 +32,13 @@ module Brut
|
|
|
8
32
|
autoload(:Command, "brut/cli/command")
|
|
9
33
|
autoload(:Error, "brut/cli/error")
|
|
10
34
|
autoload(:SystemExecError, "brut/cli/error")
|
|
35
|
+
autoload(:InvalidOption, "brut/cli/error")
|
|
11
36
|
autoload(:ExecutionResults, "brut/cli/execution_results")
|
|
12
37
|
autoload(:Options, "brut/cli/options")
|
|
13
38
|
autoload(:Output, "brut/cli/output")
|
|
14
39
|
autoload(:Executor, "brut/cli/executor")
|
|
15
40
|
autoload(:AppRunner, "brut/cli/app_runner")
|
|
41
|
+
# Holds Brut-provided CLI apps that are set up in your project.
|
|
16
42
|
module Apps
|
|
17
43
|
autoload(:DB,"brut/cli/apps/db")
|
|
18
44
|
autoload(:DB,"brut/cli/apps/test")
|
data/lib/brut/factory_bot.rb
CHANGED
|
@@ -5,7 +5,9 @@ require "active_support"
|
|
|
5
5
|
require "factory_bot"
|
|
6
6
|
require "faker"
|
|
7
7
|
|
|
8
|
+
# Encompasses a Brut app's FactoryBot configuration. This allows it to be used outside of tests.
|
|
8
9
|
class Brut::FactoryBot
|
|
10
|
+
# Configures FactoryBot and finds all definitions. After this, calls like `FactoryBot.create(...)` should work.
|
|
9
11
|
def setup!
|
|
10
12
|
Faker::Config.locale = :en
|
|
11
13
|
FactoryBot.definition_file_paths = [
|