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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +580 -0
  3. data/README.md +322 -19
  4. data/exe/portage +5 -0
  5. data/exe/portage-console +3 -0
  6. data/known-stores/categories.yml +1263 -0
  7. data/lib/portage/cli/agent_profile_url.rb +30 -0
  8. data/lib/portage/cli/browser_import/categorize.rb +59 -0
  9. data/lib/portage/cli/browser_import/confirm.rb +35 -0
  10. data/lib/portage/cli/browser_import/domains.rb +47 -0
  11. data/lib/portage/cli/browser_import/filter.rb +91 -0
  12. data/lib/portage/cli/browser_import/importer.rb +248 -0
  13. data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
  14. data/lib/portage/cli/browser_import/prober.rb +60 -0
  15. data/lib/portage/cli/browser_import/profiles.rb +114 -0
  16. data/lib/portage/cli/browser_import/readers.rb +179 -0
  17. data/lib/portage/cli/browser_import/saver.rb +62 -0
  18. data/lib/portage/cli/browser_import/sqlite.rb +68 -0
  19. data/lib/portage/cli/browser_import.rb +23 -0
  20. data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
  21. data/lib/portage/cli/browser_profile/bridge.rb +120 -0
  22. data/lib/portage/cli/browser_profile/browsers.rb +69 -0
  23. data/lib/portage/cli/browser_profile/cdp.rb +67 -0
  24. data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
  25. data/lib/portage/cli/browser_profile/errors.rb +26 -0
  26. data/lib/portage/cli/browser_profile/launcher.rb +34 -0
  27. data/lib/portage/cli/browser_profile/profile.rb +93 -0
  28. data/lib/portage/cli/browser_profile.rb +25 -0
  29. data/lib/portage/cli/buy.rb +521 -29
  30. data/lib/portage/cli/classifier.rb +158 -0
  31. data/lib/portage/cli/compare.rb +3 -0
  32. data/lib/portage/cli/doctor.rb +195 -7
  33. data/lib/portage/cli/dot_env.rb +117 -0
  34. data/lib/portage/cli/find.rb +96 -12
  35. data/lib/portage/cli/handoff_agents.rb +186 -0
  36. data/lib/portage/cli/handoff_only.rb +94 -0
  37. data/lib/portage/cli/handoff_reconciler.rb +15 -1
  38. data/lib/portage/cli/handoff_target.rb +61 -0
  39. data/lib/portage/cli/index/builder.rb +335 -0
  40. data/lib/portage/cli/index/exporter.rb +91 -0
  41. data/lib/portage/cli/index/known_cache.rb +155 -0
  42. data/lib/portage/cli/index/product_store.rb +101 -0
  43. data/lib/portage/cli/index/sources/browser.rb +31 -0
  44. data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
  45. data/lib/portage/cli/index/sources/stores_file.rb +58 -0
  46. data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
  47. data/lib/portage/cli/index/sources/wikidata.rb +95 -0
  48. data/lib/portage/cli/index/sources.rb +44 -0
  49. data/lib/portage/cli/index/store.rb +109 -0
  50. data/lib/portage/cli/index.rb +20 -0
  51. data/lib/portage/cli/known_stores_url.rb +15 -0
  52. data/lib/portage/cli/offer_sources.rb +460 -0
  53. data/lib/portage/cli/payment_methods.rb +24 -3
  54. data/lib/portage/cli/search_backends.rb +337 -12
  55. data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
  56. data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
  57. data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
  58. data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
  59. data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
  60. data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
  61. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
  62. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
  63. data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
  64. data/lib/portage/cli/setup_wizard.rb +74 -0
  65. data/lib/portage/cli/version.rb +1 -1
  66. data/lib/portage/cli/webmcp.rb +10 -3
  67. data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
  68. data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
  69. data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
  70. data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
  71. data/lib/portage/cli/webmcp_mappings.rb +84 -0
  72. data/lib/portage/cli.rb +528 -6
  73. metadata +59 -2
@@ -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
- def initialize(query:, limit: MAX_PROBES, max_price: nil, backends: nil, cache: nil, throttle: THROTTLE)
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
- return report(candidates: candidates, message: no_candidates_message) if candidates.empty?
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
- stores = probe(candidates)
48
- offers = rank(stores.flat_map { |store| offers_for(store) })
49
- report(candidates: candidates, stores: stores.map { |s| s.slice(:origin, :source, :checkout) },
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.map { |s| s.slice(:origin, :source, :checkout) },
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: ENV.fetch("PORTAGE_AGENT_PROFILE", nil) }
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 @max_price && amount && amount > @max_price
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
- return "Found #{offers.length} offer(s) across #{stores.length} UCP store(s)." if offers.any?
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