legionio 1.4.116 → 1.4.118

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6979926154eb319a990087443af9becaa5831d4eb0ab5525f1b044287e2c279f
4
- data.tar.gz: '0129c7452e19e2f9c63f996ea254b44764a6caebfccb84f50ad0c8d058b910bb'
3
+ metadata.gz: a10f06b3cc591d8fa5aae3b31216e037826f961962df05276ba5fbd78805235c
4
+ data.tar.gz: 5a9e88f6bbb42b27586adba45becd7ecaedc69f190bd3f0fbddb3de27fcffd79
5
5
  SHA512:
6
- metadata.gz: c1d1b49e93559deda93761855fa70fb72fe76673af87cf7c5fa4819a326d57318a2cc43c7c1f8bf9d0311487e3bbe1ee9d2154f52f14254630f4db8d7302f23f
7
- data.tar.gz: fec26b4465b3806815ca5ddc62a476a54c4e88ce94654a18983aadab6940fcca9197ee8545b85038960d9688a9ba64d810499eef09374d0723081e523048766c
6
+ metadata.gz: 3cab7e4e400553baf1b57918d610dbb7a0dd1143dd4df52f104bd03e55d23e53bfbffd815c32b9836ec421ba58c613b32ec97d6af0b68f26cf0fdb55570e7790
7
+ data.tar.gz: 13c8e47feeccf21c4ea33b2ddd31f37f8afd6f73765e6df48ead40e6e2a96125019c9b5aa1107383db46958bcaf51a7cd593344f70b1d295e807dedf33b1d41f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.118] - 2026-03-22
4
+
5
+ ### Added
6
+ - `legion detect --install` interactive extension picker: multi-select via tty-prompt (when available) or numbered list fallback
7
+ - `legion detect --install-all` for non-interactive bulk install of all missing extensions
8
+ - Signal context shown in picker (e.g., which app/formula triggered the recommendation)
9
+
10
+ ## [1.4.117] - 2026-03-22
11
+
12
+ ### Added
13
+ - `Legion::CLI::Error` gains `suggestions`, `code` attributes and `.actionable` factory method
14
+ - `Legion::CLI::ErrorHandler` module: 6-pattern matcher maps common exceptions (RabbitMQ, DB, extensions, permissions, data, Vault) to actionable errors with fix suggestions
15
+ - `ErrorHandler.wrap` wraps any `StandardError` into a `CLI::Error` with suggestions when a pattern matches
16
+ - `ErrorHandler.format_error` prints suggestions below the error line when the error is actionable
17
+ - `Legion::CLI::Main.start` overrides Thor's entry point to wrap unhandled exceptions through `ErrorHandler` before exiting
18
+
3
19
  ## [1.4.116] - 2026-03-22
4
20
 
5
21
  ### Added
@@ -19,7 +19,8 @@ module Legion
19
19
  default_task :scan
20
20
 
21
21
  desc 'scan', 'Scan environment and recommend extensions (default)'
22
- option :install, type: :boolean, default: false, desc: 'Install missing extensions after scan'
22
+ option :install, type: :boolean, default: false, desc: 'Interactive install of missing extensions after scan'
23
+ option :install_all, type: :boolean, default: false, desc: 'Install all missing extensions without prompting'
23
24
  option :dry_run, type: :boolean, default: false, desc: 'Show what would be installed without installing'
24
25
  option :format, type: :string, enum: %w[sarif markdown json], desc: 'Output format (sarif, markdown, json)'
25
26
  def scan
@@ -35,7 +36,11 @@ module Legion
35
36
  out.json(detections: results)
36
37
  else
37
38
  display_detections(out, results)
38
- install_missing(out) if options[:install]
39
+ if options[:install]
40
+ interactive_install(out, results)
41
+ elsif options[:install_all]
42
+ install_missing(out)
43
+ end
39
44
  end
40
45
  end
41
46
 
@@ -130,6 +135,98 @@ module Legion
130
135
  puts " #{installed_count} of #{total_count} extension(s) installed"
131
136
  end
132
137
 
