portage-cli 0.7.4 → 0.8.0
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 +580 -0
- data/README.md +322 -19
- data/exe/portage +5 -0
- data/exe/portage-console +3 -0
- data/known-stores/categories.yml +1263 -0
- data/lib/portage/cli/agent_profile_url.rb +30 -0
- data/lib/portage/cli/browser_import/categorize.rb +59 -0
- data/lib/portage/cli/browser_import/confirm.rb +35 -0
- data/lib/portage/cli/browser_import/domains.rb +47 -0
- data/lib/portage/cli/browser_import/filter.rb +91 -0
- data/lib/portage/cli/browser_import/importer.rb +248 -0
- data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
- data/lib/portage/cli/browser_import/prober.rb +60 -0
- data/lib/portage/cli/browser_import/profiles.rb +114 -0
- data/lib/portage/cli/browser_import/readers.rb +179 -0
- data/lib/portage/cli/browser_import/saver.rb +62 -0
- data/lib/portage/cli/browser_import/sqlite.rb +68 -0
- data/lib/portage/cli/browser_import.rb +23 -0
- data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
- data/lib/portage/cli/browser_profile/bridge.rb +120 -0
- data/lib/portage/cli/browser_profile/browsers.rb +69 -0
- data/lib/portage/cli/browser_profile/cdp.rb +67 -0
- data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
- data/lib/portage/cli/browser_profile/errors.rb +26 -0
- data/lib/portage/cli/browser_profile/launcher.rb +34 -0
- data/lib/portage/cli/browser_profile/profile.rb +93 -0
- data/lib/portage/cli/browser_profile.rb +25 -0
- data/lib/portage/cli/buy.rb +521 -29
- data/lib/portage/cli/classifier.rb +158 -0
- data/lib/portage/cli/compare.rb +3 -0
- data/lib/portage/cli/doctor.rb +195 -7
- data/lib/portage/cli/dot_env.rb +117 -0
- data/lib/portage/cli/find.rb +96 -12
- data/lib/portage/cli/handoff_agents.rb +186 -0
- data/lib/portage/cli/handoff_only.rb +94 -0
- data/lib/portage/cli/handoff_reconciler.rb +15 -1
- data/lib/portage/cli/handoff_target.rb +61 -0
- data/lib/portage/cli/index/builder.rb +335 -0
- data/lib/portage/cli/index/exporter.rb +91 -0
- data/lib/portage/cli/index/known_cache.rb +155 -0
- data/lib/portage/cli/index/product_store.rb +101 -0
- data/lib/portage/cli/index/sources/browser.rb +31 -0
- data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
- data/lib/portage/cli/index/sources/stores_file.rb +58 -0
- data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
- data/lib/portage/cli/index/sources/wikidata.rb +95 -0
- data/lib/portage/cli/index/sources.rb +44 -0
- data/lib/portage/cli/index/store.rb +109 -0
- data/lib/portage/cli/index.rb +20 -0
- data/lib/portage/cli/known_stores_url.rb +15 -0
- data/lib/portage/cli/offer_sources.rb +460 -0
- data/lib/portage/cli/payment_methods.rb +24 -3
- data/lib/portage/cli/search_backends.rb +337 -12
- data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
- data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
- data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
- data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
- data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
- data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
- data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
- data/lib/portage/cli/setup_wizard.rb +74 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +10 -3
- data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
- data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
- data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
- data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
- data/lib/portage/cli/webmcp_mappings.rb +84 -0
- data/lib/portage/cli.rb +528 -6
- metadata +59 -2
data/lib/portage/cli/find.rb
CHANGED
|
@@ -3,9 +3,12 @@ require "portage/ucp"
|
|
|
3
3
|
require "portage/ucp/client"
|
|
4
4
|
|
|
5
5
|
require_relative "search_backends"
|
|
6
|
+
require_relative "offer_sources"
|
|
7
|
+
require_relative "agent_profile_url"
|
|
6
8
|
require_relative "probe_cache"
|
|
7
9
|
require_relative "decisions"
|
|
8
10
|
require_relative "user_agent"
|
|
11
|
+
require_relative "handoff_only"
|
|
9
12
|
|
|
10
13
|
module Portage
|
|
11
14
|
module Cli
|
|
@@ -29,33 +32,52 @@ module Portage
|
|
|
29
32
|
|
|
30
33
|
# @param max_price [Integer, nil] minor units, matching the protocol's
|
|
31
34
|
# own money representation — the CLI converts from major units.
|
|
32
|
-
|
|
35
|
+
# @param offer_sources [Array<#offers>, nil] a second kind of backend
|
|
36
|
+
# (OfferSources) that answers offers directly, with no manifest
|
|
37
|
+
# probe of its own — see #call. nil (the default) is
|
|
38
|
+
# OfferSources.default.
|
|
39
|
+
# @param handoff_only [HandoffOnly, nil] Tier C's host list
|
|
40
|
+
# (docs/plans/buy-skill-and-local-browser.md Phase 5) — nil (the
|
|
41
|
+
# default) is the real ~/.portage/config.json-backed one. A
|
|
42
|
+
# candidate on it is never probed (see #call): it still surfaces as
|
|
43
|
+
# a candidate, marked `handoff_only: true`, so an agent can list it
|
|
44
|
+
# ("Amazon also sells this") without this process ever fetching it.
|
|
45
|
+
def initialize(query:, limit: MAX_PROBES, max_price: nil, backends: nil, cache: nil, throttle: THROTTLE,
|
|
46
|
+
offer_sources: nil, handoff_only: nil)
|
|
33
47
|
@query = query.to_s
|
|
34
48
|
@limit = [limit, MAX_PROBES].min
|
|
35
49
|
@max_price = max_price
|
|
36
50
|
@backends = backends || SearchBackends.default
|
|
37
51
|
@cache = cache || ProbeCache.new
|
|
38
52
|
@throttle = throttle
|
|
53
|
+
@offer_sources = offer_sources || OfferSources.default
|
|
54
|
+
@handoff_only = handoff_only || HandoffOnly.new
|
|
39
55
|
end
|
|
40
56
|
|
|
41
57
|
def call
|
|
42
58
|
return report(message: "Nothing to search for — pass --query.") if @query.strip.empty?
|
|
43
59
|
|
|
44
60
|
candidates = candidate_origins
|
|
45
|
-
|
|
61
|
+
probed, stores = probe_candidates(candidates)
|
|
62
|
+
sourced = source_offers
|
|
63
|
+
return report(candidates: candidates, message: no_candidates_message) if nothing_to_go_on?(candidates, sourced)
|
|
46
64
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
offers: offers, message: summary(candidates, stores, offers))
|
|
65
|
+
offers = rank(sourced + probed.flat_map { |store| offers_for(store) })
|
|
66
|
+
report(candidates: candidates, stores: store_summaries(stores), offers: offers,
|
|
67
|
+
message: summary(candidates, stores, offers))
|
|
51
68
|
rescue Portage::Ucp::Client::MissingAgentProfileError
|
|
52
|
-
report(candidates: candidates, stores: stores
|
|
69
|
+
report(candidates: candidates, stores: store_summaries(stores),
|
|
53
70
|
message: "Set PORTAGE_AGENT_PROFILE to a URL that describes this agent — each store " \
|
|
54
71
|
"verifies it before answering a catalog search.")
|
|
55
72
|
end
|
|
56
73
|
|
|
57
74
|
private
|
|
58
75
|
|
|
76
|
+
# Neither the URL backends nor any OfferSource found anything to
|
|
77
|
+
# probe or rank — split out of #call to keep its own branching under
|
|
78
|
+
# the complexity budget.
|
|
79
|
+
def nothing_to_go_on?(candidates, sourced) = candidates.empty? && sourced.empty?
|
|
80
|
+
|
|
59
81
|
# --- Step 1: ask the backends who might sell this ---
|
|
60
82
|
|
|
61
83
|
def candidate_origins
|
|
@@ -78,7 +100,44 @@ module Portage
|
|
|
78
100
|
existing = seen[uri.host]
|
|
79
101
|
return if existing && !upgradable?(existing, uri)
|
|
80
102
|
|
|
81
|
-
seen[uri.host] = { origin: origin_of(uri), source: existing ? existing[:source] : backend.name
|
|
103
|
+
seen[uri.host] = { origin: origin_of(uri), source: existing ? existing[:source] : backend.name,
|
|
104
|
+
handoff_only: @handoff_only.host?(uri.host) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Tier C candidates never reach #probe — no UCP request, ever — but
|
|
108
|
+
# still surface in the report's `stores`, `checkout: false`.
|
|
109
|
+
def handoff_only_stores(candidates)
|
|
110
|
+
candidates.map { |c| c.merge(checkout: false) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Splits candidates into the ones #probe actually fetches and the
|
|
114
|
+
# Tier C ones that never touch the network — kept out of #call to
|
|
115
|
+
# stay under its own complexity budget.
|
|
116
|
+
# @return [Array(Array<Hash>, Array<Hash>)] probed stores (the ones
|
|
117
|
+
# with a live `session`, so #offers_for can ask them what they
|
|
118
|
+
# stock), and every store for the report (probed + hand-off-only).
|
|
119
|
+
def probe_candidates(candidates)
|
|
120
|
+
probeable, deferred = candidates.partition { |c| !c[:handoff_only] }
|
|
121
|
+
probed = probe(probeable)
|
|
122
|
+
[probed, probed + handoff_only_stores(deferred)]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def store_summaries(stores)
|
|
126
|
+
stores.map { |s| s.slice(:origin, :source, :checkout, :handoff_only) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# --- Step 1b: ask any OfferSources directly, no probe needed ---
|
|
130
|
+
|
|
131
|
+
# Each source already returns Find#offer-shaped hashes (store:/
|
|
132
|
+
# source:/checkout:/product_id:/title:/amount:/currency:/url:) and
|
|
133
|
+
# swallows its own failures, so nothing here needs the try/rescue
|
|
134
|
+
# #urls_from gives the URL backends. --max-price applies here exactly
|
|
135
|
+
# as it does to a probed store's offers in #offer.
|
|
136
|
+
def source_offers
|
|
137
|
+
offers = @offer_sources.flat_map do |source|
|
|
138
|
+
source.offers(@query, limit: PER_STORE_RESULTS, context: BuyerContext.from_env)
|
|
139
|
+
end
|
|
140
|
+
offers.reject { |offer| over_max_price?(offer[:amount]) }
|
|
82
141
|
end
|
|
83
142
|
|
|
84
143
|
def upgradable?(existing, uri)
|
|
@@ -156,18 +215,21 @@ module Portage
|
|
|
156
215
|
# before answering any call (see Transports::Http) — the own-store
|
|
157
216
|
# loopback path ignores it harmlessly.
|
|
158
217
|
def agent_meta
|
|
159
|
-
{ agent_profile:
|
|
218
|
+
{ agent_profile: AgentProfileUrl.resolve }
|
|
160
219
|
end
|
|
161
220
|
|
|
162
221
|
def offer(store, product)
|
|
163
222
|
amount, currency = price_of(product)
|
|
164
|
-
return nil if
|
|
223
|
+
return nil if over_max_price?(amount)
|
|
165
224
|
|
|
166
225
|
{ store: store[:origin], source: store[:source], checkout: store[:checkout],
|
|
167
226
|
product_id: field(product, "id"), title: field(product, "title"),
|
|
168
227
|
amount: amount, currency: currency, url: field(product, "url") }
|
|
169
228
|
end
|
|
170
229
|
|
|
230
|
+
# An unpriced offer stays in: no price isn't the same as too dear.
|
|
231
|
+
def over_max_price?(amount) = @max_price && amount && amount > @max_price
|
|
232
|
+
|
|
171
233
|
# Buyable first, then cheapest, then unpriced (see Decisions.rank —
|
|
172
234
|
# core's Support::OfferRanking, the rule portage-ucp-decision's
|
|
173
235
|
# OfferRanking wraps), so an agent loop ranking its own candidate list
|
|
@@ -213,7 +275,7 @@ module Portage
|
|
|
213
275
|
names = @backends.map(&:name)
|
|
214
276
|
return no_backends_message if names.empty?
|
|
215
277
|
|
|
216
|
-
"No candidate stores came back from #{names.join(', ')} for \"#{@query}\"
|
|
278
|
+
"No candidate stores came back from #{names.join(', ')} for \"#{@query}\".#{keyed_backend_hint}"
|
|
217
279
|
end
|
|
218
280
|
|
|
219
281
|
def no_backends_message
|
|
@@ -221,8 +283,30 @@ module Portage
|
|
|
221
283
|
"or list stores in ~/.portage/stores.yml."
|
|
222
284
|
end
|
|
223
285
|
|
|
286
|
+
# DuckDuckGo's free Instant Answer API is the keyless default, but it
|
|
287
|
+
# only answers *entity* queries ("burton snowboards" → burton.com) —
|
|
288
|
+
# open-ended shopping terms like "coffee" or "iphone" resolve to
|
|
289
|
+
# nothing, every time, regardless of what those stores actually stock
|
|
290
|
+
# (see SearchBackends::DuckDuckGo's own comment). Names alone (e.g.
|
|
291
|
+
# `no_candidates_message`) don't say *why* nothing came back, so this
|
|
292
|
+
# spells out the fix rather than leaving the caller to guess whether
|
|
293
|
+
# it's a network problem or a backend limitation. Only fires when no
|
|
294
|
+
# keyed backend (Brave/Google CSE) ran alongside it — one of those
|
|
295
|
+
# already covered the query with real web search, so there's nothing
|
|
296
|
+
# to suggest.
|
|
297
|
+
def keyed_backend_hint
|
|
298
|
+
return "" unless SearchBackends.only_duckduckgo?(@backends)
|
|
299
|
+
|
|
300
|
+
" DuckDuckGo's free API only resolves specific brand/product names, not open-ended search — " \
|
|
301
|
+
"set BRAVE_SEARCH_API_KEY or GOOGLE_CSE_KEY/GOOGLE_CSE_CX for real web search " \
|
|
302
|
+
"(see `portage doctor`)."
|
|
303
|
+
end
|
|
304
|
+
|
|
224
305
|
def summary(candidates, stores, offers)
|
|
225
|
-
|
|
306
|
+
# Counted from the offers, not `stores`: an OfferSource's offers
|
|
307
|
+
# come from stores that were never probed.
|
|
308
|
+
selling = offers.map { |o| o[:store] }.uniq.length
|
|
309
|
+
return "Found #{offers.length} offer(s) across #{selling} store(s)." if offers.any?
|
|
226
310
|
return "#{stores.length} store(s) speak UCP but none stock \"#{@query}\"." if stores.any?
|
|
227
311
|
|
|
228
312
|
"Checked #{candidates.length} store(s); none of them speak UCP."
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "portage/ucp/support/connection"
|
|
7
|
+
require_relative "config"
|
|
8
|
+
require_relative "user_agent"
|
|
9
|
+
|
|
10
|
+
module Portage
|
|
11
|
+
module Cli
|
|
12
|
+
# `--handoff-target agent:<name>` (docs/plans/buy-skill-and-local-browser.md
|
|
13
|
+
# Phase 5, decision 3): looks up a named agent in
|
|
14
|
+
# ~/.portage/config.json's `handoff_agents` and, only when it's
|
|
15
|
+
# explicitly `"approved": true`, hands it the same JSON payload
|
|
16
|
+
# `--notify-webhook` sends (Buy#handoff_notify_payload) — checkout URL
|
|
17
|
+
# plus the approved cart summary (items, qty, total, store). No
|
|
18
|
+
# credentials, payment tokens or shipping details beyond what the
|
|
19
|
+
# checkout URL already holds ever go into that payload.
|
|
20
|
+
#
|
|
21
|
+
# Config shape:
|
|
22
|
+
# "handoff_agents": {
|
|
23
|
+
# "openclaw": { "command": ["openclaw", "handoff"], "approved": true },
|
|
24
|
+
# "storefront": { "webhook": "https://example.com/hooks/handoff", "approved": true }
|
|
25
|
+
# }
|
|
26
|
+
#
|
|
27
|
+
# An entry with no "approved": true (or no entry at all) is never
|
|
28
|
+
# invoked — #lookup returns nil, and the caller (Buy) reports why and
|
|
29
|
+
# falls back to `print` behavior.
|
|
30
|
+
class HandoffAgents
|
|
31
|
+
CONFIG_KEY = "handoff_agents".freeze
|
|
32
|
+
|
|
33
|
+
def initialize(config: Config.load)
|
|
34
|
+
@config = config
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [#call, nil] an object responding to #call(payload) that
|
|
38
|
+
# returns nil on success or a delivery-failure message — nil when
|
|
39
|
+
# `name` has no config entry, or the entry isn't approved.
|
|
40
|
+
def lookup(name)
|
|
41
|
+
entry = agents[name.to_s]
|
|
42
|
+
return nil unless entry.is_a?(Hash) && entry["approved"] == true
|
|
43
|
+
|
|
44
|
+
return Command.new(entry["command"]) if entry["command"]
|
|
45
|
+
return Webhook.new(entry["webhook"]) if entry["webhook"]
|
|
46
|
+
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def agents
|
|
53
|
+
raw = @config.get(CONFIG_KEY)
|
|
54
|
+
raw.is_a?(Hash) ? raw : {}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Runs the configured argv (never a shell string — merchant-controlled
|
|
58
|
+
# text never reaches a shell), with the payload as JSON on stdin, a
|
|
59
|
+
# scrubbed environment (only ENV_ALLOWLIST passed through —
|
|
60
|
+
# PORTAGE_*, API keys and every other ambient var are dropped), and a
|
|
61
|
+
# hard timeout that kills the process on expiry. A non-zero exit
|
|
62
|
+
# means "not delivered", same posture as Notifier's non-2xx.
|
|
63
|
+
class Command
|
|
64
|
+
TIMEOUT = 30
|
|
65
|
+
# Grace period between SIGTERM and SIGKILL once TIMEOUT is hit — long
|
|
66
|
+
# enough for a well-behaved process to notice and exit, short enough
|
|
67
|
+
# that a hung/ignoring one doesn't stall the hand-off much further.
|
|
68
|
+
KILL_GRACE = 2
|
|
69
|
+
BODY_EXCERPT = 200
|
|
70
|
+
# Only the basics a well-behaved subprocess needs to run at all —
|
|
71
|
+
# never PORTAGE_*, a payment/API key, or a shipping variable.
|
|
72
|
+
ENV_ALLOWLIST = %w[PATH HOME LANG LC_ALL TERM TMPDIR TZ].freeze
|
|
73
|
+
|
|
74
|
+
def initialize(argv)
|
|
75
|
+
@argv = Array(argv).map(&:to_s)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def call(payload)
|
|
79
|
+
return "agent command is empty" if @argv.empty?
|
|
80
|
+
|
|
81
|
+
stdout, stderr, status = run(payload)
|
|
82
|
+
return nil if status&.success?
|
|
83
|
+
|
|
84
|
+
"agent command exited #{status&.exitstatus.inspect}: #{excerpt(stderr, stdout)}"
|
|
85
|
+
rescue Timeout::Error
|
|
86
|
+
"agent command timed out after #{TIMEOUT}s"
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
"agent command failed: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Reads stdout/stderr on their own threads — same posture as
|
|
94
|
+
# Open3.capture3 — so a child that fills the stderr pipe while this
|
|
95
|
+
# blocks reading stdout to EOF (or vice versa) can't deadlock the
|
|
96
|
+
# hand-off. Open3.popen3's block form always joins `wait_thr` in its
|
|
97
|
+
# own `ensure` once this block returns, so a timeout has to actually
|
|
98
|
+
# kill the child (and wait for it to die) *before* returning from
|
|
99
|
+
# here — otherwise that `ensure` blocks forever on a process nothing
|
|
100
|
+
# is waiting on anymore, and the TIMEOUT constant does nothing.
|
|
101
|
+
def run(payload)
|
|
102
|
+
env = ENV.to_h.slice(*ENV_ALLOWLIST)
|
|
103
|
+
out = nil
|
|
104
|
+
err = nil
|
|
105
|
+
status = nil
|
|
106
|
+
Open3.popen3(env, *@argv, unsetenv_others: true) do |stdin, stdout, stderr, wait_thr|
|
|
107
|
+
write_and_close(stdin, payload)
|
|
108
|
+
out_thread = Thread.new { stdout.read.to_s }
|
|
109
|
+
err_thread = Thread.new { stderr.read.to_s }
|
|
110
|
+
status = wait_with_timeout(wait_thr)
|
|
111
|
+
out = out_thread.value
|
|
112
|
+
err = err_thread.value
|
|
113
|
+
end
|
|
114
|
+
[out, err, status]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def wait_with_timeout(wait_thr)
|
|
118
|
+
Timeout.timeout(TIMEOUT) { wait_thr.value }
|
|
119
|
+
rescue Timeout::Error
|
|
120
|
+
kill_and_wait(wait_thr)
|
|
121
|
+
raise
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# SIGTERM first, SIGKILL after KILL_GRACE if it's still alive —
|
|
125
|
+
# either way this doesn't return until `wait_thr.value` resolves, so
|
|
126
|
+
# the process is confirmed dead (and reaped) before #run's caller
|
|
127
|
+
# ever gets to leave the `Open3.popen3` block.
|
|
128
|
+
def kill_and_wait(wait_thr)
|
|
129
|
+
Process.kill("TERM", wait_thr.pid)
|
|
130
|
+
Timeout.timeout(KILL_GRACE) { wait_thr.value }
|
|
131
|
+
rescue Errno::ESRCH
|
|
132
|
+
nil
|
|
133
|
+
rescue Timeout::Error
|
|
134
|
+
kill_quietly(wait_thr.pid)
|
|
135
|
+
wait_thr.value
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def kill_quietly(pid)
|
|
139
|
+
Process.kill("KILL", pid)
|
|
140
|
+
rescue Errno::ESRCH
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def write_and_close(stdin, payload)
|
|
145
|
+
stdin.write(JSON.generate(payload))
|
|
146
|
+
ensure
|
|
147
|
+
stdin.close
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def excerpt(stderr, stdout) = (stderr.to_s.empty? ? stdout.to_s : stderr.to_s).strip[0, BODY_EXCERPT]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# POSTs the payload as JSON — https only, same connection helper and
|
|
154
|
+
# short timeout as Notifier, failures swallowed into the return
|
|
155
|
+
# value rather than raised.
|
|
156
|
+
class Webhook
|
|
157
|
+
TIMEOUT = 10
|
|
158
|
+
BODY_EXCERPT = 200
|
|
159
|
+
|
|
160
|
+
def initialize(url)
|
|
161
|
+
@url = url.to_s
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def call(payload)
|
|
165
|
+
return "agent webhook must be https" unless @url.start_with?("https://")
|
|
166
|
+
|
|
167
|
+
response = post(URI(@url), JSON.generate(payload))
|
|
168
|
+
return nil if response.is_a?(Net::HTTPSuccess)
|
|
169
|
+
|
|
170
|
+
"agent webhook answered #{response.code}: #{response.body.to_s[0, BODY_EXCERPT]}"
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
"agent webhook POST failed: #{e.message}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def post(uri, body)
|
|
178
|
+
Portage::Ucp::Support::Connection.start(uri, route: :notify, open_timeout: TIMEOUT,
|
|
179
|
+
read_timeout: TIMEOUT) do |http|
|
|
180
|
+
http.post(uri.request_uri, body, UserAgent.headers.merge("Content-Type" => "application/json"))
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require_relative "config"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# Tier C (docs/plans/buy-skill-and-local-browser.md Phase 5): the single
|
|
7
|
+
# place that decides whether a host is hand-off only — Amazon (every
|
|
8
|
+
# marketplace) by default, plus whatever the user adds or removes in
|
|
9
|
+
# ~/.portage/config.json's "handoff_only_hosts". `Buy#call`, `Find`,
|
|
10
|
+
# `Index::Builder` and `BrowserImport::Importer` all read this list
|
|
11
|
+
# (Importer already had an injectable `handoff_only_hosts:` seam for it)
|
|
12
|
+
# rather than each hard-coding or re-deriving their own — the one
|
|
13
|
+
# invariant every caller depends on is "never probed, never fetched,
|
|
14
|
+
# never automated", and that only holds if there's exactly one place
|
|
15
|
+
# deciding host membership.
|
|
16
|
+
#
|
|
17
|
+
# Absent key: the shipped default list. Present key (even an empty
|
|
18
|
+
# array): the user's list *is* the list — they can drop Amazon entirely
|
|
19
|
+
# or add other hosts, and #host? only ever consults what #hosts returns.
|
|
20
|
+
class HandoffOnly
|
|
21
|
+
CONFIG_KEY = "handoff_only_hosts".freeze
|
|
22
|
+
|
|
23
|
+
# Every Amazon marketplace TLD, current as of this phase — the user's
|
|
24
|
+
# own config.json list can extend or shrink this once it's present.
|
|
25
|
+
DEFAULT_HOSTS = %w[
|
|
26
|
+
amazon.com amazon.co.uk amazon.de amazon.fr amazon.it amazon.es amazon.nl amazon.se amazon.pl
|
|
27
|
+
amazon.com.be amazon.ie amazon.ca amazon.com.mx amazon.com.br amazon.co.jp amazon.in amazon.com.au
|
|
28
|
+
amazon.sg amazon.ae amazon.sa amazon.eg amazon.com.tr amazon.cn
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# Facts, not legal advice (docs/plans/buy-skill-and-local-browser.md
|
|
32
|
+
# decision 5): what the site's terms say, plus the as-is/no-warranty
|
|
33
|
+
# line. Reused verbatim by Buy's `handoff_only` report, `doctor`,
|
|
34
|
+
# `portage setup`'s wizard, and the `buy` skill's own reference doc.
|
|
35
|
+
LEGAL_NOTICE = "This retailer's terms restrict automated purchasing agents, so Portage opens the page " \
|
|
36
|
+
"and you complete the purchase. Portage is open-source software provided as-is, " \
|
|
37
|
+
"without warranty.".freeze
|
|
38
|
+
|
|
39
|
+
def initialize(config: Config.load)
|
|
40
|
+
@config = config
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Array<String>] lowercase base hosts (no scheme, no path,
|
|
44
|
+
# no "www."). A user entry of "www.example.com", "example.com/",
|
|
45
|
+
# or "https://www.example.com/s?k=x" all normalize to the same
|
|
46
|
+
# "example.com" — the same normalization
|
|
47
|
+
# `BrowserImport::Importer`/`Domains` already apply to a history or
|
|
48
|
+
# bookmark domain, so a host written any of those ways behaves the
|
|
49
|
+
# same everywhere it's checked.
|
|
50
|
+
def hosts
|
|
51
|
+
configured = @config.get(CONFIG_KEY)
|
|
52
|
+
return DEFAULT_HOSTS unless configured.is_a?(Array)
|
|
53
|
+
|
|
54
|
+
configured.filter_map { |h| self.class.normalize_entry(h) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param host [String, nil] a bare hostname, e.g. "www.amazon.co.uk".
|
|
58
|
+
def host?(host) = self.class.matches_any?(host, hosts)
|
|
59
|
+
|
|
60
|
+
# Amazon-ness is a fact about the domain, independent of whatever the
|
|
61
|
+
# user's own config currently lists (they may have removed it from
|
|
62
|
+
# #hosts and still be on an amazon.* origin) — used by Buy to decide
|
|
63
|
+
# whether it knows a search/cart-add URL pattern for this host at all.
|
|
64
|
+
def self.amazon?(host) = matches_any?(host, DEFAULT_HOSTS)
|
|
65
|
+
|
|
66
|
+
def self.matches_any?(host, list)
|
|
67
|
+
normalized = host.to_s.downcase.strip
|
|
68
|
+
return false if normalized.empty?
|
|
69
|
+
|
|
70
|
+
list.any? { |base| normalized == base || normalized.end_with?(".#{base}") }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A bare host ("amazon.co.uk"), one already carrying "www." ("www.
|
|
74
|
+
# amazon.co.uk"), or a full URL a user pasted in ("https://www.
|
|
75
|
+
# amazon.co.uk/s?k=x") — a scheme means this is a URL, parsed for its
|
|
76
|
+
# host; otherwise it's a bare host/host-with-path, so only the part
|
|
77
|
+
# before the first "/" or "?" counts.
|
|
78
|
+
def self.normalize_entry(value)
|
|
79
|
+
text = value.to_s.strip
|
|
80
|
+
return nil if text.empty?
|
|
81
|
+
|
|
82
|
+
host = text.include?("://") ? uri_host(text) : text[%r{\A[^/?\s]+}]
|
|
83
|
+
host = host.to_s.downcase.delete_prefix("www.")
|
|
84
|
+
host.empty? ? nil : host
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.uri_host(text)
|
|
88
|
+
URI.parse(text).host
|
|
89
|
+
rescue URI::InvalidURIError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -8,6 +8,7 @@ require_relative "homepage_fetch"
|
|
|
8
8
|
require_relative "handoff_spend_mode"
|
|
9
9
|
require_relative "reconcile_notifier"
|
|
10
10
|
require_relative "permissive_authenticator"
|
|
11
|
+
require_relative "handoff_only"
|
|
11
12
|
require_relative "handoff_reconciler/wire_adapters"
|
|
12
13
|
|
|
13
14
|
module Portage
|
|
@@ -48,13 +49,15 @@ module Portage
|
|
|
48
49
|
def initialize(transaction_log: Portage::Ucp::Support::TransactionLog.new,
|
|
49
50
|
order_ledger: Portage::Ucp::Support::OrderLedger.new,
|
|
50
51
|
journal: Portage::Ucp::Journal::PurchaseJournal.new,
|
|
51
|
-
notifier: ReconcileNotifier.new, spend_mode: HandoffSpendMode.resolve, clock: -> { Time.now }
|
|
52
|
+
notifier: ReconcileNotifier.new, spend_mode: HandoffSpendMode.resolve, clock: -> { Time.now },
|
|
53
|
+
handoff_only: nil)
|
|
52
54
|
@transaction_log = transaction_log
|
|
53
55
|
@order_ledger = order_ledger
|
|
54
56
|
@journal = journal
|
|
55
57
|
@notifier = notifier
|
|
56
58
|
@spend_mode = spend_mode
|
|
57
59
|
@clock = clock
|
|
60
|
+
@handoff_only = handoff_only || HandoffOnly.new
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
# @param record [Hash] a TransactionLog record, as returned by
|
|
@@ -216,6 +219,17 @@ module Portage
|
|
|
216
219
|
raise ReconnectError, "no store_url on this record" if store_url.to_s.empty?
|
|
217
220
|
|
|
218
221
|
uri = URI.parse(store_url)
|
|
222
|
+
# Defense in depth (docs/plans/buy-skill-and-local-browser.md
|
|
223
|
+
# Phase 5): nothing in this gem should ever write a hand-off-only
|
|
224
|
+
# host's checkout into TransactionLog as a pending `settled_by:
|
|
225
|
+
# "shopper"` record in the first place (Buy's `handoff_only`
|
|
226
|
+
# outcome never calls #hand_off/#record_pending_handoff at all),
|
|
227
|
+
# but this is the one place every reconcile path fetches a store
|
|
228
|
+
# again from a saved record, so it's guarded here too rather than
|
|
229
|
+
# trusted to stay that way.
|
|
230
|
+
raise ReconnectError, "#{uri.host} is hand-off only — never reconciled automatically" \
|
|
231
|
+
if @handoff_only.host?(uri.host)
|
|
232
|
+
|
|
219
233
|
native_session(uri) || adapter_session(uri) ||
|
|
220
234
|
raise(ReconnectError, "no automated path back into #{store_url}")
|
|
221
235
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require_relative "config"
|
|
2
|
+
require_relative "setting"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# `portage buy --handoff-target` (docs/plans/buy-skill-and-local-browser.md
|
|
7
|
+
# Phase 5): which of the four hand-off targets a dead-end checkout_url
|
|
8
|
+
# goes to. Same Setting precedence as CheckoutHandoff/Notifier — a
|
|
9
|
+
# per-invocation override beats PORTAGE_HANDOFF_TARGET, which beats
|
|
10
|
+
# ~/.portage/config.json's "handoff_target" — and defaults to "default"
|
|
11
|
+
# (today's CheckoutHandoff auto-open behavior) when nothing is set at
|
|
12
|
+
# any level.
|
|
13
|
+
#
|
|
14
|
+
# Raises ArgumentError on anything else, so `Cli.run_buy` can treat a
|
|
15
|
+
# bad value as a usage error the same way it already does for a bad
|
|
16
|
+
# --min-confidence (see Cli.confidence_check) — built once, up front,
|
|
17
|
+
# before a checkout is ever attempted.
|
|
18
|
+
class HandoffTarget
|
|
19
|
+
ENV_VAR = "PORTAGE_HANDOFF_TARGET".freeze
|
|
20
|
+
CONFIG_KEY = "handoff_target".freeze
|
|
21
|
+
DEFAULT = "default".freeze
|
|
22
|
+
KNOWN_KINDS = %w[default print profile].freeze
|
|
23
|
+
AGENT_PATTERN = /\Aagent:(.+)\z/
|
|
24
|
+
|
|
25
|
+
attr_reader :kind, :agent_name
|
|
26
|
+
|
|
27
|
+
def initialize(override: nil, config: Config.load)
|
|
28
|
+
value = Setting.resolve(override: override, env: ENV_VAR, config: config, config_key: CONFIG_KEY)
|
|
29
|
+
value = value.to_s.strip
|
|
30
|
+
parse!(value.empty? ? DEFAULT : value)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def default? = @kind == "default"
|
|
34
|
+
def print? = @kind == "print"
|
|
35
|
+
def profile? = @kind == "profile"
|
|
36
|
+
def agent? = @kind == "agent"
|
|
37
|
+
|
|
38
|
+
# `agent:<name>`'s own label for a report/dispatch — "agent:<name>"
|
|
39
|
+
# for an agent target, or just the kind otherwise.
|
|
40
|
+
def label = agent? ? "agent:#{agent_name}" : @kind
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def parse!(value)
|
|
45
|
+
if KNOWN_KINDS.include?(value)
|
|
46
|
+
@kind = value
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
match = AGENT_PATTERN.match(value)
|
|
51
|
+
unless match
|
|
52
|
+
raise ArgumentError, "Unknown --handoff-target #{value.inspect} — use default, print, profile, " \
|
|
53
|
+
"or agent:<name>."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@kind = "agent"
|
|
57
|
+
@agent_name = match[1]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|