portage-cli 0.7.0 → 0.7.4

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.
@@ -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
@@ -0,0 +1,38 @@
1
+ require_relative "config"
2
+ require_relative "setting"
3
+
4
+ module Portage
5
+ module Cli
6
+ # docs/plans/handoff-reconcile.md Phase 3 — which channels fire when a
7
+ # handoff settles, from `portage buy --wait` or `portage orders
8
+ # reconcile` (see ReconcileNotifier). A comma list, same Setting
9
+ # precedence as every other standing preference here: an explicit
10
+ # `override:` beats PORTAGE_RECONCILE_NOTIFY, which beats config.json's
11
+ # "reconcile_notify". Unset is `webhook` only.
12
+ #
13
+ # "journal" is accepted but drives nothing here: the Phase 1 order
14
+ # snapshot journal write already happens unconditionally in
15
+ # HandoffReconciler#record_journal whenever a completed checkout carries
16
+ # an order. Naming it in `reconcile_notify` documents that as always-on
17
+ # rather than gating a second write path.
18
+ module ReconcileNotify
19
+ ENV_VAR = "PORTAGE_RECONCILE_NOTIFY".freeze
20
+ CONFIG_KEY = "reconcile_notify".freeze
21
+ CHANNELS = %w[webhook journal macos terminal].freeze
22
+ DEFAULT = %w[webhook].freeze
23
+
24
+ module_function
25
+
26
+ # @param extra [Array<String>] channels to force on regardless of
27
+ # configuration — `portage buy --wait` forces `terminal` in plain
28
+ # (non-`--json`) mode, so the shopper sees a line when it settles
29
+ # even with nothing configured.
30
+ # @return [Array<String>]
31
+ def resolve(override: nil, config: Config.load, extra: [])
32
+ raw = Setting.resolve(override: override, env: ENV_VAR, config: config, config_key: CONFIG_KEY)
33
+ configured = raw.nil? ? DEFAULT : raw.to_s.split(",").map(&:strip)
34
+ ((configured & CHANNELS) + extra).uniq
35
+ end
36
+ end
37
+ end
38
+ end
@@ -2,6 +2,9 @@ require "net/http"
2
2
  require "uri"
3
3
  require "json"
4
4
  require "yaml"
5
+ require "portage/ucp"
6
+ require "portage/ucp/support/connection"
7
+ require_relative "user_agent"
5
8
 
6
9
  module Portage
7
10
  module Cli
@@ -18,7 +21,6 @@ module Portage
18
21
  module SearchBackends
19
22
  OPEN_TIMEOUT = 5
20
23
  READ_TIMEOUT = 5
21
- USER_AGENT = "portage-find".freeze
22
24
 
23
25
  # Reference works and marketplaces-of-links that a search backend will
24
26
  # happily return for a product query but that are never themselves a UCP
@@ -45,9 +47,9 @@ module Portage
45
47
  end
46
48
 
47
49
  def self.request(uri, headers)
48
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
49
- open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
50
- http.get(uri.request_uri, { "User-Agent" => USER_AGENT }.merge(headers))
50
+ Portage::Ucp::Support::Connection.start(uri, route: :search, open_timeout: OPEN_TIMEOUT,
51
+ read_timeout: READ_TIMEOUT) do |http|
52
+ http.get(uri.request_uri, UserAgent.headers.merge(headers))
51
53
  end
52
54
  end
53
55
  private_class_method :request
@@ -20,9 +20,14 @@ module Portage
20
20
 
21
21
  # @return [Portage::Ucp::PostalAddress, nil] nil unless every required
22
22
  # field is set — a partial profile isn't enough to submit, and this
23
- # module never guesses at a missing field.
23
+ # module never guesses at a missing field. An empty value counts as
24
+ # unset, so a `.env` copied from `.env.example` with blanks left in
25
+ # doesn't submit an address of empty strings.
24
26
  def self.from_env
25
- attrs = ENV_VARS.filter_map { |key, var| [key, ENV.fetch(var, nil)] if ENV.key?(var) }.to_h
27
+ attrs = ENV_VARS.filter_map do |key, var|
28
+ value = ENV.fetch(var, nil)
29
+ [key, value] unless value.to_s.empty?
30
+ end.to_h
26
31
  return nil unless REQUIRED.all? { |key| attrs.key?(key) }
27
32
 
28
33
  Portage::Ucp::PostalAddress.new(**attrs)