138
+ def interactive_install(out, results)
139
+ missing_gems = Legion::Extensions::Detect.missing
140
+ return out.success('All detected extensions are installed') if missing_gems.empty?
141
+
142
+ signal_map = build_signal_map(results)
143
+ selected = pick_extensions(out, missing_gems, signal_map)
144
+ if selected.empty?
145
+ puts ' No extensions selected'
146
+ return
147
+ end
148
+
149
+ if options[:dry_run]
150
+ out.header('Would install')
151
+ selected.each { |name| puts " #{name}" }
152
+ return
153
+ end
154
+
155
+ install_selected(out, selected)
156
+ end
157
+
158
+ def pick_extensions(out, missing_gems, signal_map)
159
+ if tty_prompt_available?
160
+ pick_with_tty_prompt(missing_gems, signal_map)
161
+ else
162
+ pick_with_numbers(out, missing_gems, signal_map)
163
+ end
164
+ end
165
+
166
+ def pick_with_tty_prompt(missing_gems, signal_map)
167
+ require 'tty-prompt'
168
+ prompt = ::TTY::Prompt.new
169
+
170
+ choices = missing_gems.map do |name|
171
+ label = signal_map[name] ? "#{name} (#{signal_map[name]})" : name
172
+ { name: label, value: name }
173
+ end
174
+
175
+ prompt.multi_select('Select extensions to install:', choices, per_page: 20, echo: false)
176
+ end
177
+
178
+ def pick_with_numbers(out, missing_gems, signal_map)
179
+ out.spacer
180
+ out.header('Missing Extensions')
181
+ missing_gems.each_with_index do |name, idx|
182
+ reason = signal_map[name] ? " (#{signal_map[name]})" : ''
183
+ puts " #{out.colorize((idx + 1).to_s.rjust(3), :label)} #{name}#{reason}"
184
+ end
185
+ out.spacer
186
+ puts ' Enter numbers to install (comma-separated), "all", or "none":'
187
+ print ' > '
188
+ input = $stdin.gets&.strip || 'none'
189
+
190
+ return missing_gems.dup if input.downcase == 'all'
191
+ return [] if input.empty? || input.downcase == 'none'
192
+
193
+ indices = input.split(/[,\s]+/).filter_map { |s| s.to_i - 1 if s.match?(/\A\d+\z/) }
194
+ indices.filter_map { |i| missing_gems[i] if i >= 0 && i < missing_gems.size }.uniq
195
+ end
196
+
197
+ def build_signal_map(results)
198
+ map = {}
199
+ results.each do |detection|
200
+ signals = detection[:matched_signals].join(', ')
201
+ detection[:installed].each do |gem_name, installed|
202
+ map[gem_name] = signals unless installed
203
+ end
204
+ end
205
+ map
206
+ end
207
+
208
+ def install_selected(out, selected)
209
+ out.header("Installing #{selected.size} extension(s)")
210
+ result = Legion::Extensions::Detect::Installer.install(selected)
211
+
212
+ result[:installed].each { |name| out.success(" Installed #{name}") }
213
+ result[:failed].each { |f| out.error(" Failed: #{f[:name]} — #{f[:error]}") }
214
+
215
+ out.spacer
216
+ if result[:failed].empty?
217
+ out.success("#{result[:installed].size} extension(s) installed")
218
+ else
219
+ out.warn("#{result[:installed].size} installed, #{result[:failed].size} failed")
220
+ end
221
+ end
222
+
223
+ def tty_prompt_available?
224
+ require 'tty-prompt'
225
+ true
226
+ rescue LoadError
227
+ false
228
+ end
229
+
133
230
  def install_missing(out)
134
231
  missing_gems = Legion::Extensions::Detect.missing
135
232
  return if missing_gems.empty?
@@ -2,6 +2,19 @@
2
2
 
3
3
  module Legion
4
4
  module CLI
5
- class Error < StandardError; end
5
+ class Error < StandardError
6
+ attr_reader :suggestions, :code
7
+
8
+ def self.actionable(code:, message:, suggestions: [])
9
+ err = new(message)
10
+ err.instance_variable_set(:@code, code)
11
+ err.instance_variable_set(:@suggestions, suggestions)
12
+ err
13
+ end
14
+
15
+ def actionable?
16
+ !suggestions.nil? && !suggestions.empty?
17
+ end
18
+ end
6
19
  end
