legionio 1.6.31 → 1.6.32

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: 56ff571f5c8480be8a6e783549dd9cd377805dd5590e0e07a7fa60f2f0da39da
4
- data.tar.gz: 6d6251a3b04602caffb8e76b12fc1677c7caf1981a5e7f9cd73c1bea52bf97d6
3
+ metadata.gz: 382eadc81bfd7449fe1d3d8c5c9e833795cb17deaab9d6b00c04a0d10508ad88
4
+ data.tar.gz: c3df12bdc9300f8c1cf4ace607b623140286c78c7615b35f774809c5ecb7e53e
5
5
  SHA512:
6
- metadata.gz: 9b40b7e385bfa89fd4642ea18bc0bc014ba6407dc182f3ffbb8462963e45c9afffa0471015cda08a77c78ad71eabb31709cfe69a967653ae531c3457e5b90419
7
- data.tar.gz: c6fa233e90846bf0296f1f9ba3adf171d9ccfa34d224de96fcd721e1e013218c3cfc778988a80d661d57a93ca1002a1b57ffba0a75033aa40b657b1f92c2e9f3
6
+ metadata.gz: eb50b2ab0354df1dbcf053bb6794944ec29265c482bc274dafdc2c42a288e0349a187d07ff5172ad36ed203729a6714e5c2a9601e032c3592e1f8df22276ce39
7
+ data.tar.gz: c0a172f265bd7bf5e07bf34460d7f9a5b7be2b872c11e21f5de47e7354a67a0e4e425a7b3ef70d9bc9b54c61ccb920f820ab9b33ab059a38ff9977f0852f560d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.6.32] - 2026-03-28
6
+
7
+ ### Added
8
+ - `POST /api/logs` endpoint (`Routes::Logs`) — accepts `error`/`warn` level messages from CLI, normalizes with server-side metadata (timestamp, node, legion_versions, ruby_version, pid), computes `error_fingerprint` via `EventBuilder.fingerprint` when `exception_class` is present, and publishes to the `legion.logging` exchange with routing key `legion.logging.exception.{level}.cli.{source}` or `legion.logging.log.{level}.cli.{source}`
9
+ - `Legion::CLI::ErrorForwarder` module — fire-and-forget HTTP helper that POSTs CLI errors/warnings to the daemon API; silently swallows all failures so daemon unavailability never crashes the CLI
10
+ - `ErrorForwarder.forward_error` wired into both rescue blocks in `CLI::Main.start` (fires before `exit(1)`)
11
+
5
12
  ## [1.6.31] - 2026-03-28
6
13
 
