cli-kit 5.2.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.yml +1 -0
- data/Gemfile.lock +2 -1
- data/cli-kit.gemspec +2 -1
- data/lib/cli/kit/config.rb +196 -2
- data/lib/cli/kit/core_ext.rb +115 -3
- data/lib/cli/kit/error_handler.rb +32 -6
- data/lib/cli/kit/version.rb +1 -1
- data/lib/cli/kit.rb +3 -2
- metadata +18 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c406f8c2cf0b1c558c25cd0e6af9e4dd1de520f8a304e8762ee4e2086d72ab15
|
|
4
|
+
data.tar.gz: c916e638ee9dafd16b5ad646ba43ed1d226fe386488e1a0072a7b5f9107713f5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c111015f730162b6ccf7f6f83d45782b27bdc044739cdc100146e08468acc4888ae363a9703f7c044b14e248a291f82d0c8f77cd7986bc2539f52d361b17356f
|
|
7
|
+
data.tar.gz: 9f8716992e5a263a8657a2e20035e679eb953cf790727283855804e6d433d5c53b412ec8f34b4fe5b09b6b82a98067ab76b32377c0108203d2c31d6f08143ca8
|
data/.github/release.yml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
changelog:
|
|
2
|
+
exclude:
|
|
3
|
+
labels:
|
|
4
|
+
- dependencies
|
|
5
|
+
authors:
|
|
6
|
+
- dependabot
|
|
7
|
+
categories:
|
|
8
|
+
- title: 🚧 Breaking Changes
|
|
9
|
+
labels:
|
|
10
|
+
- breaking-change
|
|
11
|
+
- title: ✨ Enhancements
|
|
12
|
+
labels:
|
|
13
|
+
- enhancement
|
|
14
|
+
- feature
|
|
15
|
+
- title: 🐛 Bug Fixes
|
|
16
|
+
labels:
|
|
17
|
+
- bugfix
|
|
18
|
+
- title: 🛠 Other Changes
|
|
19
|
+
labels:
|
|
20
|
+
- "*"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
name: Release gem on RubyGems.org and create GitHub release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v[0-9]+.[0-9]+.[0-9]+'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
release_gem:
|
|
10
|
+
name: Release gem on RubyGems.org
|
|
11
|
+
if: github.repository == 'Shopify/cli-kit'
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: write
|
|
16
|
+
id-token: write
|
|
17
|
+
|
|
18
|
+
environment: release
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- name: Checkout repository
|
|
22
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
23
|
+
with:
|
|
24
|
+
persist-credentials: false
|
|
25
|
+
|
|
26
|
+
- name: Set up Ruby
|
|
27
|
+
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
|
|
28
|
+
with:
|
|
29
|
+
bundler-cache: true
|
|
30
|
+
|
|
31
|
+
- name: Release gem on RubyGems.org
|
|
32
|
+
uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1
|
|
33
|
+
|
|
34
|
+
release_github:
|
|
35
|
+
name: Create GitHub release
|
|
36
|
+
if: github.repository == 'Shopify/cli-kit'
|
|
37
|
+
needs: release_gem
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
|
|
40
|
+
permissions:
|
|
41
|
+
contents: write
|
|
42
|
+
|
|
43
|
+
steps:
|
|
44
|
+
- name: Checkout repository
|
|
45
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
46
|
+
|
|
47
|
+
- name: Create GitHub release
|
|
48
|
+
run: |
|
|
49
|
+
tag_name="$(git describe --tags --abbrev=0)"
|
|
50
|
+
gh release create "${tag_name}" --verify-tag --generate-notes
|
|
51
|
+
env:
|
|
52
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
data/.github/workflows/ruby.yml
CHANGED
data/.rubocop.yml
CHANGED
data/Gemfile.lock
CHANGED
data/cli-kit.gemspec
CHANGED
|
@@ -26,8 +26,9 @@ Gem::Specification.new do |spec|
|
|
|
26
26
|
spec.require_paths = ['lib']
|
|
27
27
|
|
|
28
28
|
spec.add_runtime_dependency('cli-ui', '~> 2.4')
|
|
29
|
+
spec.add_runtime_dependency('logger', '~> 1.7')
|
|
29
30
|
|
|
30
|
-
spec.required_ruby_version = '>= 3.
|
|
31
|
+
spec.required_ruby_version = '>= 3.2'
|
|
31
32
|
|
|
32
33
|
spec.add_development_dependency('bundler', '~> 2.1')
|
|
33
34
|
spec.add_development_dependency('minitest', '~> 5.0')
|
data/lib/cli/kit/config.rb
CHANGED
|
@@ -2,12 +2,131 @@
|
|
|
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
10
|
XDG_CONFIG_HOME = 'XDG_CONFIG_HOME'
|
|
10
11
|
|
|
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
|
+
|
|
11
130
|
#: (tool_name: String) -> void
|
|
12
131
|
def initialize(tool_name:)
|
|
13
132
|
@tool_name = tool_name
|
|
@@ -129,8 +248,83 @@ module CLI
|
|
|
129
248
|
all_configs.each do |section, sub_config|
|
|
130
249
|
all_configs.delete(section) if sub_config.empty?
|
|
131
250
|
end
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
134
328
|
end
|
|
135
329
|
end
|
|
136
330
|
end
|
data/lib/cli/kit/core_ext.rb
CHANGED
|
@@ -6,11 +6,82 @@ class Exception
|
|
|
6
6
|
# don't. I'm not sure why. If you, the reader, want to take some time to
|
|
7
7
|
# figure it out, go ahead and refactor to that.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
66
|
+
end
|
|
67
|
+
|
|
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
|
|
12
80
|
end
|
|
13
81
|
|
|
82
|
+
#: -> bool
|
|
83
|
+
def bug? = (Fault::System === fault)
|
|
84
|
+
|
|
14
85
|
#: -> bool
|
|
15
86
|
def silent?
|
|
16
87
|
false
|
|
@@ -26,3 +97,44 @@ class Exception
|
|
|
26
97
|
singleton_class.define_method(:silent?) { silent }
|
|
27
98
|
end
|
|
28
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
|
|
@@ -84,7 +84,7 @@ module CLI
|
|
|
84
84
|
rescue Interrupt => e # Ctrl-C
|
|
85
85
|
# transform message, prevent bugsnag
|
|
86
86
|
exc = e.exception('Interrupt')
|
|
87
|
-
CLI::Kit.raise(exc,
|
|
87
|
+
CLI::Kit.raise(exc, fault: Exception::Fault::TERROIR)
|
|
88
88
|
rescue Errno::ENOSPC => e
|
|
89
89
|
# transform message, prevent bugsnag
|
|
90
90
|
message = if @tool_name
|
|
@@ -93,7 +93,7 @@ module CLI
|
|
|
93
93
|
'Your disk is full - free space is required to operate'
|
|
94
94
|
end
|
|
95
95
|
exc = e.exception(message)
|
|
96
|
-
CLI::Kit.raise(exc,
|
|
96
|
+
CLI::Kit.raise(exc, fault: Exception::Fault::TERROIR)
|
|
97
97
|
end
|
|
98
98
|
# If SystemExit was raised, e.g. `exit()`, then
|
|
99
99
|
# return whatever status is attached to the exception
|
|
@@ -114,12 +114,38 @@ module CLI
|
|
|
114
114
|
e.bug? ? CLI::Kit::EXIT_BUG : CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG
|
|
115
115
|
end
|
|
116
116
|
|
|
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)
|
|
122
|
+
|
|
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
|
+
|
|
117
146
|
#: (Exception? error) -> Exception?
|
|
118
147
|
def exception_for_submission(error)
|
|
119
|
-
|
|
120
|
-
return if error.nil?
|
|
121
|
-
|
|
122
|
-
return unless error.bug?
|
|
148
|
+
return if error.nil? || caused_by_benign_signal?(error) || !caused_by_bug?(error)
|
|
123
149
|
|
|
124
150
|
case error
|
|
125
151
|
when SignalException
|
data/lib/cli/kit/version.rb
CHANGED
data/lib/cli/kit.rb
CHANGED
|
@@ -109,7 +109,7 @@ module CLI
|
|
|
109
109
|
# `depth` is used to trim leading elements of the backtrace. If you wrap
|
|
110
110
|
# this method in your own wrapper, you'll want to pass `depth: 2`, for
|
|
111
111
|
# example.
|
|
112
|
-
#: (?(Class | String | Exception) exception, ?untyped string, ?Array[String]? array, ?cause: Exception?, ?bug: bool?, ?silent: bool?, ?depth: Integer) -> bot
|
|
112
|
+
#: (?(Class | String | Exception) exception, ?untyped string, ?Array[String]? array, ?cause: Exception?, ?bug: bool?, ?silent: bool?, ?fault: Exception::Fault?, ?depth: Integer) -> bot
|
|
113
113
|
def raise(
|
|
114
114
|
# default arguments
|
|
115
115
|
exception = UNTYPED_NIL,
|
|
@@ -117,7 +117,7 @@ module CLI
|
|
|
117
117
|
array = UNTYPED_NIL,
|
|
118
118
|
cause: $ERROR_INFO,
|
|
119
119
|
# new arguments
|
|
120
|
-
bug: nil, silent: nil, depth: 1
|
|
120
|
+
bug: nil, silent: nil, fault: nil, depth: 1
|
|
121
121
|
)
|
|
122
122
|
k = Kernel #: as untyped
|
|
123
123
|
if array
|
|
@@ -132,6 +132,7 @@ module CLI
|
|
|
132
132
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
133
133
|
e.bug!(bug) unless bug.nil?
|
|
134
134
|
e.silent!(silent) unless silent.nil?
|
|
135
|
+
e.fault!(fault) if fault
|
|
135
136
|
Kernel.raise(e, cause: cause)
|
|
136
137
|
end
|
|
137
138
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cli-kit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.
|
|
4
|
+
version: 5.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Burke Libbey
|
|
@@ -26,6 +26,20 @@ dependencies:
|
|
|
26
26
|
- - "~>"
|
|
27
27
|
- !ruby/object:Gem::Version
|
|
28
28
|
version: '2.4'
|
|
29
|
+
- !ruby/object:Gem::Dependency
|
|
30
|
+
name: logger
|
|
31
|
+
requirement: !ruby/object:Gem::Requirement
|
|
32
|
+
requirements:
|
|
33
|
+
- - "~>"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '1.7'
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - "~>"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '1.7'
|
|
29
43
|
- !ruby/object:Gem::Dependency
|
|
30
44
|
name: bundler
|
|
31
45
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -81,7 +95,9 @@ extra_rdoc_files: []
|
|
|
81
95
|
files:
|
|
82
96
|
- ".github/CODEOWNERS"
|
|
83
97
|
- ".github/dependabot.yml"
|
|
98
|
+
- ".github/release.yml"
|
|
84
99
|
- ".github/workflows/cla.yml"
|
|
100
|
+
- ".github/workflows/release.yml"
|
|
85
101
|
- ".github/workflows/ruby.yml"
|
|
86
102
|
- ".gitignore"
|
|
87
103
|
- ".rubocop.sorbet.yml"
|
|
@@ -164,7 +180,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
164
180
|
requirements:
|
|
165
181
|
- - ">="
|
|
166
182
|
- !ruby/object:Gem::Version
|
|
167
|
-
version: '3.
|
|
183
|
+
version: '3.2'
|
|
168
184
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
169
185
|
requirements:
|
|
170
186
|
- - ">="
|