portage-cli 0.6.4 → 0.7.3

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.
@@ -5,6 +5,7 @@ require "uri"
5
5
  require "net/http"
6
6
  require "portage/ucp"
7
7
  require "portage/ucp/client"
8
+ require "portage/ucp/support/connection"
8
9
 
9
10
  # Buy::PermissiveAuthenticator (adapter-loopback auth) is used by
10
11
  # #adapter_session below — not require_relative'd here to avoid a load
@@ -14,6 +15,7 @@ require "portage/ucp/client"
14
15
  require_relative "payment_methods/keychain_backend"
15
16
  require_relative "payment_methods/secret_service_backend"
16
17
  require_relative "payment_methods/env_backend"
18
+ require_relative "user_agent"
17
19
 
18
20
  module Portage
19
21
  module Cli
@@ -199,7 +201,7 @@ module Portage
199
201
  # entangled with cart/checkout-specific branching this only needs the
200
202
  # session object from.
201
203
  def discover_session(url)
202
- native = Portage::Ucp::Client.discover(url)
204
+ native = Portage::Ucp::Client.discover(url, headers: UserAgent.headers)
203
205
  native if native
204
206
  rescue Portage::Ucp::Client::DiscoveryError
205
207
  adapter_session(url)
@@ -221,9 +223,9 @@ module Portage
221
223
  end
222
224
 
223
225
  def fetch_homepage(uri)
224
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
225
- open_timeout: 5, read_timeout: 5) do |http|
226
- http.get(uri.request_uri, { "User-Agent" => "portage-payment-enroll" })
226
+ response = Portage::Ucp::Support::Connection.start(uri, route: :payment, open_timeout: 5,
227
+ read_timeout: 5) do |http|
228
+ http.get(uri.request_uri, UserAgent.headers)
227
229
  end
228
230
  response.is_a?(Net::HTTPSuccess) ? [response.body, response.to_hash] : [nil, {}]
229
231
  rescue StandardError