7
14
  ### Fixed
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module Absorbers
7
+ def self.registered(app)
8
+ app.get '/api/absorbers' do
9
+ patterns = Legion::Extensions::Absorbers::PatternMatcher.list
10
+ items = patterns.map do |p|
11
+ {
12
+ type: p[:type],
13
+ value: p[:value],
14
+ priority: p[:priority],
15
+ description: p[:description],
16
+ absorber_class: p[:absorber_class]&.name
17
+ }
18
+ end
19
+ json_response(items)
20
+ end
21
+
22
+ app.get '/api/absorbers/resolve' do
23
+ input = params[:url] || params[:input]
24
+ halt 400, json_error('missing_param', 'url parameter is required') unless input
25
+
26
+ absorber = Legion::Extensions::Absorbers::PatternMatcher.resolve(input)
27
+ json_response({
28
+ input: input,
29
+ match: !absorber.nil?,
30
+ absorber: absorber&.name
31
+ })
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ class API < Sinatra::Base
7
+ module Routes
8
+ module AuthTeams
9
+ # In-memory pending auth states (state -> { verifier:, created_at:, result: })
10
+ @pending = {}
11
+ @mutex = Mutex.new
12
+
13
+ class << self
14
+ attr_reader :pending, :mutex
15
+ end
16
+
17
+ def self.registered(app)
18
+ register_store_helper(app)
19
+ register_authorize(app)
20
+ register_status(app)
21
+ register_callback(app)
22
+ end
23
+
24
+ def self.register_authorize(app)
25
+ app.post '/api/auth/teams/authorize' do
26
+ teams_settings = Legion::Settings[:microsoft_teams] || {}
27
+ auth_settings = teams_settings[:auth] || {}
28
+
29
+ tenant_id = teams_settings[:tenant_id] || auth_settings[:tenant_id]
30
+ client_id = teams_settings[:client_id] || auth_settings[:client_id]
31
+
32
+ halt 422, json_error('missing_config', 'microsoft_teams.tenant_id and client_id required', status_code: 422) unless tenant_id && client_id
33
+
34
+ body = parse_request_body
35
+ delegated = auth_settings[:delegated] || {}
36
+ scopes = body[:scopes] || delegated[:scopes] ||
37
+ 'OnlineMeetings.Read OnlineMeetingTranscript.Read.All offline_access'
38
+
39
+ state = SecureRandom.hex(32)
40
+ verifier = SecureRandom.urlsafe_base64(32)
41
+ challenge = Base64.urlsafe_encode64(
42
+ Digest::SHA256.digest(verifier), padding: false
43
+ )
44
+
45
+ port = Legion::Settings.dig(:api, :port) || 4567
46
+ redirect_uri = "http://127.0.0.1:#{port}/api/auth/teams/callback"
47
+
48
+ authorize_url = "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/authorize?" \
49
+ "client_id=#{client_id}&response_type=code&redirect_uri=#{::URI.encode_www_form_component(redirect_uri)}" \
50
+ "&scope=#{::URI.encode_www_form_component(scopes)}" \
51
+ "&state=#{state}&code_challenge=#{challenge}&code_challenge_method=S256"
52
+
53
+ AuthTeams.mutex.synchronize do
54
+ AuthTeams.pending[state] = { verifier: verifier, created_at: Time.now, result: nil,
55
+ tenant_id: tenant_id, client_id: client_id,
56
+ redirect_uri: redirect_uri, scopes: scopes }
57
+ end
58
+
59
+ json_response({ authorize_url: authorize_url, state: state })
60
+ end
61
+ end
62
+
63
+ def self.register_status(app)
64
+ app.get '/api/auth/teams/status' do
65
+ state = params[:state]
66
+ halt 422, json_error('missing_state', 'state parameter required', status_code: 422) unless state
67
+
68
+ entry = AuthTeams.mutex.synchronize { AuthTeams.pending[state] }
69
+ halt 404, json_error('unknown_state', 'no pending auth for this state', status_code: 404) unless entry
70
+
71
+ if entry[:result]
72
+ AuthTeams.mutex.synchronize { AuthTeams.pending.delete(state) }
73
+ json_response(entry[:result])
74
+ else
75
+ json_response({ authenticated: false, waiting: true })
76
+ end
77
+ end
78
+ end
79
+
80
+ def self.register_callback(app)
81
+ app.get '/api/auth/teams/callback' do
82
+ code = params[:code]
83
+ state = params[:state]
84
+ error = params[:error]
85
+
86
+ entry = AuthTeams.mutex.synchronize { AuthTeams.pending[state] }
87
+
88
+ if error || !entry
89
+ msg = error || 'unknown state'
90
+ AuthTeams.mutex.synchronize { entry[:result] = { authenticated: false, error: msg } } if entry
91
+ content_type :html
92
+ return '<html><body><h2>Authentication failed.</h2><p>You can close this tab.</p></body></html>'
93
+ end
94
+
95
+ # Exchange code for token
96
+ require 'net/http'
97
+ token_uri = ::URI.parse("https://login.microsoftonline.com/#{entry[:tenant_id]}/oauth2/v2.0/token")
98
+ token_response = ::Net::HTTP.post_form(token_uri, {
99
+ 'client_id' => entry[:client_id],
100
+ 'grant_type' => 'authorization_code',
101
+ 'code' => code,
102
+ 'redirect_uri' => entry[:redirect_uri],
103
+ 'code_verifier' => entry[:verifier],
104
+ 'scope' => entry[:scopes]
105
+ })
106
+
107
+ token_body = Legion::JSON.load(token_response.body)
108
+
109
+ if token_body[:access_token]
110
+ # Store token via TokenCache if available
111
+ store_teams_token(token_body, entry[:scopes])
112
+ AuthTeams.mutex.synchronize { entry[:result] = { authenticated: true } }
113
+ content_type :html
114
+ '<html><body><h2>Authentication successful!</h2><p>You can close this tab.</p></body></html>'
115
+ else
116
+ err = token_body[:error_description] || token_body[:error] || 'token exchange failed'
117
+ Legion::Logging.error "Teams OAuth token exchange failed: #{err}" if defined?(Legion::Logging)
118
+ AuthTeams.mutex.synchronize { entry[:result] = { authenticated: false, error: err } }
119
+ content_type :html
120
+ "<html><body><h2>Authentication failed.</h2><p>#{err}</p></body></html>"
121
+ end
122
+ rescue StandardError => e
123
+ Legion::Logging.error "Teams OAuth callback error: #{e.message}" if defined?(Legion::Logging)
124
+ AuthTeams.mutex.synchronize { entry[:result] = { authenticated: false, error: e.message } } if entry
125
+ content_type :html
126
+ '<html><body><h2>Authentication error.</h2><p>Check daemon logs.</p></body></html>'
127
+ end
128
+ end
129
+
130
+ module TeamsTokenHelper
131
+ def store_teams_token(token_body, scopes)
132
+ require 'legion/extensions/microsoft_teams/helpers/token_cache'
133
+ cache = Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.new
134
+ cache.store_delegated_token(
135
+ access_token: token_body[:access_token],
136
+ refresh_token: token_body[:refresh_token],
137
+ expires_in: token_body[:expires_in] || 3600,
138
+ scopes: scopes
139
+ )
140
+ cache.save_to_vault
141
+ Legion::Logging.info 'Teams delegated token stored' if defined?(Legion::Logging)
142
+ rescue StandardError => e
143
+ Legion::Logging.warn "Failed to store Teams token: #{e.message}" if defined?(Legion::Logging)
144
+ end
145
+ end
146
+
147
+ def self.register_store_helper(app)
148
+ app.helpers TeamsTokenHelper
149
+ end
150
+
151
+ class << self
152
+ private :register_authorize, :register_status, :register_callback, :register_store_helper
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module Logs
7
+ VALID_LEVELS = %w[error warn].freeze
8
+
9
+ def self.registered(app)
10
+ register_ingest(app)
11
+ end
12
+
13
+ def self.register_ingest(app)
14
+ app.post '/api/logs' do
15
+ body = parse_request_body
16
+ Legion::API::Routes::Logs.validate_log_request!(self, body)
17
+
18
+ level = body[:level].to_s
19
+ source = body[:source].to_s.then { |s| s.empty? ? 'unknown' : s }
20
+ payload = Legion::API::Routes::Logs.build_log_payload(body, level, source)
21
+ key = Legion::API::Routes::Logs.routing_key_for(body, level, source)
22
+
23
+ Legion::Transport::Messages::Dynamic.new(
24
+ exchange: 'legion.logging', routing_key: key, **payload
25
+ ).publish
26
+
27
+ json_response({ published: true, routing_key: key }, status_code: 201)
28
+ rescue StandardError => e
29
+ Legion::Logging.error "API POST /api/logs: #{e.class} - #{e.message}" if defined?(Legion::Logging)
30
+ halt 500, json_error('publish_error', e.message, status_code: 500)
31
+ end
32
+ end
33
+
34
+ def self.validate_log_request!(ctx, body)
35
+ unless VALID_LEVELS.include?(body[:level].to_s)
36
+ Legion::Logging.warn 'API POST /api/logs returned 422: level must be error or warn' if defined?(Legion::Logging)
37
+ ctx.halt 422, ctx.json_error('invalid_level', 'level must be "error" or "warn"', status_code: 422)
38
+ end
39
+
40
+ return unless body[:message].to_s.strip.empty?
41
+
42
+ Legion::Logging.warn 'API POST /api/logs returned 422: message is required' if defined?(Legion::Logging)
43
+ ctx.halt 422, ctx.json_error('missing_field', 'message is required', status_code: 422)
44
+ end
45
+
46
+ def self.build_log_payload(body, level, source)
47
+ payload = {
48
+ level: level,
49
+ message: body[:message].to_s,
50
+ timestamp: Time.now.utc.iso8601(3),
51
+ node: Legion::Settings[:client][:name],
52
+ legion_versions: Legion::Logging::EventBuilder.send(:legion_versions),
53
+ ruby_version: "#{RUBY_VERSION} #{RUBY_PLATFORM}",
54
+ pid: ::Process.pid,
55
+ component_type: body[:component_type].to_s.then { |t| t.empty? ? 'cli' : t },
56
+ source: source
57
+ }
58
+ payload[:exception_class] = body[:exception_class] if body[:exception_class]
59
+ payload[:backtrace] = body[:backtrace] if body[:backtrace]
60
+ payload[:command] = body[:command] if body[:command]
61
+ payload[:error_fingerprint] = fingerprint_for(body, payload) if body[:exception_class]
62
+ payload
63
+ end
64
+
65
+ def self.fingerprint_for(body, payload)
66
+ Legion::Logging::EventBuilder.fingerprint(
67
+ exception_class: body[:exception_class].to_s,
68
+ message: body[:message].to_s,
69
+ caller_file: '',
70
+ caller_line: 0,
71
+ caller_function: '',
72
+ gem_name: '',
73
+ component_type: payload[:component_type],
74
+ backtrace: Array(body[:backtrace])
75
+ )
76
+ end
77
+
78
+ def self.routing_key_for(body, level, source)
79
+ kind = body[:exception_class] ? 'exception' : 'log'
80
+ "legion.logging.#{kind}.#{level}.cli.#{source}"
81
+ end
82
+
83
+ class << self
84
+ private :register_ingest, :fingerprint_for
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
data/lib/legion/api.rb CHANGED
@@ -26,6 +26,7 @@ require_relative 'api/gaia'
26
26
  require_relative 'api/openapi'