@@ -0,0 +1,39 @@
1
+ require "portage/ucp/client"
2
+ require_relative "version"
3
+ require_relative "config"
4
+ require_relative "setting"
5
+
6
+ module Portage
7
+ module Cli
8
+ # Every request this CLI makes — to a store, a search backend, a
9
+ # payment handler, or a notify webhook — names itself, then the client
10
+ # gem it speaks UCP through, so a merchant reading its logs sees
11
+ # "portage-cli/... portage-ucp-client/... (+https://github.com/...)"
12
+ # rather than Ruby's default and knows where to look.
13
+ #
14
+ # A caller running many agents behind one IP (or one that just wants
15
+ # its own contact info in the string a merchant might grep for) can
16
+ # override it — same Setting precedence as Notifier's webhook URL:
17
+ # PORTAGE_USER_AGENT beats ~/.portage/config.json's "user_agent" key,
18
+ # both of which beat this default. Resolved fresh on every call rather
19
+ # than frozen at load time, so a config.json edit or an ENV change
20
+ # takes effect without restarting whatever's driving this CLI.
21
+ module UserAgent
22
+ DEFAULT = "portage-cli/#{VERSION} #{Portage::Ucp::Client::USER_AGENT}".freeze
23
+ ENV_VAR = "PORTAGE_USER_AGENT".freeze
24
+ CONFIG_KEY = "user_agent".freeze
25
+
26
+ module_function
27
+
28
+ # @param config [Config]
29
+ # @return [String] never blank — falls back to DEFAULT.
30
+ def value(config: Config.load)
31
+ Setting.resolve(env: ENV_VAR, config: config, config_key: CONFIG_KEY) || DEFAULT
32
+ end
33
+
34
+ # @param config [Config]
35
+ # @return [Hash] a fresh header hash, safe for a caller to #merge into.
36
+ def headers(config: Config.load) = { "User-Agent" => value(config: config) }
37
+ end
38
+ end
39
+ end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.7.0".freeze
3
+ VERSION = "0.7.4".freeze
4
4
  end
5
5
  end
@@ -0,0 +1,25 @@
1
+ module Portage
2
+ module Cli
3
+ # docs/plans/handoff-reconcile.md Phase 4 — `portage-ucp-webmcp` is
4
+ # optional at runtime, not in portage-cli's gemspec, same posture as
5
+ # `Decisions.available?` for `portage-ucp-decision` and
6
+ # `Resolver.build_adapter` for a platform adapter gem: `require`, then
7
+ # rescue LoadError, so `gem install portage-cli` stays light and only a
8
+ # caller who actually passes `Buy.new(webmcp_bridge:)` ever needs it
9
+ # installed.
10
+ module Webmcp
11
+ # @return [Boolean] whether portage-ucp-webmcp could be loaded.
12
+ # Memoized: `require` runs once per process.
13
+ def self.available?
14
+ return @available unless @available.nil?
15
+
16
+ @available = begin
17
+ require "portage/ucp/webmcp"
18
+ true
19
+ rescue LoadError
20
+ false
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ require_relative "config"
2
+ require_relative "setting"
3
+
4
+ module Portage
5
+ module Cli
6
+ # docs/plans/handoff-reconcile.md Phase 4 — how `Buy`'s optional WebMCP
7
+ # path (see Webmcp, Buy#webmcp_flow) finishes a checkout it built in the
8
+ # browser. Same Setting precedence as HandoffSpendMode: no CLI flag (a
9
+ # WebMCP bridge is an injected collaborator, not something a shell
10
+ # invocation can name), so just PORTAGE_WEBMCP_CHECKOUT_MODE >
11
+ # config.json's "webmcp_checkout_mode". Unset, or anything not in
12
+ # MODES, is `express_stop`, the only mode actually implemented — see
13
+ # Buy#webmcp_token_unsupported_report for why `token` isn't yet.
14
+ module WebmcpCheckoutMode
15
+ ENV_VAR = "PORTAGE_WEBMCP_CHECKOUT_MODE".freeze
16
+ CONFIG_KEY = "webmcp_checkout_mode".freeze
17
+ MODES = %w[express_stop token].freeze
18
+ DEFAULT = "express_stop".freeze
19
+
20
+ module_function
21
+
22
+ def resolve(override: nil, config: Config.load)
23
+ mode = Setting.resolve(override: override, env: ENV_VAR, config: config, config_key: CONFIG_KEY)
24
+ MODES.include?(mode) ? mode : DEFAULT
25
+ end
26
+ end
27
+ end
28
+ end