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.
@@ -10,6 +10,13 @@ require_relative "decisions"
10
10
  require_relative "confidence_check"
11
11
  require_relative "checkout_handoff"
12
12
  require_relative "notifier"
13
+ require_relative "user_agent"
14
+ require_relative "homepage_fetch"
15
+ require_relative "permissive_authenticator"
16
+ require_relative "handoff_reconciler"
17
+ require_relative "handoff_spend_mode"
18
+ require_relative "webmcp"
19
+ require_relative "webmcp_checkout_mode"
13
20
 
14
21
  module Portage
15
22
  module Cli
@@ -49,13 +56,26 @@ module Portage
49
56
  # rolling cap and velocity limit count. nil (the default) is the
50
57
  # real ~/.portage/transactions.json, the file Dispatcher writes for
51
58
  # own-store purchases.
52
- # rubocop:disable Metrics/ParameterLists -- all keywords; one per flag, plus two injectable collaborators
59
+ # @param max_price [Integer, nil] the most one unit may cost, in minor
60
+ # units (same as Find's). A product priced above it is never picked,
61
+ # even with a --product-id; one with no price to go on still is, as
62
+ # in Find, and the checkout's own total then meets the spend policy.
63
+ # @param webmcp_bridge [#list_tools, #execute_tool, nil] docs/plans/
64
+ # handoff-reconcile.md Phase 4 — a `portage-ucp-webmcp` outbound
65
+ # Bridge (already pointed at a navigated page) an embedding caller
66
+ # already holds. nil (the default, and the only option from the
67
+ # `portage buy` CLI, which has no browser of its own) skips WebMCP
68
+ # entirely — zero behavior change from before this parameter
69
+ # existed. Given one, attempted after native-UCP discovery finds
70
+ # nothing at this URL and before a platform-adapter fallback (see
71
+ # #webmcp_flow).
72
+ # rubocop:disable Metrics/ParameterLists -- all keywords; one per flag, plus injectable collaborators
53
73
  def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil,
54
- auto_open: nil, notify_webhook: nil, confidence_check: nil, transaction_log: nil)
74
+ auto_open: nil, notify_webhook: nil, confidence_check: nil, transaction_log: nil,
75
+ max_price: nil, webmcp_bridge: nil)
55
76
  # rubocop:enable Metrics/ParameterLists
56
77
  raw = url.to_s.strip