27
27
  require_relative 'api/rbac'
28
28
  require_relative 'api/auth'
29
+ require_relative 'api/auth_teams'
29
30
  require_relative 'api/auth_worker'
30
31
  require_relative 'api/auth_human'
31
32
  require_relative 'api/auth_saml'
@@ -44,7 +45,9 @@ require_relative 'api/apollo'
44
45
  require_relative 'api/costs'
45
46
  require_relative 'api/traces'
46
47
  require_relative 'api/stats'
48
+ require_relative 'api/absorbers'
47
49
  require_relative 'api/codegen'
50
+ require_relative 'api/logs'
48
51
  require_relative 'api/router'
49
52
  require_relative 'api/library_routes'
50
53
  require_relative 'api/sync_dispatch'
@@ -152,6 +155,7 @@ module Legion
152
155
  register Routes::Gaia unless router.library_names.include?('gaia')
153
156
  register Routes::Rbac unless router.library_names.include?('rbac')
154
157
  register Routes::Auth
158
+ register Routes::AuthTeams
155
159
  register Routes::AuthWorker
156
160
  register Routes::AuthHuman
157
161
  register Routes::AuthSaml
@@ -169,7 +173,9 @@ module Legion
169
173
  register Routes::Costs
170
174
  register Routes::Traces