@@ -0,0 +1,16 @@
1
+ require "portage/ucp"
2
+
3
+ module Portage
4
+ module Cli
5
+ # Loopback buy against your own store needs *some* authenticator (§9
6
+ # rejects anonymous mutation by default) — since this process already
7
+ # has this platform's own credentials (that's the gate to even reach the
8
+ # adapter-fallback path, in both Buy and HandoffReconciler), authenticating
9
+ # this local CLI session is reasonable. Shared by both rather than
10
+ # defined once as a nested class of Buy, since HandoffReconciler's own
11
+ # reconnect needs it too and shouldn't reach into Buy's internals for it.
12
+ class PermissiveAuthenticator < Portage::Ucp::Authenticator
13
+ def call(_server_context) = :local_cli
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,149 @@
1
+ require "uri"
2
+ require_relative "proxy_settings"
3
+
4
+ module Portage
5
+ module Cli
6
+ # Phase 2 of docs/plans/proxy-support.md's `doctor` checks for whatever
7
+ # `ProxySettings` resolved: each configured proxy's own reachability (a
8
+ # real CONNECT/GET through it, via Support::Connection — same code path
9
+ # a real request would use, not a re-implementation), whether a
10
+ # configured gateway answers at all, and the two warnings the plan calls
11
+ # out by name (plaintext credentials sitting in config.json, an
12
+ # intercepting proxy on the payment route). Every message redacts
13
+ # credentials the same way Support::Connection.redact does — never the
14
+ # real userinfo, in any finding this produces.
15
+ #
16
+ # Stays entirely separate from Doctor's own Phase 0 `proxy_finding`
17
+ # (the raw $http_proxy/$https_proxy env report) rather than replacing
18
+ # it — that one is still accurate and still worth showing for anyone who
19
+ # hasn't touched Phase 2 flags/config.json at all.
20
+ class ProxyDoctor
21
+ PROBE_URI = URI("https://example.com/").freeze
22
+ ROUTE_NAMES = %w[store search notify payment platform probe].freeze
23
+
24
+ def initialize(proxy_settings:, config: Config.load, timeout: 5, probe_uri: PROBE_URI)
25
+ @proxy_settings = proxy_settings
26
+ @config = config
27
+ @timeout = timeout
28
+ @probe_uri = probe_uri
29
+ end
30
+
31
+ def findings
32
+ [*route_summary_findings, *reachability_findings, *plaintext_credential_findings,
33
+ *payment_intercept_findings].compact
34
+ end
35
+
36
+ private
37
+
38
+ def routes = @proxy_settings.routes
39
+
40
+ # --- effective routing, credentials redacted -----------------------
41
+
42
+ def route_summary_findings
43
+ return [] unless anything_configured?
44
+
45
+ lines = ROUTE_NAMES.map { |name| "#{name}: #{effective_description(name)}" }
46
+ [Doctor::Finding.new(check: "proxy_routes", message: lines.join("; "))]
47
+ end
48
+
49
+ def direct_profile = Portage::Ucp::Support::ProxyConfig::Profile::DIRECT
50
+
51
+ def anything_configured?
52
+ routes.any? { |name, spec| name != "payment" || spec != direct_profile }
53
+ end
54
+
55
+ def effective_description(name)
56
+ return describe(routes[name]) if routes.key?(name)
57
+ return describe(routes["default"]) if routes.key?("default")
58
+
59
+ "unconfigured (falls back to HTTP_PROXY/HTTPS_PROXY env, or direct)"
60
+ end
61
+
62
+ def describe(spec)
63
+ spec.is_a?(Array) ? spec.map { |hop| describe_hop(hop) }.join(" -> ") : describe_hop(spec)
64
+ end
65
+
66
+ def describe_hop(hop)
67
+ return "direct" if hop.direct?
68
+
69
+ "#{hop.mode} #{redact(hop.url)}"
70
+ end
71
+
72
+ def redact(url)
73
+ return "(none)" unless url
74
+
75
+ uri = URI(url)
76
+ userinfo = uri.userinfo ? "***@" : ""
77
+ "#{uri.scheme}://#{userinfo}#{uri.host}:#{uri.port}"
78
+ rescue URI::InvalidURIError
79
+ "(unparseable)"
80
+ end
81
+
82
+ # --- reachability ---------------------------------------------------
83
+
84
+ def reachability_findings
85
+ collect_profiles.filter_map { |profile| reachability_finding(profile) }
86
+ end
87
+
88
+ def collect_profiles
89
+ hops = routes.values.flat_map { |spec| spec.is_a?(Array) ? spec : [spec] }
90
+ hops.reject(&:direct?).uniq(&:url)
91
+ end
92
+
93
+ def reachability_finding(profile)
94
+ probe(profile)
95
+ nil
96
+ rescue StandardError => e
97
+ Doctor::Finding.new(check: "proxy_reachability",
98
+ message: "#{profile.mode} proxy #{redact(profile.url)} is not reachable: " \
99
+ "#{e.class}: #{e.message}")
100
+ end
101
+
102
+ def probe(profile)
103
+ synthetic = Portage::Ucp::Support::ProxyConfig.new(routes: { "probe" => profile })
104
+ Portage::Ucp::Support::Connection.start(@probe_uri, route: "probe", proxy: synthetic,
105
+ open_timeout: @timeout, read_timeout: @timeout) do |http|
106
+ http.get(@probe_uri.request_uri, {})
107
+ end
108
+ end
109
+
110
+ # --- warnings ---------------------------------------------------
111
+
112
+ def plaintext_credential_findings
113
+ offenders = credential_bearing_profiles.select { |profile| plaintext_password?(profile) }
114
+ return [] if offenders.empty?
115
+
116
+ [Doctor::Finding.new(check: "proxy_credentials",
117
+ message: "#{offenders.length} proxy profile(s) in config.json carry a plaintext " \
118
+ "password in their url — use password_ref (Keychain/Secret Service) or " \
119
+ "${ENV} instead.")]
120
+ end
121
+
122
+ def credential_bearing_profiles
123
+ raw = @config.get("proxy") || {}
124
+ [raw["default"], *raw.fetch("chains", {}).values.flatten, *raw.fetch("routes", {}).values].grep(Hash)
125
+ end
126
+
127
+ def plaintext_password?(profile)
128
+ uri = URI(profile["url"].to_s)
129
+ !uri.password.to_s.empty?
130
+ rescue URI::InvalidURIError
131
+ false
132
+ end
133
+
134
+ def payment_intercept_findings
135
+ spec = routes["payment"]
136
+ return [] unless intercepting?(spec)
137
+
138
+ [Doctor::Finding.new(check: "proxy_payment_intercept",
139
+ message: "the payment route is routed through a proxy that can see plaintext payment " \
140
+ "data (ca_file set, or gateway mode): #{describe(spec)}.")]
141
+ end
142
+
143
+ def intercepting?(spec)
144
+ hops = spec.is_a?(Array) ? spec : [spec]
145
+ hops.any? { |hop| hop.gateway? || hop.ca_file }
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,48 @@
1
+ require "open3"
2
+ require_relative "../payment_methods"
3
+
4
+ module Portage
5
+ module Cli
6
+ class ProxySettings
7
+ # Resolves `proxy.password_ref` (and any per-hop `password_ref`) the
8
+ # same three-tier way PaymentMethods.detect_backend picks a payment
9
+ # secret store: macOS Keychain, then Linux Secret Service, then
10
+ # nothing in headless mode. Deliberately its own service name rather
11
+ # than PaymentMethods::KeychainBackend/SecretServiceBackend directly —
12
+ # those are scoped to "portage-cli-payment", and their headless tier
13
+ # (EnvBackend#read(_id)) answers *every* id with PORTAGE_PAYMENT_TOKEN
14
+ # regardless of what was asked for, which would make a misconfigured
15
+ # proxy.password_ref silently leak the payment token as a proxy
16
+ # password. A ref that can't be resolved here just comes back nil —
17
+ # ProxySettings turns that into a clear config error rather than
18
+ # sending a proxy request with a literal "password_ref" placeholder.
19
+ module PasswordRef
20
+ SERVICE = "portage-cli-proxy".freeze
21
+
22
+ module_function
23
+
24
+ def resolve(ref)
25
+ return nil if ref.to_s.strip.empty?
26
+ return keychain(ref) if macos? && Portage::Cli::PaymentMethods.executable?("security")
27
+ return secret_service(ref) if linux_session? && Portage::Cli::PaymentMethods.executable?("secret-tool")
28
+
29
+ nil
30
+ end
31
+
32
+ def macos? = RUBY_PLATFORM.include?("darwin")
33
+
34
+ def linux_session? = ENV["DBUS_SESSION_BUS_ADDRESS"].to_s != ""
35
+
36
+ def keychain(ref)
37
+ out, status = Open3.capture2("security", "find-generic-password", "-a", ref, "-s", SERVICE, "-w")
38
+ status.success? ? out.chomp : nil
39
+ end
40
+
41
+ def secret_service(ref)
42
+ out, status = Open3.capture2("secret-tool", "lookup", "service", SERVICE, "account", ref)
43
+ status.success? ? out.chomp : nil
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,350 @@
1
+ require "json"
2
+ require "uri"
3
+ require "portage/ucp"
4
+ require_relative "config"
5
+ require_relative "proxy_settings/password_ref"
6
+
7
+ module Portage
8
+ module Cli
9
+ # Phase 2 of docs/plans/proxy-support.md: resolves `--proxy*` flags,
10
+ # `PORTAGE_PROXY*` env vars, and `~/.portage/config.json`'s "proxy"
11
+ # section into the `Portage::Ucp::Support::ProxyConfig` Phase 1 already
12
+ # built — core stays exactly as ignorant of config.json as open decision
13
+ # 1 says it should (docs/plans/proxy-support.md). One instance per CLI
14
+ # invocation, same "resolved fresh, not frozen at load time" posture as
15
+ # UserAgent/Notifier's own Setting-backed resolution.
16
+ #
17
+ # Precedence is *per field*, not whole-object: a bare `--proxy URL`
18
+ # overrides only `default.url`, leaving config.json's `default.no_proxy`,
19
+ # `routes`, `chains`, etc. exactly as configured. See #build_default_spec
20
+ # and the `resolve3` calls throughout for where each field's own
21
+ # flag > env > config.json chain lives.
22
+ #
23
+ # The `payment` route is force-set to `:direct` unless the caller (flag,
24
+ # or config.json's `routes.payment`) names a proxy for it explicitly —
25
+ # the plan's "payment route defaults to direct" constraint isn't
26
+ # enforced by core's ProxyConfig#resolved_spec itself (an unlisted route
27
+ # falls back to "default" same as any other), so this class is where it
28
+ # actually holds. That also means portage-cli's own payment traffic no
29
+ # longer inherits a bare $http_proxy/$https_proxy the way every other
30
+ # unconfigured route still does (see #build_routes) — a deliberate
31
+ # narrowing to protect payment tokens from an egress proxy nobody meant
32
+ # to hand them to, not a bug.
33
+ class ProxySettings
34
+ # Raised instead of Portage::Ucp::Support::ProxyConfig::ConfigError —
35
+ # a CLI-level config problem (a bad flag, malformed config.json, an
36
+ # unresolvable password_ref) should read as portage-cli's own error,
37
+ # not leak a bare core exception class/message at the command line.
38
+ class ConfigError < StandardError; end
39
+
40
+ ENV_URL = "PORTAGE_PROXY".freeze
41
+ ENV_MODE = "PORTAGE_PROXY_MODE".freeze
42
+ ENV_NO_PROXY = "PORTAGE_NO_PROXY".freeze
43
+ ENV_CA = "PORTAGE_PROXY_CA".freeze
44
+ ENV_HEADERS = "PORTAGE_PROXY_HEADERS".freeze
45
+
46
+ ENV_INTERPOLATION = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/
47
+
48
+ ProxyConfig = Portage::Ucp::Support::ProxyConfig
49
+
50
+ # Adds every proxy flag to `parser`, accumulating into `target` under
51
+ # the keys #initialize's `flags:` expects — one call site shared by
52
+ # every network command's own option parser (buy/find/compare/doctor/
53
+ # payment enroll), per the plan's "sharing one helper".
54
+ def self.add_options(parser, target)
55
+ init_array_flags!(target)
56
+ add_scalar_options(parser, target)
57
+ add_repeatable_options(parser, target)
58
+ end
59
+
60
+ def self.init_array_flags!(target)
61
+ target[:proxy_headers] ||= []
62
+ target[:proxy_routes] ||= []
63
+ target[:proxy_passthrough] ||= []
64
+ end
65
+
66
+ def self.add_scalar_options(parser, target)
67
+ parser.on("--proxy URL") { |v| target[:proxy] = v }
68
+ parser.on("--proxy-mode MODE") { |v| target[:proxy_mode] = v }
69
+ parser.on("--no-proxy HOSTS") { |v| target[:no_proxy] = v }
70
+ parser.on("--proxy-chain CHAIN") { |v| target[:proxy_chain] = v }
71
+ parser.on("--proxy-ca FILE") { |v| target[:proxy_ca] = v }
72
+ parser.on("--no-env-proxy") { target[:no_env_proxy] = true }
73
+ end
74
+
75
+ def self.add_repeatable_options(parser, target)
76
+ parser.on("--proxy-header HEADER") { |v| target[:proxy_headers] << v }
77
+ parser.on("--proxy-route ROUTE") { |v| target[:proxy_routes] << v }
78
+ parser.on("--proxy-passthrough HEADER") { |v| target[:proxy_passthrough] << v }
79
+ end
80
+
81
+ # @param value [String] a header value, or any other config string.
82
+ def self.expand_env(value)
83
+ return value unless value.is_a?(String)
84
+
85
+ value.gsub(ENV_INTERPOLATION) { ENV.fetch(::Regexp.last_match(1), "") }
86
+ end
87
+
88
+ def self.expand_env_hash(hash)
89
+ hash.to_h { |key, value| [key.to_s, expand_env(value)] }
90
+ end
91
+
92
+ def self.parse_header_flag(raw)
93
+ name, value = raw.to_s.split(":", 2)
94
+ raise ConfigError, "--proxy-header must be \"Name: value\" (got #{raw.inspect})" unless name && value
95
+
96
+ [name.strip, value.strip]
97
+ end
98
+
99
+ def self.resolve(flags: {}, config: Config.load) = new(flags: flags, config: config).resolve
100
+
101
+ def initialize(flags: {}, config: Config.load)
102
+ @flags = flags
103
+ @config = config
104
+ @proxy_json = config.get("proxy") || {}
105
+ end
106
+
107
+ # @return [Portage::Ucp::Support::ProxyConfig]
108
+ # @raise [ConfigError]
109
+ def resolve
110
+ @resolve ||= build!
111
+ end
112
+
113
+ # @return [Hash] route name -> resolved spec (a ProxyConfig::Profile,
114
+ # or an Array chain of them — Profile::DIRECT for "direct") — the
115
+ # same shape #resolve handed to ProxyConfig.new, kept around for
116
+ # `doctor`'s effective-routing display.
117
+ def routes
118
+ resolve
119
+ @routes
120
+ end
121
+
122
+ # @return [Hash] `{"add" => {...}, "strip" => [...]}` from
123
+ # config.json's `proxy.default.forward_headers`, ${ENV}-expanded and
124
+ # protected-header-checked — parsed and stored, matching how the
125
+ # plan's `passthrough`/`inbound` sections are handled below, since
126
+ # core's Support::Connection has nowhere to apply an add/strip
127
+ # rewrite to the real target yet (that's request-building work at
128
+ # every call site, out of Phase 2's "CLI config + flags + doctor"
129
+ # scope — see the handoff report).
130
+ def forward_headers
131
+ resolve
132
+ @forward_headers
133
+ end
134
+
135
+ # @return [Hash] config.json's `proxy.passthrough`, verbatim (Phase 3
136
+ # reads this; Phase 2 only parses and stores it).
137
+ def passthrough
138
+ resolve
139
+ @passthrough
140
+ end
141
+
142
+ # @return [Hash] config.json's `proxy.inbound`, verbatim (Phase 3
143
+ # reads this; Phase 2 only parses and stores it).
144
+ def inbound
145
+ resolve
146
+ @inbound
147
+ end
148
+
149
+ def no_env_proxy? = !!@flags[:no_env_proxy]
150
+
151
+ private
152
+
153
+ def build!
154
+ @default_spec = build_default_spec
155
+ @default_spec = parse_chain_flag(@flags[:proxy_chain]) if present?(@flags[:proxy_chain])
156
+ @routes = build_routes
157
+ @forward_headers = forward_headers_meta
158
+ @passthrough = stringify(@proxy_json["passthrough"] || {})
159
+ @inbound = stringify(@proxy_json["inbound"] || {})
160
+ ProxyConfig.new(routes: @routes, no_proxy: no_proxy_list)
161
+ rescue ProxyConfig::ConfigError => e
162
+ raise ConfigError, "invalid proxy configuration: #{e.message}"
163
+ end
164
+
165
+ # --- default profile (the flag/env/config.json "default.*" fields) --
166
+
167
+ def build_default_spec
168
+ url = resolve3(@flags[:proxy], ENV_URL, @proxy_json.dig("default", "url"))
169
+ return nil unless present?(url)
170
+
171
+ raw = @proxy_json["default"] || {}
172
+ url = apply_password_ref(url, raw["password_ref"])
173
+ mode = resolve3(@flags[:proxy_mode], ENV_MODE, raw["mode"])
174
+ ca_file = resolve3(@flags[:proxy_ca], ENV_CA, raw["ca_file"])
175
+ build_profile(raw, url: url, mode: mode, proxy_headers: default_proxy_headers, ca_file: ca_file)
176
+ end
177
+
178
+ def default_proxy_headers
179
+ headers = self.class.expand_env_hash(stringify(@proxy_json.dig("default", "proxy_headers") || {}))
180
+ headers.merge!(env_headers)
181
+ headers.merge!(flag_headers)
182
+ headers
183
+ end
184
+
185
+ def env_headers
186
+ raw = ENV.fetch(ENV_HEADERS, nil)
187
+ return {} unless present?(raw)
188
+
189
+ parsed = JSON.parse(raw)
190
+ parsed.is_a?(Hash) ? stringify(parsed) : {}
191
+ rescue JSON::ParserError => e
192
+ raise ConfigError, "#{ENV_HEADERS} is not valid JSON: #{e.message}"
193
+ end
194
+
195
+ def flag_headers
196
+ Array(@flags[:proxy_headers]).to_h { |raw| self.class.parse_header_flag(raw) }
197
+ end
198
+
199
+ # --- forward_headers (parsed + protected-header-checked, not yet
200
+ # applied to any outgoing request — see #forward_headers above) ------
201
+
202
+ def forward_headers_meta
203
+ raw = @proxy_json.dig("default", "forward_headers") || {}
204
+ add = self.class.expand_env_hash(stringify(raw["add"] || {}))
205
+ strip = Array(raw["strip"]).map(&:to_s)
206
+ ProxyConfig.validate_headers!(add)
207
+ refuse_protected_strip!(strip)
208
+ { "add" => add, "strip" => strip }
209
+ end
210
+
211
+ def refuse_protected_strip!(strip)
212
+ offender = strip.find { |name| ProxyConfig.protected_header?(name) }
213
+ return unless offender
214
+
215
+ raise ProxyConfig::ConfigError,
216
+ "#{offender} is a protected header and cannot be stripped via forward_headers"
217
+ end
218
+
219
+ # --- no_proxy ---------------------------------------------------
220
+
221
+ def no_proxy_list
222
+ flag = @flags[:no_proxy]
223
+ return split_list(flag) if present?(flag)
224
+
225
+ env = ENV.fetch(ENV_NO_PROXY, nil)
226
+ return split_list(env) if present?(env)
227
+
228
+ Array(@proxy_json.dig("default", "no_proxy"))
229
+ end
230
+
231
+ def split_list(value) = value.to_s.split(",").map(&:strip).reject(&:empty?)
232
+
233
+ # --- routes -------------------------------------------------------
234
+
235
+ def build_routes
236
+ routes = {}
237
+ @proxy_json.fetch("routes", {}).each { |route, spec| routes[route.to_s] = resolve_route_spec(spec) }
238
+ routes["default"] = @default_spec if @default_spec
239
+ Array(@flags[:proxy_routes]).each { |raw| apply_route_flag!(routes, raw) }
240
+ routes["payment"] = ProxyConfig::Profile::DIRECT unless routes.key?("payment")
241
+ routes["default"] = ProxyConfig::Profile::DIRECT if no_env_proxy? && !routes.key?("default")
242
+ routes
243
+ end
244
+
245
+ def resolve_route_spec(spec)
246
+ case spec
247
+ when Hash then resolve_route_hash(spec)
248
+ when Array then spec.map { |hop| resolve_route_spec(hop) }
249
+ when String then resolve_route_string(spec)
250
+ else raise ConfigError, "invalid proxy route value #{spec.inspect}"
251
+ end
252
+ end
253
+
254
+ def resolve_route_hash(spec)
255
+ spec["chain"] ? resolve_chain_name(spec["chain"]) : resolve_inline_profile(spec)
256
+ end
257
+
258
+ def resolve_route_string(spec)
259
+ return ProxyConfig::Profile::DIRECT if spec == "direct"
260
+ return @default_spec || ProxyConfig::Profile::DIRECT if spec == "default"
261
+ return resolve_inline_profile("url" => spec) if spec.include?("://")
262
+
263
+ resolve_chain_name(spec)
264
+ end
265
+
266
+ def resolve_chain_name(name)
267
+ chain = @proxy_json.dig("chains", name)
268
+ raise ConfigError, "unknown proxy profile or chain #{name.inspect}" unless chain
269
+
270
+ Array(chain).map { |hop| resolve_inline_profile(hop) }
271
+ end
272
+
273
+ def resolve_inline_profile(raw)
274
+ url = apply_password_ref(raw["url"], raw["password_ref"])
275
+ headers = self.class.expand_env_hash(stringify(raw["proxy_headers"] || {}))
276
+ build_profile(raw, url: url, mode: raw["mode"], proxy_headers: headers)
277
+ end
278
+
279
+ def apply_route_flag!(routes, raw)
280
+ route, value = raw.to_s.split("=", 2)
281
+ raise ConfigError, "--proxy-route must be ROUTE=VALUE (got #{raw.inspect})" unless route && value
282
+
283
+ routes[route.strip] = route_value_from_flag(value.strip)
284
+ end
285
+
286
+ def route_value_from_flag(value)
287
+ return ProxyConfig::Profile::DIRECT if value == "direct"
288
+ return @default_spec || ProxyConfig::Profile::DIRECT if value == "default"
289
+ return ProxyConfig::Profile.new(url: value) if value.include?("://")
290
+
291
+ raise ConfigError, "--proxy-route: expected a URL or \"direct\" (got #{value.inspect})"
292
+ end
293
+
294
+ def parse_chain_flag(raw)
295
+ raw.to_s.split(",").map { |hop| chain_hop_from_flag(hop.strip) }
296
+ end
297
+
298
+ def chain_hop_from_flag(hop)
299
+ return ProxyConfig::Profile.new(mode: :gateway, url: hop.sub(/\Agateway\+/, "")) if hop.start_with?("gateway+")
300
+
301
+ ProxyConfig::Profile.new(mode: :forward, url: hop)
302
+ end
303
+
304
+ # --- shared profile builder ------------------------------------------
305
+
306
+ # Builds a real ProxyConfig::Profile (not a plain Hash core would only
307
+ # coerce lazily, on first #chain_for) so a protected header or a bad
308
+ # mode/url raises right here, at config-load time, from any of the
309
+ # three sources (flag/env/config.json) that fed `proxy_headers` —
310
+ # exactly what the plan's "Protected headers" constraint and this
311
+ # phase's own step 8 require, rather than surfacing on the first real
312
+ # request Support::Connection makes.
313
+ def build_profile(raw, url:, mode:, proxy_headers:, ca_file: raw["ca_file"])
314
+ kwargs = { url: url, proxy_headers: proxy_headers, ca_file: ca_file,
315
+ target_header: raw["target_header"], target_param: raw["target_param"] }.compact
316
+ kwargs[:mode] = mode.to_sym if present?(mode)
317
+ ProxyConfig::Profile.new(**kwargs)
318
+ end
319
+
320
+ def apply_password_ref(url, password_ref)
321
+ return url unless present?(url) && present?(password_ref)
322
+
323
+ secret = PasswordRef.resolve(password_ref)
324
+ unless secret
325
+ raise ConfigError, "proxy password_ref #{password_ref.inspect} could not be resolved from any local " \
326
+ "secret backend (macOS Keychain, Linux Secret Service)"
327
+ end
328
+
329
+ uri = URI(url)
330
+ uri.password = URI.encode_www_form_component(secret)
331
+ uri.to_s
332
+ end
333
+
334
+ # --- misc -----------------------------------------------------------
335
+
336
+ def resolve3(flag, env_name, config_value)
337
+ return flag if present?(flag)
338
+
339
+ from_env = env_name && ENV.fetch(env_name, nil)
340
+ return from_env if present?(from_env)
341
+
342
+ config_value if present?(config_value)
343
+ end
344
+
345
+ def present?(value) = !(value.nil? || (value.is_a?(String) && value.strip.empty?))
346
+
347
+ def stringify(hash) = hash.to_h { |key, value| [key.to_s, value] }
348
+ end
349
+ end
350
+ end
@@ -0,0 +1,65 @@
1
+ require_relative "notifier"
2
+ require_relative "macos_notifier"
3
+ require_relative "reconcile_notify"
4
+
5
+ module Portage
6
+ module Cli
7
+ # docs/plans/handoff-reconcile.md Phase 3 — fans a settle event
8
+ # (`checkout_reconciled`, from HandoffReconciler#notify) out to whichever
9
+ # channels `ReconcileNotify` names. The default `HandoffReconciler`
10
+ # notifier for both `portage buy --wait` and `portage orders reconcile`,
11
+ # replacing the plain webhook-only `Notifier` those used through Phase 1.
12
+ #
13
+ # Every channel is best-effort, same posture as `Notifier#call` itself —
14
+ # one channel failing (a bad webhook URL, no `osascript` on this host)
15
+ # never stops another from firing, and this never raises out of
16
+ # `HandoffReconciler#notify`, which already wraps the whole call in its
17
+ # own rescue.
18
+ class ReconcileNotifier
19
+ def initialize(channels: ReconcileNotify.resolve, webhook: Notifier.new, macos: MacosNotifier.new)
20
+ @channels = channels
21
+ @webhook = webhook
22
+ @macos = macos
23
+ end
24
+
25
+ def enabled?(channel) = @channels.include?(channel)
26
+
27
+ # @return [String, nil] the webhook channel's own failure message, same
28
+ # shape `Notifier#call` always returned — the other channels have no
29
+ # return-value contract to preserve, since nothing read theirs before
30
+ # this class existed.
31
+ def call(payload)
32
+ error = @webhook.call(payload) if enabled?("webhook")
33
+ notify_macos(payload) if enabled?("macos")
34
+ puts terminal_line(payload) if enabled?("terminal")
35
+ error
36
+ end
37
+
38
+ private
39
+
40
+ def notify_macos(payload)
41
+ @macos.call(title: "Portage checkout #{payload[:result]}", message: macos_message(payload))
42
+ end
43
+
44
+ def macos_message(payload)
45
+ parts = [payload[:shop], format_amount(payload[:amount], payload[:currency])].compact
46
+ parts.empty? ? "checkout #{payload[:checkout_id]}" : parts.join(" — ")
47
+ end
48
+
49
+ def terminal_line(payload)
50
+ parts = ["[reconcile] #{payload[:checkout_id]}: #{payload[:result]}"]
51
+ parts << "resolution: #{payload[:resolution]}" if payload[:resolution]
52
+ parts << "order: #{payload[:order_id]}" if payload[:order_id]
53
+ amount = format_amount(payload[:amount], payload[:currency])
54
+ parts << amount if amount
55
+ parts.join(" — ")
56
+ end
57
+
58
+ def format_amount(amount, currency)
59
+ return nil unless amount
60
+
61
+ "#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
62
+ end
63
+ end
64
+ end
65
+ end