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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +142 -0
- data/README.md +266 -17
- data/lib/portage/cli/buy.rb +187 -41
- data/lib/portage/cli/doctor.rb +130 -2
- data/lib/portage/cli/find.rb +2 -1
- data/lib/portage/cli/handoff_reconciler/wire_adapters.rb +50 -0
- data/lib/portage/cli/handoff_reconciler.rb +249 -0
- data/lib/portage/cli/handoff_spend_mode.rb +28 -0
- data/lib/portage/cli/handoff_wait_timeout.rb +49 -0
- data/lib/portage/cli/handoff_waiter.rb +103 -0
- data/lib/portage/cli/homepage_fetch.rb +41 -0
- data/lib/portage/cli/install_doctor.rb +239 -0
- data/lib/portage/cli/macos_notifier.rb +37 -0
- data/lib/portage/cli/notifier.rb +6 -3
- data/lib/portage/cli/payment_methods.rb +6 -4
- data/lib/portage/cli/permissive_authenticator.rb +16 -0
- data/lib/portage/cli/proxy_doctor.rb +149 -0
- data/lib/portage/cli/proxy_settings/password_ref.rb +48 -0
- data/lib/portage/cli/proxy_settings.rb +350 -0
- data/lib/portage/cli/reconcile_notifier.rb +65 -0
- data/lib/portage/cli/reconcile_notify.rb +38 -0
- data/lib/portage/cli/search_backends.rb +6 -4
- data/lib/portage/cli/shipping_profile.rb +7 -2
- data/lib/portage/cli/user_agent.rb +39 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +25 -0
- data/lib/portage/cli/webmcp_checkout_mode.rb +28 -0
- data/lib/portage/cli.rb +249 -15
- metadata +37 -6
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
data/lib/portage/cli/doctor.rb
CHANGED
|
@@ -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
|
-
|
|
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"
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -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
|