171
175
  register Routes::Stats
176
+ register Routes::Absorbers
172
177
  register Routes::Codegen
178
+ register Routes::Logs
173
179
  register Routes::GraphQL if defined?(Routes::GraphQL)
174
180
 
175
181
  use Legion::API::Middleware::RequestLogger
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'legion/extensions/absorbers'
4
- require 'legion/extensions/absorbers/pattern_matcher'
5
- require 'legion/extensions/actors/absorber_dispatch'
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
6
 
7
7
  module Legion
8
8
  module CLI
@@ -18,6 +18,10 @@ module Legion
18
18
  option :scope, type: :string, default: 'global', desc: 'Knowledge scope (global/local/all)'
19
19
  def url(input_url)
20
20
  Connection.ensure_settings
21
+ require 'legion/extensions/absorbers'
22
+ require 'legion/extensions/absorbers/pattern_matcher'
23
+ require 'legion/extensions/actors/absorber_dispatch'
24
+
21
25
  out = formatter
22
26
  result = Legion::Extensions::Actors::AbsorberDispatch.dispatch(
23
27
  input: input_url,
@@ -36,9 +40,8 @@ module Legion
36
40
 
37
41
  desc 'list', 'List registered absorber patterns'
38
42
  def list
39
- Connection.ensure_settings
40
43
  out = formatter
41
- patterns = Legion::Extensions::Absorbers::PatternMatcher.list
44
+ patterns = fetch_absorbers
42
45
 
43
46
  if options[:json]
44
47
  out.json(patterns.map { |p| { type: p[:type], value: p[:value], description: p[:description] } })
@@ -56,14 +59,13 @@ module Legion
56
59
 
57
60
  desc 'resolve URL', 'Show which absorber would handle a URL (dry run)'
58
61
  def resolve(input_url)
59
- Connection.ensure_settings
60
62
  out = formatter
61
- absorber = Legion::Extensions::Absorbers::PatternMatcher.resolve(input_url)
63
+ result = fetch_resolve(input_url)
62
64
 
63
65
  if options[:json]
64
- out.json({ input: input_url, absorber: absorber&.name, match: !absorber.nil? })
65
- elsif absorber
66
- out.success("#{input_url} -> #{absorber.name}")
66
+ out.json(result)
67
+ elsif result[:match]
68
+ out.success("#{input_url} -> #{result[:absorber]}")
67
69
  else
68
70
  out.warn("No absorber registered for: #{input_url}")
69
71
  end
@@ -73,6 +75,41 @@ module Legion
73
75
  def formatter
74
76
  @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color])