7
20
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module CLI
5
+ module ErrorHandler
6
+ PATTERNS = [
7
+ {
8
+ match: /connection refused.*5672|ECONNREFUSED.*5672|bunny.*not connected/i,
9
+ code: :transport_unavailable,
10
+ message: 'Cannot connect to RabbitMQ',
11
+ suggestions: [
12
+ "Run 'legion doctor' to diagnose connectivity",
13
+ "Check transport settings: 'legion config show -s transport'",
14
+ 'Verify RabbitMQ is running: brew services list | grep rabbitmq'
15
+ ]
16
+ },
17
+ {
18
+ match: /table.*not.*found|no such table|PG::UndefinedTable|Sequel::DatabaseError.*exist/i,
19
+ code: :database_missing,
20
+ message: 'Database table not found',
21
+ suggestions: [
22
+ "Run 'legion start' to apply pending migrations",
23
+ "Check database config: 'legion config show -s data'",
24
+ "Verify database is running: 'legion doctor'"
25
+ ]
26
+ },
27
+ {
28
+ match: /extension.*not.*found|no such extension|uninitialized constant.*Extensions/i,
29
+ code: :extension_missing,
30
+ message: 'Extension not found',
31
+ suggestions: [
32
+ "Search available extensions: 'legion marketplace search <name>'",
33
+ 'Install with: gem install lex-<name>',
34
+ "List installed: 'legion lex list'"
35
+ ]
36
+ },
37
+ {
38
+ match: /permission denied|EACCES/i,
39
+ code: :permission_denied,
40
+ message: 'Permission denied',
41
+ suggestions: [
42
+ 'Try running with sudo for system directories',
43
+ 'Set custom config dir: LEGIONIO_CONFIG_DIR=~/.legionio',
44
+ 'Check file permissions: ls -la ~/.legionio/'
45
+ ]
46
+ },
47
+ {
48
+ match: /legion-data.*not.*connected|data.*not.*available/i,
49
+ code: :data_unavailable,
50
+ message: 'Database not connected',
51
+ suggestions: [
52
+ "Check database config: 'legion config show -s data'",
53
+ "Run diagnostics: 'legion doctor'",
54
+ 'Some commands work without a database — try adding --no-data flag'
55
+ ]
56
+ },
57
+ {
58
+ match: /vault.*not.*connected|vault.*sealed|VAULT_ADDR/i,
59
+ code: :vault_unavailable,
60
+ message: 'Vault not connected',
61
+ suggestions: [
62
+ "Check Vault config: 'legion config show -s crypt'",
63
+ 'Verify VAULT_ADDR and VAULT_TOKEN environment variables',
64
+ "Run diagnostics: 'legion doctor'"
65
+ ]
66
+ }
67
+ ].freeze
68
+
69
+ module_function
70
+
71
+ def wrap(error)
72
+ pattern = PATTERNS.find { |p| error.message.match?(p[:match]) }
73
+ return error unless pattern
74
+
75
+ Error.actionable(
76
+ code: pattern[:code],
77
+ message: "#{pattern[:message]}: #{error.message}",
78
+ suggestions: pattern[:suggestions]
79
+ )
80
+ end
81
+
82
+ def format_error(error, formatter)
83
+ formatter.error(error.message)
84
+ return unless error.is_a?(Error) && error.actionable?
85
+
86
+ error.suggestions.each do |suggestion|
87
+ puts " #{formatter.colorize('>', :label)} #{suggestion}"
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
data/lib/legion/cli.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'thor'
4
4
  require 'legion/version'
5
5
  require 'legion/cli/error'
6
+ require 'legion/cli/error_handler'
6
7
  require 'legion/cli/output'
7
8
  require 'legion/cli/connection'
8
9
 
@@ -61,6 +62,19 @@ module Legion
61
62
  true
62
63
  end
63
64
 
65
+ def self.start(given_args = ARGV, config = {})
66
+ super
67
+ rescue Legion::CLI::Error => e
68
+ formatter = Output::Formatter.new(json: given_args.include?('--json'), color: !given_args.include?('--no-color'))
69
+ ErrorHandler.format_error(e, formatter)
70
+ exit(1)
71
+ rescue StandardError => e
72
+ wrapped = ErrorHandler.wrap(e)
73
+ formatter = Output::Formatter.new(json: given_args.include?('--json'), color: !given_args.include?('--no-color'))
74
+ ErrorHandler.format_error(wrapped, formatter)
75
+ exit(1)
76
+ end
77
+
64
78
  class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
65
79
  class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
66
80
  class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.116'
4
+ VERSION = '1.4.118'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.116
4
+ version: 1.4.118
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -498,6 +498,7 @@ files:
498
498
  - lib/legion/cli/doctor/vault_check.rb
499
499
  - lib/legion/cli/doctor_command.rb
500
500
  - lib/legion/cli/error.rb
501
+ - lib/legion/cli/error_handler.rb
501
502
  - lib/legion/cli/eval_command.rb
502
503
  - lib/legion/cli/failover_command.rb
503
504
  - lib/legion/cli/function.rb