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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +216 -0
- data/README.md +263 -7
- data/lib/portage/cli/buy.rb +529 -104
- data/lib/portage/cli/checkout_handoff.rb +6 -18
- data/lib/portage/cli/confidence_check.rb +127 -0
- data/lib/portage/cli/decisions.rb +72 -0
- data/lib/portage/cli/doctor.rb +104 -1
- data/lib/portage/cli/find.rb +10 -9
- 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/history.rb +23 -6
- data/lib/portage/cli/homepage_fetch.rb +41 -0
- data/lib/portage/cli/macos_notifier.rb +37 -0
- data/lib/portage/cli/notifier.rb +26 -27
- 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/setting.rb +44 -0
- 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 +367 -27
- metadata +46 -5
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "uri"
|
|
2
2
|
require_relative "config"
|
|
3
|
+
require_relative "setting"
|
|
3
4
|
|
|
4
5
|
module Portage
|
|
5
6
|
module Cli
|
|
@@ -8,10 +9,10 @@ module Portage
|
|
|
8
9
|
# (escalation, permission denied, no payment token) hands off a link
|
|
9
10
|
# rather than completing the purchase itself.
|
|
10
11
|
#
|
|
11
|
-
# Default off. Precedence for the toggle (open decision #1, resolved
|
|
12
|
-
# a per-invocation `auto_open:` override (portage buy
|
|
13
|
-
# --no-auto-open) beats PORTAGE_AUTO_OPEN_CHECKOUT, which
|
|
14
|
-
# ~/.portage/config.json's "auto_open_checkout" (Config).
|
|
12
|
+
# Default off. Precedence for the toggle (open decision #1, resolved,
|
|
13
|
+
# see Setting): a per-invocation `auto_open:` override (portage buy
|
|
14
|
+
# --auto-open / --no-auto-open) beats PORTAGE_AUTO_OPEN_CHECKOUT, which
|
|
15
|
+
# beats ~/.portage/config.json's "auto_open_checkout" (Config).
|
|
15
16
|
#
|
|
16
17
|
# No new gem for the actual open — every other shell-out in this repo
|
|
17
18
|
# (PaymentMethods::KeychainBackend, SecretServiceBackend) hand-rolls
|
|
@@ -22,7 +23,6 @@ module Portage
|
|
|
22
23
|
class CheckoutHandoff
|
|
23
24
|
ENV_VAR = "PORTAGE_AUTO_OPEN_CHECKOUT".freeze
|
|
24
25
|
CONFIG_KEY = "auto_open_checkout".freeze
|
|
25
|
-
TRUE_VALUES = %w[1 true yes].freeze
|
|
26
26
|
|
|
27
27
|
def initialize(auto_open: nil, config: Config.load)
|
|
28
28
|
@override = auto_open
|
|
@@ -30,12 +30,7 @@ module Portage
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def auto_open?
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
env = env_override
|
|
36
|
-
return env unless env.nil?
|
|
37
|
-
|
|
38
|
-
!!@config.get(CONFIG_KEY)
|
|
33
|
+
Setting.flag?(override: @override, env: ENV_VAR, config: @config, config_key: CONFIG_KEY)
|
|
39
34
|
end
|
|
40
35
|
|
|
41
36
|
# @return [Boolean] whether the browser was actually opened.
|
|
@@ -47,13 +42,6 @@ module Portage
|
|
|
47
42
|
|
|
48
43
|
private
|
|
49
44
|
|
|
50
|
-
def env_override
|
|
51
|
-
raw = ENV.fetch(ENV_VAR, nil)
|
|
52
|
-
return nil if raw.nil?
|
|
53
|
-
|
|
54
|
-
TRUE_VALUES.include?(raw.downcase)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
45
|
def https?(url)
|
|
58
46
|
URI.parse(url).scheme == "https"
|
|
59
47
|
rescue URI::InvalidURIError
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "setting"
|
|
3
|
+
require_relative "decisions"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Cli
|
|
7
|
+
# The confidence gate (docs/plans/system-one-decision-layer.md
|
|
8
|
+
# § Responsibilities 3) as `portage buy` uses it: one yes/no question put
|
|
9
|
+
# to a Decision::ModelBackends backend right before an unattended
|
|
10
|
+
# (`--yes`) completion — "is this checkout what the shopper asked for,
|
|
11
|
+
# and safe to complete without a person looking at it?"
|
|
12
|
+
#
|
|
13
|
+
# Default off. Nothing asks a model anything unless a backend is named,
|
|
14
|
+
# via `--decision-backend NAME` or PORTAGE_DECISION_BACKEND (`jev` or
|
|
15
|
+
# `laya`, Decision::ModelBackends::REGISTRY's keys). The threshold comes
|
|
16
|
+
# from `--min-confidence N` or PORTAGE_MIN_CONFIDENCE, else
|
|
17
|
+
# DEFAULT_THRESHOLD.
|
|
18
|
+
#
|
|
19
|
+
# This answers the plan's open question about how the gate relates to
|
|
20
|
+
# `Confirmer`: it runs in front of the completion, after `--yes` and the
|
|
21
|
+
# local PolicyCheck, and never in place of either. A low score holds the
|
|
22
|
+
# purchase and hands the checkout to the shopper. It never
|
|
23
|
+
# auto-approves anything `--yes` wouldn't already have allowed.
|
|
24
|
+
#
|
|
25
|
+
# Fails closed. An unknown backend name, a backend that isn't configured
|
|
26
|
+
# (no JEV_API_KEY, no Laya bridge), or a backend call that fails all hold
|
|
27
|
+
# the purchase, the same as a low score does. So does naming a backend
|
|
28
|
+
# without portage-ucp-decision installed: the backends live in that
|
|
29
|
+
# optional gem. Once a caller has asked for a confidence check, the
|
|
30
|
+
# check never passing is not "no opinion".
|
|
31
|
+
class ConfidenceCheck
|
|
32
|
+
BACKEND_ENV = "PORTAGE_DECISION_BACKEND".freeze
|
|
33
|
+
THRESHOLD_ENV = "PORTAGE_MIN_CONFIDENCE".freeze
|
|
34
|
+
DEFAULT_THRESHOLD = 0.8
|
|
35
|
+
QUESTION = "safe_to_complete".freeze
|
|
36
|
+
# Phrased so "yes" means "proceed": a noul answer's value is the
|
|
37
|
+
# probability of yes, and that is what ConfidenceGate thresholds.
|
|
38
|
+
INSTRUCTIONS = "The state is a checkout an agent built on a shopper's behalf: the shopper's search " \
|
|
39
|
+
"query, the merchant, the requested quantity, the checkout's line items and totals, and " \
|
|
40
|
+
"any warnings about where the checkout differs from the request. Answer yes only if the " \
|
|
41
|
+
"checkout clearly matches what the shopper asked for and is safe to complete without a " \
|
|
42
|
+
"person reviewing it first.".freeze
|
|
43
|
+
|
|
44
|
+
# @param backend [String, nil] a ModelBackends::REGISTRY key; nil
|
|
45
|
+
# defers to PORTAGE_DECISION_BACKEND.
|
|
46
|
+
# @param threshold [Float, nil] 0.0..1.0; nil defers to
|
|
47
|
+
# PORTAGE_MIN_CONFIDENCE, then DEFAULT_THRESHOLD.
|
|
48
|
+
# @param resolver [#call, nil] builds a backend from its name —
|
|
49
|
+
# injectable so specs never reach a real model. nil means
|
|
50
|
+
# ModelBackends.resolve, looked up only once a backend is needed.
|
|
51
|
+
# @raise [ArgumentError] for a threshold outside 0.0..1.0. An explicit
|
|
52
|
+
# `threshold:` is always checked, so a mistyped `--min-confidence`
|
|
53
|
+
# fails loudly. PORTAGE_MIN_CONFIDENCE is read, and so checked,
|
|
54
|
+
# only once a backend is enabled: a stale value in a shell that
|
|
55
|
+
# never uses the gate mustn't stop every `portage buy`.
|
|
56
|
+
def initialize(backend: nil, threshold: nil, resolver: nil)
|
|
57
|
+
@backend_name = Setting.resolve(override: backend, env: BACKEND_ENV)&.to_s&.strip
|
|
58
|
+
raw_threshold = enabled? ? Setting.resolve(override: threshold, env: THRESHOLD_ENV) : threshold
|
|
59
|
+
@threshold = parse_threshold(raw_threshold || DEFAULT_THRESHOLD)
|
|
60
|
+
@resolver = resolver
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
attr_reader :threshold
|
|
64
|
+
|
|
65
|
+
def enabled? = !@backend_name.nil?
|
|
66
|
+
|
|
67
|
+
# @param state [Hash] JSON-serializable. Never pass it a payment token.
|
|
68
|
+
# @return [Hash, nil] nil when disabled. Otherwise `proceed:`,
|
|
69
|
+
# `reason:`, `confidence:`, `threshold:`, `backend:` and `error:`.
|
|
70
|
+
# `reason` is nil when it proceeds, else why it held:
|
|
71
|
+
# `below_threshold`, `backend_error` (unknown, unconfigured or
|
|
72
|
+
# failed backend) or `not_installed` (no portage-ucp-decision).
|
|
73
|
+
# `error` is the detail behind the last two, nil otherwise.
|
|
74
|
+
def call(state)
|
|
75
|
+
return nil unless enabled?
|
|
76
|
+
return held("not_installed", not_installed_message) unless Decisions.available?
|
|
77
|
+
|
|
78
|
+
verdict = Portage::Ucp::Decision::ConfidenceGate.via_backend(
|
|
79
|
+
backend: resolve_backend, state: JSON.generate(state), question: QUESTION,
|
|
80
|
+
instructions: INSTRUCTIONS, threshold: @threshold
|
|
81
|
+
)
|
|
82
|
+
{ proceed: verdict.proceed, reason: verdict.proceed ? nil : "below_threshold",
|
|
83
|
+
confidence: verdict.confidence, threshold: @threshold, backend: @backend_name, error: nil }
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
# Any failure, not only Decision::Error: this runs after a real
|
|
86
|
+
# checkout exists, so an escaped exception would drop the report,
|
|
87
|
+
# the hand-off and the history entry along with the purchase.
|
|
88
|
+
held("backend_error", e.message)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# What `portage doctor` reports: why the named backend would hold every
|
|
92
|
+
# `--yes` purchase, before one is attempted.
|
|
93
|
+
# @return [String, nil] nil when disabled or ready to answer.
|
|
94
|
+
def configuration_problem
|
|
95
|
+
return nil unless enabled?
|
|
96
|
+
return not_installed_message unless Decisions.available?
|
|
97
|
+
|
|
98
|
+
resolve_backend.configuration_problem
|
|
99
|
+
rescue Portage::Ucp::Decision::Error => e
|
|
100
|
+
e.message
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def resolve_backend
|
|
106
|
+
(@resolver || Portage::Ucp::Decision::ModelBackends.method(:resolve)).call(@backend_name)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def held(reason, error)
|
|
110
|
+
{ proceed: false, reason: reason, confidence: nil, threshold: @threshold, backend: @backend_name,
|
|
111
|
+
error: error }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def not_installed_message
|
|
115
|
+
"portage-ucp-decision is not installed — `gem install portage-ucp-decision` to use the " \
|
|
116
|
+
"#{@backend_name} confidence check."
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def parse_threshold(raw)
|
|
120
|
+
value = Float(raw, exception: false)
|
|
121
|
+
return value if value&.between?(0.0, 1.0)
|
|
122
|
+
|
|
123
|
+
raise ArgumentError, "confidence threshold must be a number between 0.0 and 1.0, got #{raw.inspect}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
# portage-cli's judgment calls (docs/plans/system-one-decision-layer.md).
|
|
6
|
+
# The rules live in portage-ucp core: Support::OfferRanking,
|
|
7
|
+
# Support::Escalation and PolicyGuard. portage-ucp-decision's
|
|
8
|
+
# OfferRanking, EscalationPolicy and PolicyCheck are typed wrappers
|
|
9
|
+
# around those same modules, so `buy` and `find` answer the same way
|
|
10
|
+
# whether or not that gem is installed.
|
|
11
|
+
#
|
|
12
|
+
# Every method returns a plain Hash whose `reason` is a String or nil,
|
|
13
|
+
# so a caller sees the same values in-process as in `--json`.
|
|
14
|
+
#
|
|
15
|
+
# Only the confidence gate needs the optional gem, because the model
|
|
16
|
+
# backends live there (see ConfidenceCheck and .available?).
|
|
17
|
+
module Decisions
|
|
18
|
+
# Loaded the way Resolver.build_adapter's callers treat an optional
|
|
19
|
+
# adapter gem: `require`, then rescue LoadError. It isn't in the
|
|
20
|
+
# gemspec, so `gem install portage-cli` stays light.
|
|
21
|
+
# @return [Boolean] whether portage-ucp-decision could be loaded.
|
|
22
|
+
# Memoized: `require` runs once per process.
|
|
23
|
+
def self.available?
|
|
24
|
+
return @available unless @available.nil?
|
|
25
|
+
|
|
26
|
+
@available = begin
|
|
27
|
+
require "portage/ucp/decision"
|
|
28
|
+
true
|
|
29
|
+
rescue LoadError
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param offers [Array<Hash>] with `:checkout` (buyable) and `:amount`.
|
|
35
|
+
def self.rank(offers)
|
|
36
|
+
Portage::Ucp::Support::OfferRanking.rank(offers) { |offer| [offer[:checkout], offer[:amount]] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param warnings [Array<String>] mismatches that should escalate —
|
|
40
|
+
# pass none when mismatches are only to be reported.
|
|
41
|
+
# @return [Hash] `escalate:`, `reason:`.
|
|
42
|
+
def self.escalation(checkout_status:, warnings: [])
|
|
43
|
+
reason = Portage::Ucp::Support::Escalation.reason(checkout_status: checkout_status, warnings: warnings)
|
|
44
|
+
{ escalate: !reason.nil?, reason: reason&.to_s }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The buyer's spend policy, checked by PolicyGuard.
|
|
48
|
+
#
|
|
49
|
+
# PolicyGuard skips both spend caps when `amount` is nil, which suits
|
|
50
|
+
# its own caller (an adapter that can't price a checkout up front).
|
|
51
|
+
# Here it would let a checkout with no `total` line past a configured
|
|
52
|
+
# cap while reporting `allowed: true`, so a missing total is denied as
|
|
53
|
+
# `total_unknown` whenever a cap exists.
|
|
54
|
+
# @param transaction_log [Portage::Ucp::Support::TransactionLog] what
|
|
55
|
+
# the rolling cap and velocity limit count.
|
|
56
|
+
# @return [Hash] `allowed:`, `reason:`.
|
|
57
|
+
def self.policy(amount:, currency:, merchant:, token_ref:,
|
|
58
|
+
transaction_log: Portage::Ucp::Support::TransactionLog.new)
|
|
59
|
+
policy = Portage::Ucp::Policy.load
|
|
60
|
+
if amount.nil? && (policy.per_transaction_cap || policy.rolling_cap)
|
|
61
|
+
return { allowed: false, reason: "total_unknown" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Portage::Ucp::PolicyGuard.check!(amount: amount, currency: currency, merchant: merchant,
|
|
65
|
+
token_ref: token_ref, policy: policy, transaction_log: transaction_log)
|
|
66
|
+
{ allowed: true, reason: nil }
|
|
67
|
+
rescue Portage::Ucp::PolicyViolationError => e
|
|
68
|
+
{ allowed: false, reason: e.reason.to_s }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/portage/cli/doctor.rb
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
require "portage/ucp"
|
|
2
|
+
require "uri"
|
|
3
|
+
require_relative "confidence_check"
|
|
4
|
+
require_relative "user_agent"
|
|
5
|
+
require_relative "proxy_settings"
|
|
2
6
|
|
|
3
7
|
module Portage
|
|
4
8
|
module Cli
|
|
@@ -14,8 +18,9 @@ module Portage
|
|
|
14
18
|
class Doctor
|
|
15
19
|
Finding = Struct.new(:check, :message, keyword_init: true)
|
|
16
20
|
|
|
17
|
-
def initialize(adapter_class: nil)
|
|
21
|
+
def initialize(adapter_class: nil, proxy_settings: ProxySettings.new)
|
|
18
22
|
@adapter_class = adapter_class
|
|
23
|
+
@proxy_settings = proxy_settings
|
|
19
24
|
end
|
|
20
25
|
|
|
21
26
|
def call
|
|
@@ -24,6 +29,10 @@ module Portage
|
|
|
24
29
|
rate_limiter_finding,
|
|
25
30
|
signing_keys_finding,
|
|
26
31
|
payment_handlers_finding,
|
|
32
|
+
decision_backend_finding,
|
|
33
|
+
user_agent_finding,
|
|
34
|
+
proxy_finding,
|
|
35
|
+
*proxy_doctor_findings,
|
|
27
36
|
*capability_findings
|
|
28
37
|
].compact
|
|
29
38
|
end
|
|
@@ -54,6 +63,95 @@ module Portage
|
|
|
54
63
|
Finding.new(check: "signing_keys", message: "No signing_keys configured — the manifest ships unsigned.")
|
|
55
64
|
end
|
|
56
65
|
|
|
66
|
+
# The confidence gate's backend, checked only once one is selected
|
|
67
|
+
# (PORTAGE_DECISION_BACKEND): the gate is off by default, and a
|
|
68
|
+
# missing key for a backend nobody chose is noise. Once one is
|
|
69
|
+
# selected, anything short of ready holds every `--yes` purchase, so
|
|
70
|
+
# it's flagged here rather than first surfacing mid-checkout. Covers
|
|
71
|
+
# the gem not being installed, an unknown backend name, a missing
|
|
72
|
+
# JEV_API_KEY, a missing Laya bridge, and a bad PORTAGE_MIN_CONFIDENCE.
|
|
73
|
+
def decision_backend_finding
|
|
74
|
+
problem = ConfidenceCheck.new.configuration_problem
|
|
75
|
+
problem && Finding.new(check: "decision_backend",
|
|
76
|
+
message: "#{problem} — until then every `portage buy --yes` is held for the shopper.")
|
|
77
|
+
rescue ArgumentError => e
|
|
78
|
+
Finding.new(check: "decision_backend", message: e.message)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# PORTAGE_USER_AGENT / config.json's "user_agent" (UserAgent) is sent
|
|
82
|
+
# as-is on every outbound request. Net::HTTP raises ArgumentError on a
|
|
83
|
+
# header value containing CR/LF rather than send it, so a bad override
|
|
84
|
+
# would otherwise surface for the first time mid-checkout instead of
|
|
85
|
+
# here.
|
|
86
|
+
def user_agent_finding
|
|
87
|
+
value = Portage::Cli::UserAgent.value
|
|
88
|
+
return unless value =~ /[\r\n]/
|
|
89
|
+
|
|
90
|
+
Finding.new(check: "user_agent",
|
|
91
|
+
message: "Configured User-Agent (PORTAGE_USER_AGENT or user_agent in " \
|
|
92
|
+
"~/.portage/config.json) contains a newline — every outbound request will " \
|
|
93
|
+
"raise instead of sending.")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Phase 0 of docs/plans/proxy-support.md: every raw Net::HTTP.start call
|
|
97
|
+
# site in portage-cli/portage-ucp/the adapter gems resolves its proxy
|
|
98
|
+
# from Ruby stdlib's own `:ENV` default, which — confirmed against a
|
|
99
|
+
# real local proxy — only ever reads `http_proxy`/`HTTP_PROXY`, for
|
|
100
|
+
# *both* http and https targets, and never `https_proxy`/`HTTPS_PROXY`.
|
|
101
|
+
# That's surprising enough (and the failure silent enough — requests
|
|
102
|
+
# just go direct) that it's worth a doctor check on both sides: report
|
|
103
|
+
# the effective proxy when one is actually active, and flag the classic
|
|
104
|
+
# footgun of setting only HTTPS_PROXY and expecting it to do anything
|
|
105
|
+
# here.
|
|
106
|
+
def proxy_finding
|
|
107
|
+
http_proxy = ENV["http_proxy"] || ENV.fetch("HTTP_PROXY", nil)
|
|
108
|
+
return https_only_proxy_finding if !http_proxy && https_env_proxy_set?
|
|
109
|
+
|
|
110
|
+
return unless http_proxy
|
|
111
|
+
|
|
112
|
+
Finding.new(check: "proxy",
|
|
113
|
+
message: "Outbound requests proxy through #{redact_proxy_url(http_proxy)} " \
|
|
114
|
+
"(from #{ENV['http_proxy'] ? 'http_proxy' : 'HTTP_PROXY'}, used for both http:// " \
|
|
115
|
+
"and https:// targets)#{no_proxy_suffix}.")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def https_only_proxy_finding
|
|
119
|
+
Finding.new(check: "proxy",
|
|
120
|
+
message: "HTTPS_PROXY/https_proxy is set but http_proxy/HTTP_PROXY is not — Ruby's " \
|
|
121
|
+
"Net::HTTP only ever reads http_proxy for its :ENV proxy mode (for both http:// " \
|
|
122
|
+
"and https:// targets), so every portage-cli/portage-ucp/adapter call site is " \
|
|
123
|
+
"proxying nothing right now. Set http_proxy (it covers https:// targets too) if " \
|
|
124
|
+
"that traffic should go through a proxy. (portage-ucp-client's own UCP/MCP tool " \
|
|
125
|
+
"calls are the one exception — they're Faraday-based and do read https_proxy.)")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def https_env_proxy_set?
|
|
129
|
+
!(ENV["https_proxy"].to_s.empty? && ENV["HTTPS_PROXY"].to_s.empty?)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def no_proxy_suffix
|
|
133
|
+
no_proxy = ENV["no_proxy"] || ENV.fetch("NO_PROXY", nil)
|
|
134
|
+
no_proxy ? "; no_proxy=#{no_proxy}" : ""
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# `http://***@host:port` — never the real credentials (see the plan's
|
|
138
|
+
# "Credentials stay safe" constraint).
|
|
139
|
+
def redact_proxy_url(url)
|
|
140
|
+
uri = URI.parse(url.include?("://") ? url : "http://#{url}")
|
|
141
|
+
userinfo = uri.userinfo ? "***@" : ""
|
|
142
|
+
port = uri.port ? ":#{uri.port}" : ""
|
|
143
|
+
"#{uri.scheme}://#{userinfo}#{uri.host}#{port}"
|
|
144
|
+
rescue URI::InvalidURIError
|
|
145
|
+
"(unparseable proxy URL)"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Phase 2's own checks against whatever ProxySettings resolved
|
|
149
|
+
# (flags/env/config.json), kept separate from proxy_finding above —
|
|
150
|
+
# see ProxyDoctor's own comment for why the two don't merge.
|
|
151
|
+
def proxy_doctor_findings
|
|
152
|
+
ProxyDoctor.new(proxy_settings: @proxy_settings).findings
|
|
153
|
+
end
|
|
154
|
+
|
|
57
155
|
def payment_handlers_finding
|
|
58
156
|
return unless Array(config.payment_handlers).empty?
|
|
59
157
|
|
|
@@ -89,3 +187,8 @@ module Portage
|
|
|
89
187
|
end
|
|
90
188
|
end
|
|
91
189
|
end
|
|
190
|
+
|
|
191
|
+
# Loaded after Doctor closes — ProxyDoctor calls Doctor::Finding.new, so
|
|
192
|
+
# Doctor has to exist first (this file requires proxy_settings, not
|
|
193
|
+
# proxy_doctor, at the top for exactly this reason).
|
|
194
|
+
require_relative "proxy_doctor"
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -4,6 +4,8 @@ require "portage/ucp/client"
|
|
|
4
4
|
|
|
5
5
|
require_relative "search_backends"
|
|
6
6
|
require_relative "probe_cache"
|
|
7
|
+
require_relative "decisions"
|
|
8
|
+
require_relative "user_agent"
|
|
7
9
|
|
|
8
10
|
module Portage
|
|
9
11
|
module Cli
|
|
@@ -127,7 +129,7 @@ module Portage
|
|
|
127
129
|
end
|
|
128
130
|
|
|
129
131
|
def discover(origin)
|
|
130
|
-
Portage::Ucp::Client.discover(origin)
|
|
132
|
+
Portage::Ucp::Client.discover(origin, headers: UserAgent.headers)
|
|
131
133
|
rescue StandardError
|
|
132
134
|
nil
|
|
133
135
|
end
|
|
@@ -166,14 +168,13 @@ module Portage
|
|
|
166
168
|
amount: amount, currency: currency, url: field(product, "url") }
|
|
167
169
|
end
|
|
168
170
|
|
|
169
|
-
# Buyable first, then cheapest, then unpriced
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
end
|
|
171
|
+
# Buyable first, then cheapest, then unpriced (see Decisions.rank —
|
|
172
|
+
# core's Support::OfferRanking, the rule portage-ucp-decision's
|
|
173
|
+
# OfferRanking wraps), so an agent loop ranking its own candidate list
|
|
174
|
+
# gets the same order this command prints. Sorting on price alone
|
|
175
|
+
# would float a browse-only store above one you can actually check
|
|
176
|
+
# out from, which is the wrong answer to "buy me this".
|
|
177
|
+
def rank(offers) = Decisions.rank(offers)
|
|
177
178
|
|
|
178
179
|
# --- Shapes ---
|
|
179
180
|
|
|
@@ -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
|