75
77
  end
78
+
79
+ def api_port
80
+ Connection.ensure_settings
81
+ api_settings = Legion::Settings[:api]
82
+ (api_settings.is_a?(Hash) && api_settings[:port]) || 4567
83
+ rescue StandardError
84
+ 4567
85
+ end
86
+
87
+ def api_get(path)
88
+ uri = URI("http://127.0.0.1:#{api_port}#{path}")
89
+ response = Net::HTTP.get_response(uri)
90
+ unless response.is_a?(Net::HTTPSuccess)
91
+ formatter.error("API returned #{response.code} for #{path}")
92
+ raise SystemExit, 1
93
+ end
94
+ body = ::JSON.parse(response.body, symbolize_names: true)
95
+ body[:data]
96
+ rescue Errno::ECONNREFUSED
97
+ formatter.error('Daemon not running. Start with: legionio start')
98
+ raise SystemExit, 1
99
+ rescue SystemExit
100
+ raise
101
+ rescue StandardError => e
102
+ formatter.error("API request failed: #{e.message}")
103
+ raise SystemExit, 1
104
+ end
105
+
106
+ def fetch_absorbers
107
+ api_get('/api/absorbers')
108
+ end
109
+
110
+ def fetch_resolve(input_url)
111
+ api_get("/api/absorbers/resolve?url=#{URI.encode_www_form_component(input_url)}")
112
+ end
76
113
  end
