cli-kit 5.1.0 → 5.3.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/.github/release.yml +20 -0
- data/.github/workflows/release.yml +52 -0
- data/.github/workflows/ruby.yml +1 -1
- data/.rubocop.sorbet.yml +1 -0
- data/.rubocop.yml +2 -2
- data/Gemfile +1 -1
- data/Gemfile.lock +23 -24
- data/Rakefile +0 -1
- data/bin/testunit +0 -1
- data/cli-kit.gemspec +2 -1
- data/examples/minimal/example.rb +1 -1
- data/examples/single-file/example.rb +4 -3
- data/gen/lib/gen/commands/help.rb +1 -3
- data/gen/lib/gen/commands/new.rb +2 -6
- data/gen/lib/gen/commands.rb +1 -7
- data/gen/lib/gen/entry_point.rb +1 -5
- data/gen/lib/gen/generator.rb +15 -19
- data/gen/lib/gen/help.rb +3 -7
- data/lib/cli/kit/args/definition.rb +37 -95
- data/lib/cli/kit/args/evaluation.rb +40 -59
- data/lib/cli/kit/args/parser/node.rb +19 -23
- data/lib/cli/kit/args/parser.rb +12 -16
- data/lib/cli/kit/args/tokenizer.rb +15 -18
- data/lib/cli/kit/base_command.rb +4 -8
- data/lib/cli/kit/command_help.rb +24 -27
- data/lib/cli/kit/command_registry.rb +40 -36
- data/lib/cli/kit/config.rb +210 -17
- data/lib/cli/kit/core_ext.rb +118 -8
- data/lib/cli/kit/error_handler.rb +49 -39
- data/lib/cli/kit/executor.rb +7 -16
- data/lib/cli/kit/ini.rb +8 -12
- data/lib/cli/kit/levenshtein.rb +7 -5
- data/lib/cli/kit/logger.rb +8 -10
- data/lib/cli/kit/opts.rb +26 -86
- data/lib/cli/kit/parse_args.rb +5 -10
- data/lib/cli/kit/resolver.rb +4 -6
- data/lib/cli/kit/support/test_helper.rb +10 -5
- data/lib/cli/kit/system.rb +25 -114
- data/lib/cli/kit/util.rb +15 -31
- data/lib/cli/kit/version.rb +1 -1
- data/lib/cli/kit.rb +19 -36
- metadata +18 -3
- data/lib/cli/kit/sorbet_runtime_stub.rb +0 -154
data/lib/cli/kit/config.rb
CHANGED
|
@@ -2,15 +2,132 @@
|
|
|
2
2
|
|
|
3
3
|
require 'cli/kit'
|
|
4
4
|
require 'fileutils'
|
|
5
|
+
require 'tempfile'
|
|
5
6
|
|
|
6
7
|
module CLI
|
|
7
8
|
module Kit
|
|
8
9
|
class Config
|
|
9
|
-
extend T::Sig
|
|
10
|
-
|
|
11
10
|
XDG_CONFIG_HOME = 'XDG_CONFIG_HOME'
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
# Raised when the config file cannot be written (e.g. read-only
|
|
13
|
+
# directory, read-only filesystem, nix/home-manager-managed symlink
|
|
14
|
+
# pointing into +/nix/store+).
|
|
15
|
+
#
|
|
16
|
+
# Inherits from SystemCallError so existing `rescue SystemCallError`
|
|
17
|
+
# handlers around +Config#set+ continue to match. The message contains
|
|
18
|
+
# only the keys that actually changed; unchanged keys (which may
|
|
19
|
+
# include sensitive values such as API tokens) are intentionally
|
|
20
|
+
# excluded so the failure message can never leak secrets through
|
|
21
|
+
# stderr or exception reports.
|
|
22
|
+
class ConfigWriteError < SystemCallError
|
|
23
|
+
class << self
|
|
24
|
+
# Ruby's +SystemCallError.===+ uses errno-based matching,
|
|
25
|
+
# which means +rescue ConfigWriteError+ would otherwise fail
|
|
26
|
+
# to catch a ConfigWriteError instance (it would match
|
|
27
|
+
# +Errno::EACCES+ or +Errno::EPERM+ instead, based on the
|
|
28
|
+
# wrapped errno). Override with plain class-hierarchy
|
|
29
|
+
# matching so +rescue ConfigWriteError+ works as callers
|
|
30
|
+
# expect. +rescue SystemCallError+ still matches via normal
|
|
31
|
+
# inheritance.
|
|
32
|
+
#: (untyped other) -> bool
|
|
33
|
+
def ===(other)
|
|
34
|
+
other.is_a?(self)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# +SystemCallError.new+ has a factory-style signature defined
|
|
38
|
+
# in Sorbet's stdlib RBI (+(String | Integer?) ->
|
|
39
|
+
# SystemCallError+) which doesn't match this subclass's
|
|
40
|
+
# four-argument constructor. Override +.new+ here so type
|
|
41
|
+
# checkers and callers see the real signature, and allocate
|
|
42
|
+
# the instance manually to bypass +SystemCallError+'s
|
|
43
|
+
# factory behaviour.
|
|
44
|
+
#: (String config_path, String old_content, String new_content, SystemCallError cause) -> ConfigWriteError
|
|
45
|
+
def new(config_path, old_content, new_content, cause)
|
|
46
|
+
instance = allocate
|
|
47
|
+
instance.__send__(:initialize, config_path, old_content, new_content, cause)
|
|
48
|
+
instance
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
#: String
|
|
53
|
+
attr_reader :config_path
|
|
54
|
+
|
|
55
|
+
#: String
|
|
56
|
+
attr_reader :old_content
|
|
57
|
+
|
|
58
|
+
#: String
|
|
59
|
+
attr_reader :new_content
|
|
60
|
+
|
|
61
|
+
# rubocop:disable Lint/MissingSuper
|
|
62
|
+
# +SystemCallError#initialize+ has a factory-style signature that
|
|
63
|
+
# cannot be invoked from a plain subclass (it expects a matching
|
|
64
|
+
# Errno constant on the class). We bypass it deliberately and
|
|
65
|
+
# initialize via Exception so we just get a message-only
|
|
66
|
+
# exception that +rescue SystemCallError+ still catches via
|
|
67
|
+
# inheritance. +super+ would not work here.
|
|
68
|
+
#: (String config_path, String old_content, String new_content, SystemCallError cause) -> void
|
|
69
|
+
def initialize(config_path, old_content, new_content, cause)
|
|
70
|
+
@config_path = config_path
|
|
71
|
+
@old_content = old_content
|
|
72
|
+
@new_content = new_content
|
|
73
|
+
@wrapped_errno = cause.errno
|
|
74
|
+
message = <<~MSG.rstrip
|
|
75
|
+
Could not write to #{config_path}: #{cause.message}
|
|
76
|
+
|
|
77
|
+
Attempted changes (unchanged keys omitted):
|
|
78
|
+
#{diff}
|
|
79
|
+
MSG
|
|
80
|
+
Exception.instance_method(:initialize).bind(self).call(message)
|
|
81
|
+
end
|
|
82
|
+
# rubocop:enable Lint/MissingSuper
|
|
83
|
+
|
|
84
|
+
# Preserve the original errno from the wrapped Errno so callers
|
|
85
|
+
# that inspect +errno+ continue to see the real underlying code.
|
|
86
|
+
#: -> Integer?
|
|
87
|
+
def errno
|
|
88
|
+
@wrapped_errno
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A line-by-line diff of only the sections/keys that changed
|
|
92
|
+
# between +old_content+ and +new_content+. Unchanged keys are
|
|
93
|
+
# omitted so that sensitive values stored elsewhere in the config
|
|
94
|
+
# are never included in the failure message.
|
|
95
|
+
#: -> String
|
|
96
|
+
def diff
|
|
97
|
+
old_ini = CLI::Kit::Ini.new(config: @old_content).tap(&:parse).ini
|
|
98
|
+
new_ini = CLI::Kit::Ini.new(config: @new_content).tap(&:parse).ini
|
|
99
|
+
|
|
100
|
+
lines = []
|
|
101
|
+
(old_ini.keys | new_ini.keys).each do |section|
|
|
102
|
+
old_section = old_ini[section] || {}
|
|
103
|
+
new_section = new_ini[section] || {}
|
|
104
|
+
|
|
105
|
+
changes = []
|
|
106
|
+
(old_section.keys | new_section.keys).each do |key|
|
|
107
|
+
old_val = old_section[key]
|
|
108
|
+
new_val = new_section[key]
|
|
109
|
+
next if old_val == new_val
|
|
110
|
+
|
|
111
|
+
changes << "- #{key} = #{old_val}" if old_val
|
|
112
|
+
changes << "+ #{key} = #{new_val}" if new_val
|
|
113
|
+
end
|
|
114
|
+
next if changes.empty?
|
|
115
|
+
|
|
116
|
+
prefix = if old_section.empty?
|
|
117
|
+
'+ '
|
|
118
|
+
elsif new_section.empty?
|
|
119
|
+
'- '
|
|
120
|
+
else
|
|
121
|
+
' '
|
|
122
|
+
end
|
|
123
|
+
lines << "#{prefix}#{section}"
|
|
124
|
+
lines.concat(changes)
|
|
125
|
+
end
|
|
126
|
+
lines.join("\n")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
#: (tool_name: String) -> void
|
|
14
131
|
def initialize(tool_name:)
|
|
15
132
|
@tool_name = tool_name
|
|
16
133
|
end
|
|
@@ -28,13 +145,13 @@ module CLI
|
|
|
28
145
|
# #### Example Usage
|
|
29
146
|
# `config.get('name.of.config')`
|
|
30
147
|
#
|
|
31
|
-
|
|
148
|
+
#: (String section, String name, ?default: String?) -> String?
|
|
32
149
|
def get(section, name, default: nil)
|
|
33
150
|
all_configs.dig("[#{section}]", name) || default
|
|
34
151
|
end
|
|
35
152
|
|
|
36
153
|
# Coalesce and enforce the value of a config to a boolean
|
|
37
|
-
|
|
154
|
+
#: (String section, String name, ?default: bool?) -> bool?
|
|
38
155
|
def get_bool(section, name, default: false)
|
|
39
156
|
case get(section, name)
|
|
40
157
|
when 'true'
|
|
@@ -58,14 +175,15 @@ module CLI
|
|
|
58
175
|
# #### Example Usage
|
|
59
176
|
# `config.set('section', 'name.of.config', 'value')`
|
|
60
177
|
#
|
|
61
|
-
|
|
178
|
+
#: (String section, String name, (String | bool)? value) -> void
|
|
62
179
|
def set(section, name, value)
|
|
63
180
|
all_configs["[#{section}]"] ||= {}
|
|
181
|
+
section = all_configs["[#{section}]"] #: as !nil
|
|
64
182
|
case value
|
|
65
183
|
when nil
|
|
66
|
-
|
|
184
|
+
section.delete(name)
|
|
67
185
|
else
|
|
68
|
-
|
|
186
|
+
section[name] = value.to_s
|
|
69
187
|
end
|
|
70
188
|
write_config
|
|
71
189
|
end
|
|
@@ -79,7 +197,7 @@ module CLI
|
|
|
79
197
|
# #### Example Usage
|
|
80
198
|
# `config.unset('section', 'name.of.config')`
|
|
81
199
|
#
|
|
82
|
-
|
|
200
|
+
#: (String section, String name) -> void
|
|
83
201
|
def unset(section, name)
|
|
84
202
|
set(section, name, nil)
|
|
85
203
|
end
|
|
@@ -92,12 +210,12 @@ module CLI
|
|
|
92
210
|
# #### Example Usage
|
|
93
211
|
# `config.get_section('section')`
|
|
94
212
|
#
|
|
95
|
-
|
|
213
|
+
#: (String section) -> Hash[String, String]
|
|
96
214
|
def get_section(section)
|
|
97
215
|
(all_configs["[#{section}]"] || {}).dup
|
|
98
216
|
end
|
|
99
217
|
|
|
100
|
-
|
|
218
|
+
#: -> String
|
|
101
219
|
def to_s
|
|
102
220
|
ini.to_s
|
|
103
221
|
end
|
|
@@ -107,7 +225,7 @@ module CLI
|
|
|
107
225
|
# if ENV['XDG_CONFIG_HOME'] is not set, we default to ~/.config, e.g.:
|
|
108
226
|
# ~/.config/tool/config
|
|
109
227
|
#
|
|
110
|
-
|
|
228
|
+
#: -> String
|
|
111
229
|
def file
|
|
112
230
|
config_home = ENV.fetch(XDG_CONFIG_HOME, '~/.config')
|
|
113
231
|
File.expand_path(File.join(@tool_name, 'config'), config_home)
|
|
@@ -115,23 +233,98 @@ module CLI
|
|
|
115
233
|
|
|
116
234
|
private
|
|
117
235
|
|
|
118
|
-
|
|
236
|
+
#: -> Hash[String, Hash[String, String]]
|
|
119
237
|
def all_configs
|
|
120
238
|
ini.ini
|
|
121
239
|
end
|
|
122
240
|
|
|
123
|
-
|
|
241
|
+
#: -> CLI::Kit::Ini
|
|
124
242
|
def ini
|
|
125
243
|
@ini ||= CLI::Kit::Ini.new(file).tap(&:parse)
|
|
126
244
|
end
|
|
127
245
|
|
|
128
|
-
|
|
246
|
+
#: -> void
|
|
129
247
|
def write_config
|
|
130
248
|
all_configs.each do |section, sub_config|
|
|
131
249
|
all_configs.delete(section) if sub_config.empty?
|
|
132
250
|
end
|
|
133
|
-
|
|
134
|
-
|
|
251
|
+
|
|
252
|
+
# Capture +file+ and +to_s+ up front so they are visible (and
|
|
253
|
+
# known to be non-nil) inside the rescue block below.
|
|
254
|
+
config_path = file
|
|
255
|
+
new_content = to_s
|
|
256
|
+
|
|
257
|
+
begin
|
|
258
|
+
# Always write atomically (tmpfile + rename) so readers never
|
|
259
|
+
# observe a partial write. If +config_path+ is a symlink (e.g.
|
|
260
|
+
# a nix/home-manager-managed config), resolve it so the rename
|
|
261
|
+
# replaces the real file at the end of the chain and leaves
|
|
262
|
+
# the managed symlink itself intact.
|
|
263
|
+
target_path = resolved_write_path(config_path)
|
|
264
|
+
target_dir = File.dirname(target_path)
|
|
265
|
+
FileUtils.mkdir_p(target_dir)
|
|
266
|
+
write_config_atomic(target_path, target_dir, new_content)
|
|
267
|
+
rescue Errno::EACCES, Errno::EPERM, Errno::EROFS => e
|
|
268
|
+
# +EROFS+ (read-only filesystem) is the canonical error raised
|
|
269
|
+
# when the underlying filesystem is mounted read-only — common
|
|
270
|
+
# for nix/home-manager configs that live in +/nix/store+. Wrap
|
|
271
|
+
# it the same way as +EACCES+/+EPERM+ so callers always see
|
|
272
|
+
# the diff and any +rescue ConfigWriteError+ handler matches.
|
|
273
|
+
old_content = read_config_for_diff(config_path)
|
|
274
|
+
raise(ConfigWriteError.new(config_path, old_content, new_content, e))
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Write +new_content+ into +config_path+ atomically, via a tmpfile
|
|
279
|
+
# in the same directory followed by rename. Readers never observe
|
|
280
|
+
# a partial write.
|
|
281
|
+
#: (String config_path, String config_dir, String new_content) -> void
|
|
282
|
+
def write_config_atomic(config_path, config_dir, new_content)
|
|
283
|
+
tmpfile = Tempfile.new([File.basename(config_path), '.tmp'], config_dir)
|
|
284
|
+
tmpfile_path = tmpfile.path #: as !nil
|
|
285
|
+
begin
|
|
286
|
+
tmpfile.write(new_content)
|
|
287
|
+
tmpfile.close
|
|
288
|
+
# Tempfile defaults to 0o600. Match the permissions a plain
|
|
289
|
+
# +File.write+ would have produced: preserve the existing
|
|
290
|
+
# mode when the config is being updated, and use the
|
|
291
|
+
# umask-adjusted default (matching +open(2)+ for new files)
|
|
292
|
+
# otherwise. This avoids silently tightening permissions on
|
|
293
|
+
# an existing config and avoids creating new configs with
|
|
294
|
+
# the more restrictive Tempfile default.
|
|
295
|
+
mode = if File.exist?(config_path)
|
|
296
|
+
File.stat(config_path).mode
|
|
297
|
+
else
|
|
298
|
+
0o666 & ~File.umask
|
|
299
|
+
end
|
|
300
|
+
File.chmod(mode, tmpfile_path)
|
|
301
|
+
File.rename(tmpfile_path, config_path)
|
|
302
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
303
|
+
tmpfile.close unless tmpfile.closed?
|
|
304
|
+
File.unlink(tmpfile_path) if File.exist?(tmpfile_path)
|
|
305
|
+
raise
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
#: (String config_path) -> String
|
|
310
|
+
def read_config_for_diff(config_path)
|
|
311
|
+
File.read(config_path)
|
|
312
|
+
rescue SystemCallError
|
|
313
|
+
''
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Resolve +config_path+ through any symlinks so the atomic rename
|
|
317
|
+
# in +write_config_atomic+ replaces the real file at the end of
|
|
318
|
+
# the chain instead of clobbering the symlink with a regular file.
|
|
319
|
+
# For broken symlinks, fall back to +config_path+ itself so we
|
|
320
|
+
# still create a config there.
|
|
321
|
+
#: (String config_path) -> String
|
|
322
|
+
def resolved_write_path(config_path)
|
|
323
|
+
return config_path unless File.symlink?(config_path)
|
|
324
|
+
|
|
325
|
+
File.realpath(config_path)
|
|
326
|
+
rescue Errno::ENOENT
|
|
327
|
+
config_path
|
|
135
328
|
end
|
|
136
329
|
end
|
|
137
330
|
end
|
data/lib/cli/kit/core_ext.rb
CHANGED
|
@@ -2,29 +2,139 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
class Exception
|
|
5
|
-
extend(T::Sig)
|
|
6
|
-
|
|
7
5
|
# You'd think instance variables @bug and @silent would work here. They
|
|
8
6
|
# don't. I'm not sure why. If you, the reader, want to take some time to
|
|
9
7
|
# figure it out, go ahead and refactor to that.
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
# Fault represents who is responsible for an error. It is used to attribute
|
|
10
|
+
# exceptions to the right owner for observability and triage.
|
|
11
|
+
#
|
|
12
|
+
# @abstract
|
|
13
|
+
# @sealed
|
|
14
|
+
class Fault
|
|
15
|
+
# @abstract
|
|
16
|
+
#: -> String
|
|
17
|
+
def to_s = raise NotImplementedError, 'abstract method called'
|
|
18
|
+
|
|
19
|
+
# A bug in the tool itself (dev, tec, cli-kit, etc.).
|
|
20
|
+
class System < Fault
|
|
21
|
+
# @override
|
|
22
|
+
#: -> String
|
|
23
|
+
def to_s = 'system'
|
|
24
|
+
end
|
|
25
|
+
SYSTEM = System.new.freeze #: System
|
|
26
|
+
|
|
27
|
+
# The default fault for exceptions that have not been explicitly attributed.
|
|
28
|
+
# Nothing should reference this constant directly — it exists only as the
|
|
29
|
+
# default return value of Exception#fault. Use rubocop to enforce this.
|
|
30
|
+
#
|
|
31
|
+
# Inherits from System so that `fault.is_a?(System)` and checks like
|
|
32
|
+
# `bug?` (which uses `Fault::System ===`) treat unattributed errors as
|
|
33
|
+
# system errors by default.
|
|
34
|
+
class ImplicitlySystem < System
|
|
35
|
+
# @override
|
|
36
|
+
#: -> String
|
|
37
|
+
def to_s = 'implicitly_system'
|
|
38
|
+
end
|
|
39
|
+
IMPLICITLY_SYSTEM = ImplicitlySystem.new.freeze #: ImplicitlySystem
|
|
40
|
+
|
|
41
|
+
# A problem with the project's configuration (dev.yml, zone.nix, etc.).
|
|
42
|
+
class Project < Fault
|
|
43
|
+
# @override
|
|
44
|
+
#: -> String
|
|
45
|
+
def to_s = 'project'
|
|
46
|
+
end
|
|
47
|
+
PROJECT = Project.new.freeze #: Project
|
|
48
|
+
|
|
49
|
+
# User error: incorrect CLI usage, expired tokens, bad arguments, etc.
|
|
50
|
+
class User < Fault
|
|
51
|
+
# @override
|
|
52
|
+
#: -> String
|
|
53
|
+
def to_s = 'user'
|
|
54
|
+
end
|
|
55
|
+
USER = User.new.freeze #: User
|
|
56
|
+
|
|
57
|
+
# Not a real failure — e.g. user cancellation, interrupt, or conditions
|
|
58
|
+
# wildly out of anyone's control (no disk space). Doesn't require fixing
|
|
59
|
+
# or tracking.
|
|
60
|
+
class Terroir < Fault
|
|
61
|
+
# @override
|
|
62
|
+
#: -> String
|
|
63
|
+
def to_s = 'terroir'
|
|
64
|
+
end
|
|
65
|
+
TERROIR = Terroir.new.freeze #: Terroir
|
|
14
66
|
end
|
|
15
67
|
|
|
16
|
-
|
|
68
|
+
#: -> Fault
|
|
69
|
+
def fault = Fault::IMPLICITLY_SYSTEM
|
|
70
|
+
|
|
71
|
+
#: (Fault fault) -> void
|
|
72
|
+
def fault!(fault)
|
|
73
|
+
singleton_class.define_method(:fault) { fault }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
#: (Fault fault) -> self
|
|
77
|
+
def with_fault(fault)
|
|
78
|
+
fault!(fault)
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
#: -> bool
|
|
83
|
+
def bug? = (Fault::System === fault)
|
|
84
|
+
|
|
85
|
+
#: -> bool
|
|
17
86
|
def silent?
|
|
18
87
|
false
|
|
19
88
|
end
|
|
20
89
|
|
|
21
|
-
|
|
90
|
+
#: (?bool bug) -> void
|
|
22
91
|
def bug!(bug = true)
|
|
23
92
|
singleton_class.define_method(:bug?) { bug }
|
|
24
93
|
end
|
|
25
94
|
|
|
26
|
-
|
|
95
|
+
#: (?bool silent) -> void
|
|
27
96
|
def silent!(silent = true)
|
|
28
97
|
singleton_class.define_method(:silent?) { silent }
|
|
29
98
|
end
|
|
30
99
|
end
|
|
100
|
+
|
|
101
|
+
# -- Custom fault defaults for Ruby stdlib exceptions --------------------------
|
|
102
|
+
#
|
|
103
|
+
# Normally, fault attribution belongs at the raise site (via `fault:` kwarg).
|
|
104
|
+
# These classes are different: Ruby itself raises them (e.g. the kernel sends
|
|
105
|
+
# SIGINT, the OS returns ENOSPC), so there is no raise site we control. We
|
|
106
|
+
# override the default fault at the class level instead.
|
|
107
|
+
#
|
|
108
|
+
# All of these are environmental conditions ("terroir") — nobody's fault,
|
|
109
|
+
# nothing to fix, shouldn't be reported as bugs.
|
|
110
|
+
|
|
111
|
+
class Interrupt # Ctrl-C / SIGINT
|
|
112
|
+
#: -> Exception::Fault
|
|
113
|
+
def fault = Exception::Fault::TERROIR
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class SignalException # SIGTERM, SIGHUP, etc.
|
|
117
|
+
#: -> Exception::Fault
|
|
118
|
+
def fault = Exception::Fault::TERROIR
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
module Errno
|
|
122
|
+
class ENOSPC # disk full
|
|
123
|
+
#: -> Exception::Fault
|
|
124
|
+
def fault = Exception::Fault::TERROIR
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
module Errno
|
|
129
|
+
class EPIPE # broken pipe
|
|
130
|
+
#: -> Exception::Fault
|
|
131
|
+
def fault = Exception::Fault::TERROIR
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
module Errno
|
|
136
|
+
class EIO # I/O error (e.g. terminal disconnected)
|
|
137
|
+
#: -> Exception::Fault
|
|
138
|
+
def fault = Exception::Fault::TERROIR
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -6,23 +6,12 @@ require 'English'
|
|
|
6
6
|
module CLI
|
|
7
7
|
module Kit
|
|
8
8
|
class ErrorHandler
|
|
9
|
-
|
|
9
|
+
#: type exception_reporter_or_proc = singleton(ExceptionReporter) | ^() -> singleton(ExceptionReporter)
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
T.any(T.class_of(ExceptionReporter), T.proc.returns(T.class_of(ExceptionReporter)))
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
sig { params(override_exception_handler: T.proc.params(arg0: Exception).returns(Integer)).void }
|
|
11
|
+
#: ^(Exception arg0) -> Integer
|
|
16
12
|
attr_writer :override_exception_handler
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
params(
|
|
20
|
-
log_file: T.nilable(String),
|
|
21
|
-
exception_reporter: ExceptionReporterOrProc,
|
|
22
|
-
tool_name: T.nilable(String),
|
|
23
|
-
dev_mode: T::Boolean,
|
|
24
|
-
).void
|
|
25
|
-
end
|
|
14
|
+
#: (?log_file: String?, ?exception_reporter: exception_reporter_or_proc, ?tool_name: String?, ?dev_mode: bool) -> void
|
|
26
15
|
def initialize(log_file: nil, exception_reporter: NullExceptionReporter, tool_name: nil, dev_mode: false)
|
|
27
16
|
@log_file = log_file
|
|
28
17
|
@exception_reporter_or_proc = exception_reporter
|
|
@@ -30,33 +19,28 @@ module CLI
|
|
|
30
19
|
@dev_mode = dev_mode
|
|
31
20
|
end
|
|
32
21
|
|
|
22
|
+
# @abstract
|
|
33
23
|
class ExceptionReporter
|
|
34
|
-
extend T::Sig
|
|
35
|
-
extend T::Helpers
|
|
36
|
-
abstract!
|
|
37
|
-
|
|
38
24
|
class << self
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
25
|
+
# @abstract
|
|
26
|
+
#: (Exception?, ?String?) -> void
|
|
27
|
+
def report(exception, logs = nil)
|
|
28
|
+
raise(NotImplementedError)
|
|
29
|
+
end
|
|
43
30
|
end
|
|
44
31
|
end
|
|
45
32
|
|
|
46
33
|
class NullExceptionReporter < ExceptionReporter
|
|
47
|
-
extend T::Sig
|
|
48
|
-
|
|
49
34
|
class << self
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
sig { override.params(_exception: T.nilable(Exception), _logs: T.nilable(String)).void }
|
|
35
|
+
# @override
|
|
36
|
+
#: (Exception? _exception, ?String? _logs) -> void
|
|
53
37
|
def report(_exception, _logs = nil)
|
|
54
38
|
nil
|
|
55
39
|
end
|
|
56
40
|
end
|
|
57
41
|
end
|
|
58
42
|
|
|
59
|
-
|
|
43
|
+
#: { -> void } -> Integer
|
|
60
44
|
def call(&block)
|
|
61
45
|
# @at_exit_exception is set if handle_abort decides to submit an error.
|
|
62
46
|
# $ERROR_INFO is set if we terminate because of a signal.
|
|
@@ -64,7 +48,7 @@ module CLI
|
|
|
64
48
|
triage_all_exceptions(&block)
|
|
65
49
|
end
|
|
66
50
|
|
|
67
|
-
|
|
51
|
+
#: (Exception? error) -> void
|
|
68
52
|
def report_exception(error)
|
|
69
53
|
if (notify_with = exception_for_submission(error))
|
|
70
54
|
logs = nil
|
|
@@ -92,7 +76,7 @@ module CLI
|
|
|
92
76
|
# they're #bug?
|
|
93
77
|
#
|
|
94
78
|
# Returns an exit status for the program.
|
|
95
|
-
|
|
79
|
+
#: { -> void } -> Integer
|
|
96
80
|
def triage_all_exceptions(&block)
|
|
97
81
|
begin
|
|
98
82
|
block.call
|
|
@@ -100,7 +84,7 @@ module CLI
|
|
|
100
84
|
rescue Interrupt => e # Ctrl-C
|
|
101
85
|
# transform message, prevent bugsnag
|
|
102
86
|
exc = e.exception('Interrupt')
|
|
103
|
-
CLI::Kit.raise(exc,
|
|
87
|
+
CLI::Kit.raise(exc, fault: Exception::Fault::TERROIR)
|
|
104
88
|
rescue Errno::ENOSPC => e
|
|
105
89
|
# transform message, prevent bugsnag
|
|
106
90
|
message = if @tool_name
|
|
@@ -109,7 +93,7 @@ module CLI
|
|
|
109
93
|
'Your disk is full - free space is required to operate'
|
|
110
94
|
end
|
|
111
95
|
exc = e.exception(message)
|
|
112
|
-
CLI::Kit.raise(exc,
|
|
96
|
+
CLI::Kit.raise(exc, fault: Exception::Fault::TERROIR)
|
|
113
97
|
end
|
|
114
98
|
# If SystemExit was raised, e.g. `exit()`, then
|
|
115
99
|
# return whatever status is attached to the exception
|
|
@@ -130,12 +114,38 @@ module CLI
|
|
|
130
114
|
e.bug? ? CLI::Kit::EXIT_BUG : CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG
|
|
131
115
|
end
|
|
132
116
|
|
|
133
|
-
|
|
134
|
-
def
|
|
135
|
-
|
|
136
|
-
|
|
117
|
+
#: (Exception? error) -> bool
|
|
118
|
+
def caused_by_benign_signal?(error)
|
|
119
|
+
current = error #: Exception?
|
|
120
|
+
while current
|
|
121
|
+
return true if current.is_a?(SignalException) && SIGNALS_THAT_ARENT_BUGS.include?(current.message)
|
|
137
122
|
|
|
138
|
-
|
|
123
|
+
current = current.cause
|
|
124
|
+
end
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Walks the cause chain looking for anything that should be reported as a
|
|
129
|
+
# bug. A terroir wrapper (e.g. Errno::EPIPE raised by a finalizer) can
|
|
130
|
+
# mask a real bug as its cause, so we can't only inspect the outermost
|
|
131
|
+
# exception. A non-benign signal (e.g. SIGSEGV) also counts as a bug.
|
|
132
|
+
#: (Exception? error) -> bool
|
|
133
|
+
def caused_by_bug?(error)
|
|
134
|
+
current = error #: Exception?
|
|
135
|
+
while current
|
|
136
|
+
return true if current.bug?
|
|
137
|
+
if current.is_a?(SignalException) && !SIGNALS_THAT_ARENT_BUGS.include?(current.message)
|
|
138
|
+
return true
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
current = current.cause
|
|
142
|
+
end
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
#: (Exception? error) -> Exception?
|
|
147
|
+
def exception_for_submission(error)
|
|
148
|
+
return if error.nil? || caused_by_benign_signal?(error) || !caused_by_bug?(error)
|
|
139
149
|
|
|
140
150
|
case error
|
|
141
151
|
when SignalException
|
|
@@ -161,14 +171,14 @@ module CLI
|
|
|
161
171
|
end
|
|
162
172
|
end
|
|
163
173
|
|
|
164
|
-
|
|
174
|
+
#: (String message) -> void
|
|
165
175
|
def stderr_puts(message)
|
|
166
176
|
$stderr.puts(CLI::UI.fmt("{{red:#{message}}}"))
|
|
167
177
|
rescue Errno::EPIPE, Errno::EIO
|
|
168
178
|
nil
|
|
169
179
|
end
|
|
170
180
|
|
|
171
|
-
|
|
181
|
+
#: -> singleton(ExceptionReporter)
|
|
172
182
|
def exception_reporter
|
|
173
183
|
case @exception_reporter_or_proc
|
|
174
184
|
when Proc
|
data/lib/cli/kit/executor.rb
CHANGED
|
@@ -7,15 +7,13 @@ require 'fileutils'
|
|
|
7
7
|
module CLI
|
|
8
8
|
module Kit
|
|
9
9
|
class Executor
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
sig { params(log_file: String).void }
|
|
10
|
+
#: (log_file: String) -> void
|
|
13
11
|
def initialize(log_file:)
|
|
14
12
|
FileUtils.mkpath(File.dirname(log_file))
|
|
15
13
|
@log_file = log_file
|
|
16
14
|
end
|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
#: (singleton(CLI::Kit::BaseCommand) command, String command_name, Array[String] args) -> void
|
|
19
17
|
def call(command, command_name, args)
|
|
20
18
|
with_traps do
|
|
21
19
|
with_logging do |id|
|
|
@@ -36,10 +34,7 @@ module CLI
|
|
|
36
34
|
|
|
37
35
|
private
|
|
38
36
|
|
|
39
|
-
|
|
40
|
-
type_parameters(:T).params(block: T.proc.params(id: String).returns(T.type_parameter(:T)))
|
|
41
|
-
.returns(T.type_parameter(:T))
|
|
42
|
-
end
|
|
37
|
+
#: [T] { (String id) -> T } -> T
|
|
43
38
|
def with_logging(&block)
|
|
44
39
|
CLI::UI.log_output_to(@log_file) do
|
|
45
40
|
CLI::UI::StdoutRouter.with_id(on_streams: [CLI::UI::StdoutRouter.duplicate_output_to].compact) do |id|
|
|
@@ -48,18 +43,14 @@ module CLI
|
|
|
48
43
|
end
|
|
49
44
|
end
|
|
50
45
|
|
|
51
|
-
|
|
46
|
+
#: [T] { -> T } -> T
|
|
52
47
|
def with_traps(&block)
|
|
53
48
|
twrap('QUIT', method(:quit_handler)) do
|
|
54
49
|
twrap('INFO', method(:info_handler), &block)
|
|
55
50
|
end
|
|
56
51
|
end
|
|
57
52
|
|
|
58
|
-
|
|
59
|
-
type_parameters(:T)
|
|
60
|
-
.params(signal: String, handler: Method, block: T.proc.returns(T.type_parameter(:T)))
|
|
61
|
-
.returns(T.type_parameter(:T))
|
|
62
|
-
end
|
|
53
|
+
#: [T] (String signal, Method handler) { -> T } -> T
|
|
63
54
|
def twrap(signal, handler, &block)
|
|
64
55
|
return yield unless Signal.list.key?(signal)
|
|
65
56
|
|
|
@@ -78,7 +69,7 @@ module CLI
|
|
|
78
69
|
end
|
|
79
70
|
end
|
|
80
71
|
|
|
81
|
-
|
|
72
|
+
#: (untyped _sig) -> void
|
|
82
73
|
def quit_handler(_sig)
|
|
83
74
|
z = caller
|
|
84
75
|
CLI::UI.raw do
|
|
@@ -88,7 +79,7 @@ module CLI
|
|
|
88
79
|
exit(CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG)
|
|
89
80
|
end
|
|
90
81
|
|
|
91
|
-
|
|
82
|
+
#: (untyped _sig) -> void
|
|
92
83
|
def info_handler(_sig)
|
|
93
84
|
z = caller
|
|
94
85
|
CLI::UI.raw do
|