57
- raw = "https://#{raw}" unless raw =~ %r{\Ahttps?://}i
58
- @uri = URI.parse(raw)
78
+ @uri = URI.parse(raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}")
59
79
  @query = query
60
80
  @qty = qty
61
81
  @payment_token = payment_token
@@ -66,6 +86,8 @@ module Portage
66
86
  @notify_webhook = notify_webhook
67
87
  @confidence_check = confidence_check
68
88
  @transaction_log = transaction_log
89
+ @max_price = max_price
90
+ @webmcp_bridge = webmcp_bridge
69
91
  @decisions = {}
70
92
  end
71
93
 
@@ -84,6 +106,9 @@ module Portage
84
106
  return native_flow(session) if session
85
107
  end
86
108
 
109
+ webmcp_result = webmcp_flow
110
+ return webmcp_result if webmcp_result
111
+
87
112
  platform = Portage::Ucp::Resolver.detect_platform(body, headers)
88
113
  adapter_flow(platform) || dead_end
89
114
  end
@@ -93,7 +118,7 @@ module Portage
93
118
  # --- Step 1: native UCP manifest ---
94
119
 
95
120
  def discover(url)
96
- Portage::Ucp::Client.discover(url.to_s)
121
+ Portage::Ucp::Client.discover(url.to_s, headers: UserAgent.headers)
97
122
  rescue Portage::Ucp::Client::ManifestShapeError => e
98
123
  # The store *is* running UCP — this client just couldn't parse its
99
124
  # manifest. Distinct from a genuine 404/unreachable host below:
@@ -200,6 +225,64 @@ module Portage
200
225
  match && URI.join(@uri, match[1])
201
226
  end
202
227
 
228
+ # --- Step 1b: WebMCP outbound (docs/plans/handoff-reconcile.md Phase 4) ---
229
+ #
230
+ # Opt-in only: nil unless a caller passed `webmcp_bridge:` (see
231
+ # #initialize), so `portage buy` from the shell — with no browser of
232
+ # its own — sees no behavior change here at all. Attempted after
233
+ # native-UCP discovery finds nothing at this URL and before falling
234
+ # back to a platform adapter, since a WebMCP page, like native UCP,
235
+ # works without this process holding the store's own credentials.
236
+ #
237
+ # `express_stop` (the only implemented mode) builds the cart and
238
+ # checkout, then always hands off rather than attempting
239
+ # #complete_checkout — which a WebMCP page doesn't expose by default
240
+ # in the first place (ToolCatalog leaves it out: it needs a
241
+ # server-side Confirmer swap this gem doesn't have a seam for yet).
242
+ # That hand-off feeds Phases 1-3 completely unchanged: reason
243
+ # `express_stop` reserves a pending record, notifies, and later
244
+ # reconciles exactly like any other hand-off.
245
+ def webmcp_flow
246
+ return nil unless @webmcp_bridge
247
+ return webmcp_not_installed_report unless Portage::Cli::Webmcp.available?
248
+
249
+ session = Portage::Ucp::WebMcp.connect(bridge: @webmcp_bridge)
250
+ return nil unless session.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
251
+
252
+ return webmcp_token_unsupported_report if webmcp_checkout_mode == "token"
253
+
254
+ full_buy(session, source: "webmcp", force_handoff: true)
255
+ rescue Portage::Ucp::WebMcp::BridgeError, Portage::Ucp::WebMcp::ToolNotFoundError,
256
+ Portage::Ucp::Client::ServerError => e
257
+ build_report(source: "webmcp", outcome: "webmcp_error", browse: false, checkout: false,
258
+ message: "WebMCP checkout failed: #{e.message}")
259
+ end
260
+
261
+ def webmcp_checkout_mode
262
+ Portage::Cli::WebmcpCheckoutMode.resolve
263
+ end
264
+
265
+ def webmcp_not_installed_report
266
+ build_report(source: "webmcp", outcome: "webmcp_not_installed", browse: false, checkout: false,
267
+ message: "A WebMCP bridge was given but portage-ucp-webmcp isn't installed — " \
268
+ "`gem install portage-ucp-webmcp`.")
269
+ end
270
+
271
+ # `token` mode isn't implemented: it needs `Mcp::Server.build` to swap
272
+ # in a payment-token Confirmer for a WebMCP session (see
273
+ # portage-ucp-webmcp's README, "complete_checkout"), plus PayPal/Stripe
274
+ # agent-token enrollment in `portage payment enroll` — neither exists
275
+ # yet (docs/plans/handoff-reconcile.md Phase 4 is explicitly a sketch
276
+ # pending that design). Fails loudly here rather than silently
277
+ # behaving like `express_stop`, so a caller who configured `token`
278
+ # notices instead of getting a hand-off they didn't ask for.
279
+ def webmcp_token_unsupported_report
280
+ build_report(source: "webmcp", outcome: "webmcp_token_unsupported", browse: false, checkout: false,
281
+ message: "webmcp_checkout_mode=token isn't supported yet — see " \
282
+ "docs/plans/handoff-reconcile.md Phase 4. Use express_stop (the default), or " \
283
+ "complete the checkout via the page's own payment button.")
284
+ end
285
+
203
286
  # --- Step 2/3: platform detection + adapter fallback ---
204
287
 
205
288
  def adapter_flow(platform)
@@ -296,7 +379,11 @@ module Portage
296
379
  # remote store, so shipping-address/rate selection stays loopback-only
297
380
  # for now rather than guessing at a wire shape no real UCP server has
298
381
  # confirmed (see docs/design-log.md).
299
- def full_buy(session, source:, fulfillment_adapter: nil)
382
+ # @param force_handoff [Boolean] docs/plans/handoff-reconcile.md Phase
383
+ # 4's `express_stop` — skips escalation/confirmation/completion
384
+ # entirely and always hands the built checkout off, once past the
385
+ # `--dry-run` check. Only #webmcp_flow ever sets this.
386
+ def full_buy(session, source:, fulfillment_adapter: nil, force_handoff: false)
300
387
  products = safe_search(session)
301
388
  product = select_product(products)
302
389
  unless product
@@ -310,7 +397,7 @@ module Portage
310
397
  checkout = select_cheapest_shipping(session, checkout) if fulfillment_adapter
311
398
 
312
399
  warnings = reconcile_checkout(product, checkout)
313
- finish_checkout(session, source, products, checkout, warnings)
400
+ finish_checkout(session, source, products, checkout, warnings, force_handoff: force_handoff)
314
401
  end
315
402
 
316
403
  # A store can silently drop the requested line, change its quantity, or
@@ -406,9 +493,10 @@ module Portage
406
493
  end
407
494
 
408
495
  def no_match_message
409
- return "No product matched \"#{@query}\"." unless @product_id
496
+ budget = @max_price ? " at or under #{@max_price} minor units" : ""
497
+ return "No product matched \"#{@query}\"#{budget}." unless @product_id
410
498
 
411
- "Product #{@product_id} isn't in this store's results for \"#{@query}\"."
499
+ "Product #{@product_id} isn't in this store's results for \"#{@query}\"#{budget}."
412
500
  end
413
501
 
414
502
  # With a --product-id, that exact product or nothing: falling back to the
@@ -420,12 +508,28 @@ module Portage
420
508
  # when an in-stock match sits right below it (confirmed live
421
509
  # 2026-09-23 on allbirds.com and billabong.com). Falls back to the top
422
510
  # hit when nothing reports stock, so the store still gets to answer.
511
+ #
512
+ # Either way, only among products within --max-price: before it
513
+ # reached here, `buy <url> --max-price` checked out whatever the store
514
+ # ranked first (confirmed live 2026-09-24: a $679.95 board on
515
+ # burton.com under --max-price 600).
423
516
  def select_product(products)
517
+ products = products.select { |product| within_max_price?(product) }
424
518
  return products.find { |product| available?(product) } || products.first unless @product_id
425
519
 
426
520
  products.find { |product| product_id_of(product) == @product_id }
427
521
  end
428
522
 
523
+ # Priced the way #reconcile_checkout expects the store to charge: the
524
+ # variant #line_item_id_of would check out, else the product's lowest
525
+ # price.
526
+ def within_max_price?(product)
527
+ return true unless @max_price
528
+
529
+ price = expected_unit_price(product, line_item_id_of(product))
530
+ price.nil? || price <= @max_price
531
+ end
532
+
429
533
  # A product with no variant availability to go on counts as buyable —
430
534
  # only an explicit `available: false` on every variant rules it out.
431
535
  def available?(product)
@@ -462,10 +566,11 @@ module Portage
462
566
  product_id_of(product)
463
567
  end
464
568
 
465
- def finish_checkout(session, source, products, checkout, warnings = [])
569
+ def finish_checkout(session, source, products, checkout, warnings = [], force_handoff: false)
466
570
  escalation = decide_escalation(checkout, warnings)
467
571
  return escalated_report(source, products, checkout, warnings, escalation) if escalation[:escalate]
468
572
  return dry_run_report(source, products, checkout, warnings) if @dry_run
573
+ return webmcp_handoff_report(source, products, checkout, warnings) if force_handoff
469
574
  return confirmation_needed_report(source, products, checkout, warnings) unless confirmed?
470
575
 
471
576
  complete(session, source, products, checkout, warnings)
@@ -670,10 +775,23 @@ module Portage
670
775
  # relay and an agent loop branch on the same value.
671
776
  def handoff_report(source, products, checkout, warnings, outcome:, message:)
672
777
  handoff = hand_off(checkout, reason: outcome, source: source, message: message, warnings: warnings)
673
- checkout_report(source, products, checkout, outcome: outcome, warnings: warnings, message: message,
778
+ checkout_report(source, products, checkout, outcome: outcome, message: message,
779
+ warnings: warnings + Array(@pending_handoff_warning),
674
780
  checkout_url: checkout_url_of(checkout), handoff: handoff)
675
781
  end
676
782
 
783
+ # docs/plans/handoff-reconcile.md Phase 4 — #webmcp_flow's
784
+ # `express_stop` mode. Reuses #handoff_report as-is: reason
785
+ # `express_stop` reserves a pending shopper record and fires
786
+ # auto-open/notify exactly like any other hand-off (Phases 1-3 apply
787
+ # unchanged), even though it got here because a mode setting chose to
788
+ # stop, not because anything was denied or escalated.
789
+ def webmcp_handoff_report(source, products, checkout, warnings)
790
+ message = "Cart and checkout are built — finish payment with the store's own express-pay button " \
791
+ "on the page."
792
+ handoff_report(source, products, checkout, warnings, outcome: "express_stop", message: message)
793
+ end
794
+
677
795
  # Never fires on --dry-run (a dry run creates a real checkout but never
678
796
  # attempts completion — auto-opening/notifying over a preview run would
679
797
  # be actively wrong), and never fires without a checkout_url to hand
@@ -685,15 +803,67 @@ module Portage
685
803
  # The webhook body carries the report's own `message`, plus the store
686
804
  # and the shopper's query, so a Slack/Zapier relay can post it as-is
687
805
  # without a lookup back into this process.
806
+ #
807
+ # Also where docs/plans/handoff-reconcile.md Phase 1 records a pending
808
+ # `settled_by: "shopper"` TransactionLog row (best-effort — a failed
809
+ # write surfaces as a warning, never blocks the hand-off itself), and
810
+ # where Phase 2's `precheck` spend mode suppresses auto-open when this
811
+ # checkout's total would already exceed the buyer's own spend cap.
688
812
  def hand_off(checkout, reason:, source:, message:, warnings:)
689
813
  url = checkout_url_of(checkout)
690
814
  return nil if @dry_run || url.nil?
691
815
 
692
- opened = CheckoutHandoff.new(auto_open: @auto_open).call(url)
693
- error = notifier.call(event: "checkout_handoff", reason: reason, message: message, store: @uri.to_s,
694
- query: @query, checkout_url: url, checkout_id: checkout["id"], source: source,
695
- totals: checkout["totals"], warnings: warnings)
696
- { url: url, opened: opened, notified: notifier.enabled? && error.nil?, notify_error: error }
816
+ over_cap = precheck_mode? && over_spend_cap?(checkout)
817
+ record_pending_handoff(checkout, reason: reason)
818
+
819
+ opened = over_cap ? false : CheckoutHandoff.new(auto_open: @auto_open).call(url)
820
+ error = notifier.call(handoff_notify_payload(checkout, url, reason: reason, source: source, message: message,
821
+ warnings: warnings, over_cap: over_cap))
822
+ result = { url: url, opened: opened, notified: notifier.enabled? && error.nil?, notify_error: error }
823
+ over_cap ? result.merge(over_cap: true) : result
824
+ end
825
+
826
+ def handoff_notify_payload(checkout, url, reason:, source:, message:, warnings:, over_cap:)
827
+ payload = { event: "checkout_handoff", reason: reason, message: message, store: @uri.to_s,
828
+ query: @query, checkout_url: url, checkout_id: checkout["id"], source: source,
829
+ totals: checkout["totals"], warnings: warnings }
830
+ over_cap ? payload.merge(over_cap: true) : payload
831
+ end
832
+
833
+ # Never raises: a record that can't be written is a warning on the
834
+ # report (surfaced via @pending_handoff_warning, see #handoff_report),
835
+ # not a reason to fail the hand-off itself — same posture as
836
+ # #settle_transaction's own @unrecorded_warning.
837
+ def record_pending_handoff(checkout, reason:)
838
+ transaction_log.reserve(idempotency_key: handoff_transaction_key(checkout), checkout_id: checkout["id"],
839
+ shop: @uri.host, payment_token_ref: token_ref, amount: checkout_total(checkout),
840
+ currency: checkout["currency"], settled_by: "shopper", handoff_reason: reason,
841
+ store_url: @uri.to_s, expires_at: checkout["expires_at"])
842
+ rescue StandardError => e
843
+ @pending_handoff_warning = "Couldn't save this hand-off for later reconcile (#{e.message}) — " \
844
+ "run `portage orders reconcile --checkout #{checkout['id']}` once fixed, " \
845
+ "or it'll never be picked up automatically."
846
+ end
847
+
848
+ def handoff_transaction_key(checkout)
849
+ "portage-buy:#{@uri.host}:#{checkout['id']}"
850
+ end
851
+
852
+ def precheck_mode?
853
+ Portage::Cli::HandoffSpendMode.resolve == "precheck"
854
+ end
855
+
856
+ # Same PolicyGuard the buyer's own spend cap already goes through
857
+ # (#decide_policy) — reused here so a hand-off that never reached
858
+ # #decide_policy at all (escalation, permission_denied, no_payment_token,
859
+ # checkout_mismatch) still gets the buyer a heads-up that finishing
860
+ # this checkout by hand would blow the cap, before they do.
861
+ def over_spend_cap?(checkout)
862
+ Portage::Ucp::PolicyGuard.check!(amount: checkout_total(checkout), currency: checkout["currency"],
863
+ merchant: @uri.host, token_ref: token_ref, transaction_log: transaction_log)
864
+ false
865
+ rescue Portage::Ucp::PolicyViolationError
866
+ true
697
867
  end
698
868
 
699
869
  def notifier
@@ -796,23 +966,7 @@ module Portage
796
966
  # catalog-only-native adapter-checkout-fallback path) ---
797
967
 
798
968
  def fetch_homepage(uri, limit = REDIRECT_LIMIT)
799
- return [nil, {}] if limit.zero?
800
-
801
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
802
- open_timeout: 5, read_timeout: 5) do |http|
803
- http.get(uri.request_uri, { "User-Agent" => "portage-buy" })
804
- end
805
-
806
- case response
807
- when Net::HTTPRedirection
808
- fetch_homepage(URI.join(uri, response["location"]), limit - 1)
809
- when Net::HTTPSuccess
810
- [response.body, response.to_hash]
811
- else
812
- [nil, {}]
813
- end
814
- rescue StandardError
815
- [nil, {}]
969
+ HomepageFetch.call(uri, limit: limit)
816
970
  end
817
971
 
818
972
  def dead_end
@@ -823,14 +977,6 @@ module Portage
823
977
  def build_report(**fields)
824
978
  { url: @uri.to_s, checkout_url: nil, products: [], warnings: [] }.merge(fields)
825
979
  end
826
-
827
- # Loopback buy against your own store needs *some* authenticator (§9
828
- # rejects anonymous mutation by default) — since this process already
829
- # has this platform's own credentials (that's the gate to even reach
830
- # here), authenticating this local CLI session is reasonable.
831
- class PermissiveAuthenticator < Portage::Ucp::Authenticator
832
- def call(_server_context) = :local_cli
833
- end
834
980
  end
835
981
  end
836
982
  end
@@ -1,5 +1,9 @@
1
1
  require "portage/ucp"
2
+ require "uri"
2
3
  require_relative "confidence_check"
4
+ require_relative "user_agent"
5
+ require_relative "proxy_settings"
6
+ require_relative "shipping_profile"
3
7
 
4
8
  module Portage
5
9
  module Cli
@@ -13,19 +17,33 @@ module Portage
13
17
  # process, so a host app passes `--require` to load its own initializer
14
18
  # first (Rails: `--require ./config/environment`).
15
19
  class Doctor
16
- Finding = Struct.new(:check, :message, keyword_init: true)
20
+ # `level` is "warning" (something to fix; any one makes doctor exit 1)
21
+ # or "info" (a report, e.g. how portage was installed). `details` is
22
+ # structured data for `--json`; `to_h` drops it when there is none.
23
+ Finding = Struct.new(:check, :message, :level, :details, keyword_init: true) do
24
+ def initialize(level: "warning", **) = super
25
+ def warning? = level == "warning"
26
+ def to_h = super.compact
27
+ end
17
28
 
18
- def initialize(adapter_class: nil)
29
+ def initialize(adapter_class: nil, proxy_settings: ProxySettings.new, install_doctor: InstallDoctor.new)
19
30
  @adapter_class = adapter_class
31
+ @proxy_settings = proxy_settings
32
+ @install_doctor = install_doctor
20
33
  end
21
34
 
22
35
  def call
23
36
  [
37
+ *@install_doctor.findings,
24
38
  authenticator_finding,
25
39
  rate_limiter_finding,
26
40
  signing_keys_finding,
27
41
  payment_handlers_finding,
28
42
  decision_backend_finding,
43
+ user_agent_finding,
44
+ shipping_finding,
45
+ proxy_finding,
46
+ *proxy_doctor_findings,
29
47
  *capability_findings
30
48
  ].compact
31
49
  end
@@ -71,6 +89,110 @@ module Portage
71
89
  Finding.new(check: "decision_backend", message: e.message)
72
90
  end
73
91
 
92
+ # PORTAGE_USER_AGENT / config.json's "user_agent" (UserAgent) is sent
93
+ # as-is on every outbound request. Net::HTTP raises ArgumentError on a
94
+ # header value containing CR/LF rather than send it, so a bad override
95
+ # would otherwise surface for the first time mid-checkout instead of
96
+ # here.
97
+ def user_agent_finding
98
+ value = Portage::Cli::UserAgent.value
99
+ return unless value =~ /[\r\n]/
100
+
101
+ Finding.new(check: "user_agent",
102
+ message: "Configured User-Agent (PORTAGE_USER_AGENT or user_agent in " \
103
+ "~/.portage/config.json) contains a newline — every outbound request will " \
104
+ "raise instead of sending.")
105
+ end
106
+
107
+ # PORTAGE_SHIP_* (ShippingProfile, BuyerContext) has two jobs, and both
108
+ # fail quietly: with no complete address an own-store checkout goes
109
+ # out with no shipping destination, and with no PORTAGE_SHIP_COUNTRY a
110
+ # native UCP store gets no buyer context, so a live Shopify store
111
+ # builds a cart in no market and reports in-stock items as
112
+ # `merchandise_out_of_stock` (docs/ucp-tool-gating-investigation.md).
113
+ def shipping_finding
114
+ missing = ShippingProfile::REQUIRED.map { |key| ShippingProfile::ENV_VARS.fetch(key) }
115
+ .select { |var| ENV[var].to_s.empty? }
116
+ return if missing.empty?
117
+
118
+ Finding.new(check: "shipping", message: "#{shipping_gap(missing)} Export them in your shell; " \
119
+ ".env.example lists every PORTAGE_SHIP_* variable.")
120
+ end
121
+
122
+ def shipping_gap(missing)
123
+ country = ShippingProfile::ENV_VARS.fetch(:address_country)
124
+ gap = if missing.length == ShippingProfile::REQUIRED.length
125
+ "No shipping address set (#{missing.join(', ')})."
126
+ else
127
+ "Shipping address incomplete: missing #{missing.join(', ')}, and a partial address is " \
128
+ "treated as none."
129
+ end
130
+ gap += " `portage buy` sends no shipping destination to your own store's checkout"
131
+ return "#{gap}." unless missing.include?(country)
132
+
133
+ "#{gap}, and without #{country} a native UCP store (e.g. Shopify) gets no market to price in " \
134
+ "and can report in-stock items as out of stock."
135
+ end
136
+
137
+ # Phase 0 of docs/plans/proxy-support.md: every raw Net::HTTP.start call
138
+ # site in portage-cli/portage-ucp/the adapter gems resolves its proxy
139
+ # from Ruby stdlib's own `:ENV` default, which — confirmed against a
140
+ # real local proxy — only ever reads `http_proxy`/`HTTP_PROXY`, for
141
+ # *both* http and https targets, and never `https_proxy`/`HTTPS_PROXY`.
142
+ # That's surprising enough (and the failure silent enough — requests
143
+ # just go direct) that it's worth a doctor check on both sides: report
144
+ # the effective proxy when one is actually active, and flag the classic
145
+ # footgun of setting only HTTPS_PROXY and expecting it to do anything
146
+ # here.
147
+ def proxy_finding
148
+ http_proxy = ENV["http_proxy"] || ENV.fetch("HTTP_PROXY", nil)
149
+ return https_only_proxy_finding if !http_proxy && https_env_proxy_set?
150
+
151
+ return unless http_proxy
152
+
153
+ Finding.new(check: "proxy",
154
+ message: "Outbound requests proxy through #{redact_proxy_url(http_proxy)} " \
155
+ "(from #{ENV['http_proxy'] ? 'http_proxy' : 'HTTP_PROXY'}, used for both http:// " \
156
+ "and https:// targets)#{no_proxy_suffix}.")
157
+ end
158
+
159
+ def https_only_proxy_finding
160
+ Finding.new(check: "proxy",
161
+ message: "HTTPS_PROXY/https_proxy is set but http_proxy/HTTP_PROXY is not — Ruby's " \
162
+ "Net::HTTP only ever reads http_proxy for its :ENV proxy mode (for both http:// " \
163
+ "and https:// targets), so every portage-cli/portage-ucp/adapter call site is " \
164
+ "proxying nothing right now. Set http_proxy (it covers https:// targets too) if " \
165
+ "that traffic should go through a proxy. (portage-ucp-client's own UCP/MCP tool " \
166
+ "calls are the one exception — they're Faraday-based and do read https_proxy.)")
167
+ end
168
+
169
+ def https_env_proxy_set?
170
+ !(ENV["https_proxy"].to_s.empty? && ENV["HTTPS_PROXY"].to_s.empty?)
171
+ end
172
+
173
+ def no_proxy_suffix
174
+ no_proxy = ENV["no_proxy"] || ENV.fetch("NO_PROXY", nil)
175
+ no_proxy ? "; no_proxy=#{no_proxy}" : ""
176
+ end
177
+
178
+ # `http://***@host:port` — never the real credentials (see the plan's
179
+ # "Credentials stay safe" constraint).
180
+ def redact_proxy_url(url)
181
+ uri = URI.parse(url.include?("://") ? url : "http://#{url}")
182
+ userinfo = uri.userinfo ? "***@" : ""
183
+ port = uri.port ? ":#{uri.port}" : ""
184
+ "#{uri.scheme}://#{userinfo}#{uri.host}#{port}"
185
+ rescue URI::InvalidURIError
186
+ "(unparseable proxy URL)"
187
+ end
188
+
189
+ # Phase 2's own checks against whatever ProxySettings resolved
190
+ # (flags/env/config.json), kept separate from proxy_finding above —
191
+ # see ProxyDoctor's own comment for why the two don't merge.
192
+ def proxy_doctor_findings
193
+ ProxyDoctor.new(proxy_settings: @proxy_settings).findings
194
+ end
195
+
74
196
  def payment_handlers_finding
75
197
  return unless Array(config.payment_handlers).empty?
76
198
 
@@ -106,3 +228,9 @@ module Portage
106
228
  end
107
229
  end
108
230
  end
231
+
232
+ # Loaded after Doctor closes — ProxyDoctor calls Doctor::Finding.new, so
233
+ # Doctor has to exist first (this file requires proxy_settings, not
234
+ # proxy_doctor, at the top for exactly this reason).
235
+ require_relative "proxy_doctor"
236
+ require_relative "install_doctor"
@@ -5,6 +5,7 @@ require "portage/ucp/client"
5
5
  require_relative "search_backends"
6
6
  require_relative "probe_cache"
7
7
  require_relative "decisions"
8
+ require_relative "user_agent"
8
9
 
9
10
  module Portage
10
11
  module Cli
@@ -128,7 +129,7 @@ module Portage
128
129
  end
129
130
 
130
131
  def discover(origin)
131
- Portage::Ucp::Client.discover(origin)
132
+ Portage::Ucp::Client.discover(origin, headers: UserAgent.headers)
132
133
  rescue StandardError
133
134
  nil
134
135
  end
@@ -0,0 +1,50 @@
1
+ module Portage
2
+ module Cli
3
+ class HandoffReconciler
4
+ # Adapts a wire hash (from #get_checkout/#get_order) to what
5
+ # OrderLedger#record/PurchaseJournal#record_checkout duck-type against
6
+ # (Portage::Ucp::Order/Checkout value objects) — see
7
+ # docs/plans/handoff-reconcile.md, "The journal needs a
8
+ # hash-to-value-object step". Deliberately not a second, parallel
9
+ # entry shape: both still go through PurchaseJournal#record_checkout /
10
+ # OrderLedger#record's own logic (`to_wire_h`) — these just wrap the
11
+ # already-wire-shaped hash to answer the handful of methods each one
12
+ # calls.
13
+ WireOrder = Struct.new(:wire) do
14
+ def id = wire["id"]
15
+ def to_wire_h = wire
16
+ end
17
+
18
+ WireCheckout = Struct.new(:wire) do
19
+ def currency = wire["currency"]
20
+ def order = wire["order"] && WireOrderRef.new(wire["order"])
21
+
22
+ def line_items
23
+ Array(wire["line_items"]).map { |li| WireLineItem.new(li) }
24
+ end
25
+ end
26
+
27
+ WireOrderRef = Struct.new(:wire) do
28
+ def id = wire["id"]
29
+ end
30
+
31
+ WireLineItem = Struct.new(:wire) do
32
+ def item = WireItem.new(wire["item"] || {})
33
+ def quantity = wire["quantity"]
34
+
35
+ def totals
36
+ Array(wire["totals"]).map { |t| WireTotal.new(t) }
37
+ end
38
+ end
39
+
40
+ WireItem = Struct.new(:wire) do
41
+ def id = wire["id"]
42
+ end
43
+
44
+ WireTotal = Struct.new(:wire) do
45
+ def type = wire["type"]
46
+ def amount = wire["amount"]
47
+ end
48
+ end
49
+ end
50
+ end