77
114
  end
78
115
  end
@@ -20,58 +20,61 @@ module Legion
20
20
  method_option :scopes, type: :string, desc: 'OAuth scopes to request'
21
21
  def teams
22
22
  out = formatter
23
- require 'legion/settings'
24
- Legion::Settings.load unless Legion::Settings.instance_variable_get(:@loader)
23
+ Connection.ensure_settings
25
24
 
26
- auth_settings = Legion::Settings.dig(:microsoft_teams, :auth) || {}
27
- delegated = auth_settings[:delegated] || {}
25
+ port = begin
26
+ Legion::Settings.dig(:api, :port) || 4567
27
+ rescue StandardError
28
+ 4567
29
+ end
30
+
31
+ out.header('Microsoft Teams Authentication')
28
32
 
29
- tenant_id = options[:tenant_id] || auth_settings[:tenant_id]
30
- client_id = options[:client_id] || auth_settings[:client_id]
31
- scopes = options[:scopes] || delegated[:scopes] ||
32
- 'OnlineMeetings.Read OnlineMeetingTranscript.Read.All offline_access'
33
+ require 'net/http'
34
+ require 'legion/json'
33
35
 
34
- unless tenant_id && client_id
35
- out.error('Missing tenant_id or client_id. Set in settings or pass --tenant-id and --client-id')
36
+ # Ask the daemon for the authorize URL
37
+ uri = ::URI.parse("http://127.0.0.1:#{port}/api/auth/teams/authorize")
38
+ params = {}
39
+ params[:scopes] = options[:scopes] if options[:scopes]
40
+ response = ::Net::HTTP.post(uri, Legion::JSON.dump(params), 'Content-Type' => 'application/json')
41
+ parsed = Legion::JSON.load(response.body)
42
+
43
+ unless response.code.to_i == 200 && parsed.dig(:data, :authorize_url)
44
+ error_msg = parsed.dig(:error, :message) || "HTTP #{response.code}"
45
+ out.error("Daemon returned: #{error_msg}")
36
46
  raise SystemExit, 1
37
47
  end
38
48
 
39
- require 'legion/extensions/microsoft_teams/helpers/browser_auth'
40
- browser_auth = Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth.new(
41
- tenant_id: tenant_id,
42
- client_id: client_id,
43
- scopes: scopes
44
- )
49
+ url = parsed[:data][:authorize_url]
50
+ out.info('Opening browser for Microsoft login...')
51
+ system('open', url) || out.warn("Open this URL manually:\n #{url}")
52
+ out.info('Waiting for callback on daemon...')
53
+
54
+ # Poll daemon for auth result
55
+ poll_uri = ::URI.parse("http://127.0.0.1:#{port}/api/auth/teams/status?state=#{parsed.dig(:data, :state)}")
56
+ 30.times do
57
+ sleep 2
58
+ poll_response = ::Net::HTTP.get_response(poll_uri)
59
+ poll_data = Legion::JSON.load(poll_response.body)
60
+
61
+ if poll_data.dig(:data, :authenticated)
62
+ out.success('Authentication successful! Token stored by daemon.')
63
+ return
64
+ end
45
65
 
46
- out.header('Microsoft Teams Authentication')
47
- result = browser_auth.authenticate
66
+ next unless poll_data.dig(:data, :error)
48
67
 
49
- if result[:error]
50
- out.error("Authentication failed: #{result[:error]} - #{result[:description]}")
68
+ out.error("Authentication failed: #{poll_data[:data][:error]}")
51
69
  raise SystemExit, 1
52
70
  end
53
71
 
54
- body = result[:result]
55
- out.success('Authentication successful!')
56
-
57
- require 'legion/extensions/microsoft_teams/helpers/token_cache'
58
- cache = Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.new
59
- cache.store_delegated_token(
60
- access_token: body['access_token'],
61
- refresh_token: body['refresh_token'],
62
- expires_in: body['expires_in'] || 3600,
63
- scopes: scopes
64
- )
65
-
66
- if cache.save_to_vault
67
- out.success('Token saved to Vault')
68
- else
69
- out.warn('Could not save token to Vault (Vault may not be connected)')
70
- end
71
-
72
- return unless options[:json]
73
-
74
- out.json({ authenticated: true, scopes: scopes, expires_in: body['expires_in'] })
72
+ out.error('Timed out waiting for authentication (60s)')
73
+ raise SystemExit, 1
74
+ rescue Errno::ECONNREFUSED
75
+ out = formatter
76
+ out.error('Daemon not running. Start it first: legionio start')
77
+ raise SystemExit, 1
75
78
  end
76
79
 
77
80
  desc 'kerberos', 'Authenticate using Kerberos TGT from your workstation'
@@ -16,16 +16,8 @@ module Legion
16
16
  desc: 'OAuth2 scopes (space-separated)'
17
17
  method_option :no_browser, type: :boolean, default: false, desc: 'Print URL instead of launching browser'
18
18
  def microsoft
19
- require 'legion/auth/token_manager'
20
- manager = Legion::Auth::TokenManager.new(provider: :microsoft)
21
-
22
- if manager.token_valid?
23
- say 'Already connected to Microsoft. Use --force to reconnect.', :green
24
- return
25
- end
26
-
27
- say 'Connecting to Microsoft...', :blue
28
- say 'OAuth2 browser flow not yet implemented. Use `legion auth teams` for Teams-specific auth.', :yellow
19
+ say 'Delegating to Teams OAuth2 browser auth...', :blue
20
+ Legion::CLI::Auth.start(['teams'] + ARGV.select { |a| a.start_with?('--') })
29
21
  end
30
22
 
31
23
  desc 'github', 'Connect a GitHub account (OAuth2 device flow)'
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module Legion
6
+ module CLI
7
+ module ErrorForwarder
8
+ module_function
9
+
10
+ def forward_error(exception, command: nil)
11
+ payload = {
12
+ level: 'error',
13
+ message: exception.message.to_s,
14
+ exception_class: exception.class.name,
15
+ backtrace: Array(exception.backtrace).first(10),
16
+ component_type: 'cli',
17
+ source: ::File.basename($PROGRAM_NAME)
18
+ }
19
+ payload[:command] = command if command
20
+ post_to_daemon(payload)
21
+ rescue StandardError
22
+ # silently swallow — forwarding must never crash the CLI
23
+ end
24
+
25
+ def forward_warning(message, command: nil)
26
+ payload = {
27
+ level: 'warn',
28
+ message: message.to_s,
29
+ component_type: 'cli',
30
+ source: ::File.basename($PROGRAM_NAME)
31
+ }
32
+ payload[:command] = command if command
33
+ post_to_daemon(payload)
34
+ rescue StandardError
35
+ # silently swallow — forwarding must never crash the CLI
36
+ end
37
+
38
+ def post_to_daemon(payload)
39
+ port = daemon_port
40
+ uri = URI("http://localhost:#{port}/api/logs")
41
+
42
+ http = Net::HTTP.new(uri.host, uri.port)
43
+ http.open_timeout = 2
44
+ http.read_timeout = 2
45
+
46
+ request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
47
+ request.body = ::JSON.generate(payload)
48
+
49
+ http.request(request)
50
+ rescue StandardError
51
+ nil
52
+ end
53
+
54
+ def daemon_port
55
+ require 'legion/settings'
56
+ Legion::Settings.load unless Legion::Settings.instance_variable_get(:@loader)
57
+ api_settings = Legion::Settings[:api]
58
+ (api_settings.is_a?(Hash) && api_settings[:port]) || 4567
59
+ rescue StandardError
60
+ 4567
61
+ end
62
+ end
63
+ end
64
+ end
data/lib/legion/cli.rb CHANGED
@@ -6,6 +6,7 @@ require 'legion/cli/error'
6
6
  require 'legion/cli/error_handler'
7
7
  require 'legion/cli/output'
8
8
  require 'legion/cli/connection'
9
+ require 'legion/cli/error_forwarder'
9
10
 
10
11
  module Legion
11
12
  module CLI
@@ -92,12 +93,14 @@ module Legion
92
93
  Legion::Logging.error("CLI::Main.start CLI error: #{e.message}") if defined?(Legion::Logging)
93
94
  formatter = Output::Formatter.new(json: given_args.include?('--json'), color: !given_args.include?('--no-color'))
94
95
  ErrorHandler.format_error(e, formatter)
96
+ ErrorForwarder.forward_error(e, command: given_args.join(' '))
95
97
  exit(1)
96
98
  rescue StandardError => e
97
99
  Legion::Logging.error("CLI::Main.start unexpected error: #{e.message}") if defined?(Legion::Logging)
98
100
  wrapped = ErrorHandler.wrap(e)
99
101
  formatter = Output::Formatter.new(json: given_args.include?('--json'), color: !given_args.include?('--no-color'))
100
102
  ErrorHandler.format_error(wrapped, formatter)
103
+ ErrorForwarder.forward_error(e, command: given_args.join(' '))
101
104
  exit(1)
102
105
  end
103
106
 
@@ -282,6 +285,9 @@ module Legion
282
285
  desc 'absorb SUBCOMMAND', 'Absorb content from external sources'
283
286
  subcommand 'absorb', AbsorbCommand
284
287
 
288
+ desc 'auth SUBCOMMAND', 'Authenticate with external services (Teams, Kerberos)'
289
+ subcommand 'auth', Auth
290
+
285
291
  desc 'connect PROVIDER', 'Connect external accounts via OAuth2'
286
292
  subcommand 'connect', ConnectCommand
287
293
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.6.31'
4
+ VERSION = '1.6.32'
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.6.31
4
+ version: 1.6.32
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -438,12 +438,14 @@ files:
438
438
  - lib/legion.rb
439
439
  - lib/legion/alerts.rb
440
440
  - lib/legion/api.rb
441
+ - lib/legion/api/absorbers.rb
441
442
  - lib/legion/api/acp.rb
442
443
  - lib/legion/api/apollo.rb
443
444
  - lib/legion/api/audit.rb
444
445
  - lib/legion/api/auth.rb
445
446
  - lib/legion/api/auth_human.rb
446
447
  - lib/legion/api/auth_saml.rb
448
+ - lib/legion/api/auth_teams.rb
447
449
  - lib/legion/api/auth_worker.rb
448
450
  - lib/legion/api/capacity.rb
449
451
  - lib/legion/api/catalog.rb
@@ -471,6 +473,7 @@ files:
471
473
  - lib/legion/api/lex_dispatch.rb
472
474
  - lib/legion/api/library_routes.rb
473
475
  - lib/legion/api/llm.rb
476
+ - lib/legion/api/logs.rb
474
477
  - lib/legion/api/marketplace.rb
475
478
  - lib/legion/api/metrics.rb
476
479
  - lib/legion/api/middleware/api_version.rb
@@ -623,6 +626,7 @@ files:
623
626
  - lib/legion/cli/doctor/vault_check.rb
624
627
  - lib/legion/cli/doctor_command.rb
625
628
  - lib/legion/cli/error.rb
629
+ - lib/legion/cli/error_forwarder.rb
626
630
  - lib/legion/cli/error_handler.rb
627
631
  - lib/legion/cli/eval_command.rb
628
632
  - lib/legion/cli/failover_command.rb