portage-cli 0.11.0 → 0.13.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -1
  3. data/README.md +88 -18
  4. data/lib/portage/cli/adapter_session.rb +34 -0
  5. data/lib/portage/cli/browser_import/importer.rb +1 -1
  6. data/lib/portage/cli/browser_import/plist_xml.rb +5 -12
  7. data/lib/portage/cli/browser_import/sqlite.rb +19 -25
  8. data/lib/portage/cli/browser_profile/cdp.rb +0 -9
  9. data/lib/portage/cli/buy.rb +217 -39
  10. data/lib/portage/cli/compare.rb +4 -3
  11. data/lib/portage/cli/confidence_check.rb +27 -7
  12. data/lib/portage/cli/confidence_state.rb +109 -0
  13. data/lib/portage/cli/find.rb +66 -9
  14. data/lib/portage/cli/generate/agent_profile.rb +1 -1
  15. data/lib/portage/cli/handoff_agents.rb +7 -21
  16. data/lib/portage/cli/handoff_reconciler.rb +2 -22
  17. data/lib/portage/cli/homepage_fetch.rb +5 -3
  18. data/lib/portage/cli/index/builder.rb +2 -11
  19. data/lib/portage/cli/index/known_cache.rb +9 -2
  20. data/lib/portage/cli/index/sources/stores_file.rb +2 -11
  21. data/lib/portage/cli/index/sources/wikidata.rb +2 -11
  22. data/lib/portage/cli/install_doctor.rb +2 -1
  23. data/lib/portage/cli/notifier.rb +16 -12
  24. data/lib/portage/cli/offer_sources.rb +3 -14
  25. data/lib/portage/cli/payment_methods.rb +7 -30
  26. data/lib/portage/cli/reconcile_notifier.rb +2 -5
  27. data/lib/portage/cli/setup_wizard/steps/env_keys_step.rb +40 -0
  28. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +11 -23
  29. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +10 -22
  30. data/lib/portage/cli/setup_wizard/steps/shipping.rb +5 -19
  31. data/lib/portage/cli/version.rb +1 -1
  32. data/lib/portage/cli.rb +25 -173
  33. metadata +5 -3
  34. data/lib/portage/cli/known_stores_url.rb +0 -15
@@ -24,6 +24,12 @@ module Portage
24
24
  # search ranker and the purchase decision to `--yes` in one breath is how
25
25
  # you end up owning a counterfeit from a shop you've never heard of, so
26
26
  # picking a store stays an explicit act (see Cli.run_buy's `--store` gate).
27
+ #
28
+ # `store:` narrows the same pipeline to one store the caller already
29
+ # named — the live re-check of an index hit or an earlier offer. No
30
+ # search backend or OfferSource runs; the store is probed and its catalog
31
+ # searched, read-only, so its offers carry the same shape, `offer_ref`
32
+ # and history entry as any other find.
27
33
  class Find
28
34
  CART_CAP = "dev.ucp.shopping.cart".freeze
29
35
  CHECKOUT_CAP = "dev.ucp.shopping.checkout".freeze
@@ -43,8 +49,11 @@ module Portage
43
49
  # candidate on it is never probed (see #call): it still surfaces as
44
50
  # a candidate, marked `handoff_only: true`, so an agent can list it
45
51
  # ("Amazon also sells this") without this process ever fetching it.
52
+ # @param store [String, nil] a store URL (see Find.store_origin): search
53
+ # only that store, live, and skip every backend and OfferSource.
46
54
  def initialize(query:, limit: MAX_PROBES, max_price: nil, backends: nil, cache: nil, throttle: THROTTLE,
47
- offer_sources: nil, handoff_only: nil)
55
+ offer_sources: nil, handoff_only: nil, store: nil)
56
+ @store = store
48
57
  @query = query.to_s
49
58
  @limit = [limit, MAX_PROBES].min
50
59
  @max_price = max_price
@@ -55,6 +64,27 @@ module Portage
55
64
  @handoff_only = handoff_only || HandoffOnly.new
56
65
  end
57
66
 
67
+ # The origin a `--store` value names: an http(s) URL (or a bare host,
68
+ # as `check` and `buy` accept) collapsed onto scheme://host[:port].
69
+ # @return [String, nil] nil for anything else, such as ftp:// or junk.
70
+ def self.store_origin(url)
71
+ text = url.to_s.strip
72
+ return nil if text.empty? || (text.include?("://") && !text.match?(%r{\Ahttps?://}i))
73
+
74
+ uri = URI.parse(text.include?("://") ? text : "https://#{text}")
75
+ origin_of_uri(uri)
76
+ rescue URI::InvalidURIError
77
+ nil
78
+ end
79
+
80
+ def self.origin_of_uri(uri)
81
+ return nil unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
82
+
83
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
84
+ "#{uri.scheme}://#{uri.host.downcase}#{port}"
85
+ end
86
+ private_class_method :origin_of_uri
87
+
58
88
  def call
59
89
  return report(message: "Nothing to search for — pass --query.") if @query.strip.empty?
60
90
 
@@ -88,6 +118,8 @@ module Portage
88
118
  # --- Step 1: ask the backends who might sell this ---
89
119
 
90
120
  def candidate_origins
121
+ return store_candidate if @store
122
+
91
123
  seen = {}
92
124
  @backends.each do |backend|
93
125
  urls_from(backend).each { |url| add_candidate(seen, backend, url) }
@@ -95,6 +127,15 @@ module Portage
95
127
  seen.values.first(@limit)
96
128
  end
97
129
 
130
+ # `--store`: the one candidate is the named store itself. A hand-off-only
131
+ # host stays a candidate, flagged, so #probe_candidates never fetches it.
132
+ def store_candidate
133
+ origin = self.class.store_origin(@store)
134
+ return [] unless origin
135
+
136
+ [{ origin: origin, source: "store", handoff_only: @handoff_only.host?(URI.parse(origin).host) }]
137
+ end
138
+
98
139
  # Keyed by host rather than by full origin: backends routinely hand back
99
140
  # both `http://` and `https://` for the same shop, and probing one host
100
141
  # twice over two schemes is a wasted request every time. https wins when
@@ -107,7 +148,8 @@ module Portage
107
148
  existing = seen[uri.host]
108
149
  return if existing && !upgradable?(existing, uri)
109
150
 
110
- seen[uri.host] = { origin: origin_of(uri), source: existing ? existing[:source] : backend.name,
151
+ seen[uri.host] = { origin: OfferSources.origin_of(uri.to_s),
152
+ source: existing ? existing[:source] : backend.name,
111
153
  handoff_only: @handoff_only.host?(uri.host) }
112
154
  end
113
155
 
@@ -141,6 +183,8 @@ module Portage
141
183
  # #urls_from gives the URL backends. --max-price applies here exactly
142
184
  # as it does to a probed store's offers in #offer.
143
185
  def source_offers
186
+ return [] if @store
187
+
144
188
  offers = @offer_sources.flat_map do |source|
145
189
  source.offers(@query, limit: PER_STORE_RESULTS, context: BuyerContext.from_env)
146
190
  end
@@ -166,13 +210,6 @@ module Portage
166
210
  nil
167
211
  end
168
212
 
169
- # Collapse every deep link a backend returns onto the origin, since
170
- # that's the only thing `/.well-known/ucp` hangs off.
171
- def origin_of(uri)
172
- port = uri.port == uri.default_port ? "" : ":#{uri.port}"
173
- "#{uri.scheme}://#{uri.host}#{port}"
174
- end
175
-
176
213
  # --- Step 2: keep the ones that actually speak UCP ---
177
214
 
178
215
  # A cached *miss* is the only verdict that saves work here — a cached hit
@@ -281,6 +318,8 @@ module Portage
281
318
  end
282
319
 
283
320
  def no_candidates_message
321
+ return "#{@store.inspect} isn't an http(s) store URL." if @store
322
+
284
323
  names = @backends.map(&:name)
285
324
  return no_backends_message if names.empty?
286
325
 
@@ -312,6 +351,8 @@ module Portage
312
351
  end
313
352
 
314
353
  def summary(candidates, stores, offers)
354
+ return store_summary(candidates.first, stores, offers) if @store
355
+
315
356
  # Counted from the offers, not `stores`: an OfferSource's offers
316
357
  # come from stores that were never probed.
317
358
  selling = offers.map { |o| o[:store] }.uniq.length
@@ -320,6 +361,22 @@ module Portage
320
361
 
321
362
  "Checked #{candidates.length} store(s); none of them speak UCP."
322
363
  end
364
+
365
+ # The `--store` wording: say what happened to that one store, not "N
366
+ # stores".
367
+ def store_summary(candidate, stores, offers)
368
+ origin = candidate[:origin]
369
+ return "Found #{offers.length} offer(s) at #{origin}." if offers.any?
370
+ return handoff_only_message(origin) if candidate[:handoff_only]
371
+ return "#{origin} doesn't speak UCP, so there is no catalogue to search." if stores.empty?
372
+
373
+ "#{origin} speaks UCP but has nothing matching \"#{@query}\"."
374
+ end
375
+
376
+ def handoff_only_message(origin)
377
+ "#{origin} is a hand-off-only retailer: Portage never fetches it, so it wasn't searched. " \
378
+ "Open the site yourself."
379
+ end
323
380
  end
324
381
  end
325
382
  end
@@ -178,7 +178,7 @@ module Portage
178
178
  end
179
179
 
180
180
  # Raw uncompressed EC point encoding: 0x04 || x (32 bytes) || y (32
181
- # bytes) for P-256 — see Portage::Ucp::Security::Signature::CURVES,
181
+ # bytes) for P-256 — see Portage::Ucp::Security::EcJwk::CURVES,
182
182
  # which decodes the same layout in reverse when verifying.
183
183
  def coordinates(pkey)
184
184
  octets = pkey.public_key.to_bn.to_s(2)
@@ -1,10 +1,9 @@
1
1
  require "json"
2
- require "net/http"
3
2
  require "open3"
4
3
  require "timeout"
5
4
  require "uri"
6
- require "portage/ucp/support/connection"
7
5
  require_relative "config"
6
+ require_relative "notifier"
8
7
  require_relative "user_agent"
9
8
 
10
9
  module Portage
@@ -150,12 +149,13 @@ module Portage
150
149
  def excerpt(stderr, stdout) = (stderr.to_s.empty? ? stdout.to_s : stderr.to_s).strip[0, BODY_EXCERPT]
151
150
  end
152
151
 
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.
152
+ # POSTs the payload as JSON — https only, through Notifier.post_json
153
+ # (same connection helper as --notify-webhook, with a longer timeout),
154
+ # failures swallowed into the return value rather than raised. Never
155
+ # Notifier#call itself: that falls back to PORTAGE_NOTIFY_WEBHOOK_URL
156
+ # or config, so a blank agent URL would post to the global webhook.
156
157
  class Webhook
157
158
  TIMEOUT = 10
158
- BODY_EXCERPT = 200
159
159
 
160
160
  def initialize(url)
161
161
  @url = url.to_s
@@ -164,21 +164,7 @@ module Portage
164
164
  def call(payload)
165
165
  return "agent webhook must be https" unless @url.start_with?("https://")
166
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
167
+ Notifier.post_json(@url, payload, timeout: TIMEOUT, label: "agent webhook")
182
168
  end
183
169
  end
184
170
  end
@@ -4,10 +4,9 @@ require "portage/ucp"
4
4
  require "portage/ucp/client"
5
5
  require "portage/ucp/journal"
6
6
  require_relative "user_agent"
7
- require_relative "homepage_fetch"
7
+ require_relative "adapter_session"
8
8
  require_relative "handoff_spend_mode"
9
9
  require_relative "reconcile_notifier"
10
- require_relative "permissive_authenticator"
11
10
  require_relative "handoff_only"
12
11
  require_relative "handoff_reconciler/wire_adapters"
13
12
 
@@ -230,7 +229,7 @@ module Portage
230
229
  raise ReconnectError, "#{uri.host} is hand-off only — never reconciled automatically" \
231
230
  if @handoff_only.host?(uri.host)
232
231
 
233
- native_session(uri) || adapter_session(uri) ||
232
+ native_session(uri) || AdapterSession.call(uri) ||
234
233
  raise(ReconnectError, "no automated path back into #{store_url}")
235
234
  end
236
235
 
@@ -239,25 +238,6 @@ module Portage
239
238
  rescue Portage::Ucp::Client::DiscoveryError
240
239
  nil
241
240
  end
242
-
243
- # Mirrors Buy's own own-store loopback fallback (#adapter_flow) — a
244
- # later process re-discovers the platform from the homepage the same
245
- # way, and must not assume the original process's session or
246
- # credentials still exist, only that this platform's env vars are
247
- # still set (see the plan's non-negotiable constraint).
248
- def adapter_session(uri)
249
- body, headers = HomepageFetch.call(uri)
250
- platform = body && Portage::Ucp::Resolver.detect_platform(body, headers)
251
- return nil unless platform
252
-
253
- env = Portage::Ucp::Resolver.env_for(platform)
254
- return nil unless Portage::Ucp::Resolver.missing_env(platform, env).empty?
255
-
256
- adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
257
- Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new)
258
- rescue StandardError
259
- nil
260
- end
261
241
  end
262
242
  end
263
243
  end
@@ -13,21 +13,23 @@ module Portage
13
13
  module HomepageFetch
14
14
  REDIRECT_LIMIT = 5
15
15
 
16
+ # @param route [Symbol] Support::Connection route for every hop,
17
+ # redirects included (:store, or :payment for PaymentMethods).
16
18
  # @return [Array(String, Hash), Array(nil, Hash)] the body and response
17
19
  # headers, or [nil, {}] on any failure/redirect exhaustion — same
18
20
  # "no automated path, not a crash" posture as the rest of the buy
19
21
  # flow.
20
- def self.call(uri, limit: REDIRECT_LIMIT)
22
+ def self.call(uri, limit: REDIRECT_LIMIT, route: :store)
21
23
  return [nil, {}] if limit.zero?
22
24
 
23
- response = Portage::Ucp::Support::Connection.start(uri, route: :store, open_timeout: 5,
25
+ response = Portage::Ucp::Support::Connection.start(uri, route: route, open_timeout: 5,
24
26
  read_timeout: 5) do |http|
25
27
  http.get(uri.request_uri, UserAgent.headers)
26
28
  end
27
29
 
28
30
  case response
29
31
  when Net::HTTPRedirection
30
- call(URI.join(uri, response["location"]), limit: limit - 1)
32
+ call(URI.join(uri, response["location"]), limit: limit - 1, route: route)
31
33
  when Net::HTTPSuccess
32
34
  [response.body, response.to_hash]
33
35
  else
@@ -5,6 +5,7 @@ require_relative "../user_agent"
5
5
  require_relative "../classifier"
6
6
  require_relative "../probe_cache"
7
7
  require_relative "../handoff_only"
8
+ require_relative "../offer_sources"
8
9
  require_relative "store"
9
10
  require_relative "product_store"
10
11
  require_relative "known_cache"
@@ -103,7 +104,7 @@ module Portage
103
104
  # Opt-in: a crawl is up to 21 more requests and 20s of pauses, where
104
105
  # a plain add is one probe.
105
106
  def add(url, crawl: false)
106
- origin = origin_of(url)
107
+ origin = OfferSources.origin_of(url)
107
108
  return { added: false, message: "Not a valid http(s) URL: #{url}" } unless origin
108
109
  return store_manual_handoff_only(origin) if handoff_only_origin?(origin)
109
110
 
@@ -356,16 +357,6 @@ module Portage
356
357
  def progress(message)
357
358
  @out&.puts(message)
358
359
  end
359
-
360
- def origin_of(url)
361
- uri = URI.parse(url.to_s)
362
- return nil unless uri.host && uri.scheme.to_s.start_with?("http")
363
-
364
- port = uri.port == uri.default_port ? "" : ":#{uri.port}"
365
- "#{uri.scheme}://#{uri.host}#{port}"
366
- rescue URI::InvalidURIError
367
- nil
368
- end
369
360
  end
370
361
  end
371
362
  end
@@ -5,7 +5,6 @@ require "fileutils"
5
5
  require "timeout"
6
6
  require "portage/ucp/support/connection"
7
7
 
8
- require_relative "../known_stores_url"
9
8
  require_relative "../user_agent"
10
9
 
11
10
  module Portage
@@ -32,6 +31,14 @@ module Portage
32
31
  STORES_PATH = File.join(Dir.home, ".portage", "index", "known-stores.json").freeze
33
32
  PRODUCTS_PATH = File.join(Dir.home, ".portage", "index", "known-products.json").freeze
34
33
 
34
+ # Published on the same jsdelivr `@main` channel AgentProfileUrl
35
+ # uses for the agent profile: free GitHub, no Actions, so a new
36
+ # entry reaches every install on its next refresh as soon as a PR
37
+ # merges, with no gem/brew release to wait on.
38
+ BASE_URL = "https://cdn.jsdelivr.net/gh/tomtom87/Portage@main/portage-cli/known-stores".freeze
39
+ STORES_URL = "#{BASE_URL}/stores.json".freeze
40
+ PRODUCTS_URL = "#{BASE_URL}/products.json".freeze
41
+
35
42
  STALE_AFTER = 7 * 24 * 60 * 60
36
43
  TIMEOUT = 5
37
44
 
@@ -44,7 +51,7 @@ module Portage
44
51
  FORBIDDEN_FIELDS = %w[price amount stock].freeze
45
52
 
46
53
  def initialize(stores_path: STORES_PATH, products_path: PRODUCTS_PATH,
47
- stores_url: KnownStoresUrl::STORES, products_url: KnownStoresUrl::PRODUCTS)
54
+ stores_url: STORES_URL, products_url: PRODUCTS_URL)
48
55
  @stores_path = stores_path
49
56
  @products_path = products_path
50
57
  @stores_url = stores_url
@@ -1,6 +1,7 @@
1
1
  require "uri"
2
2
 
3
3
  require_relative "../../search_backends"
4
+ require_relative "../../offer_sources"
4
5
 
5
6
  module Portage
6
7
  module Cli
@@ -36,21 +37,11 @@ module Portage
36
37
  private
37
38
 
38
39
  def sighting_for(url)
39
- origin = origin_of(url)
40
+ origin = OfferSources.origin_of(url)
40
41
  return nil unless origin
41
42
 
42
43
  { origin: origin, url: nil, title: nil, brand: nil, gtin: nil }
43
44
  end
44
-
45
- def origin_of(url)
46
- uri = URI.parse(url.to_s)
47
- return nil unless uri.host && uri.scheme.to_s.start_with?("http")
48
-
49
- port = uri.port == uri.default_port ? "" : ":#{uri.port}"
50
- "#{uri.scheme}://#{uri.host}#{port}"
51
- rescue URI::InvalidURIError
52
- nil
53
- end
54
45
  end
55
46
  end
56
47
  end
@@ -5,6 +5,7 @@ require "timeout"
5
5
  require "portage/ucp/support/connection"
6
6
 
7
7
  require_relative "../../user_agent"
8
+ require_relative "../../offer_sources"
8
9
 
9
10
  module Portage
10
11
  module Cli
@@ -73,21 +74,11 @@ module Portage
73
74
 
74
75
  def sighting_for(row)
75
76
  url = row.dig("website", "value")
76
- origin = origin_of(url)
77
+ origin = OfferSources.origin_of(url)
77
78
  return nil unless origin
78
79
 
79
80
  { origin: origin, url: url, title: nil, brand: row.dig("itemLabel", "value"), gtin: nil }
80
81
  end
81
-
82
- def origin_of(url)
83
- uri = URI.parse(url.to_s)
84
- return nil unless uri.host && uri.scheme.to_s.start_with?("http")
85
-
86
- port = uri.port == uri.default_port ? "" : ":#{uri.port}"
87
- "#{uri.scheme}://#{uri.host}#{port}"
88
- rescue URI::InvalidURIError
89
- nil
90
- end
91
82
  end
92
83
  end
93
84
  end
@@ -3,7 +3,8 @@ require_relative "version"
3
3
 
4
4
  module Portage
5
5
  module Cli
6
- # Phase 4 of docs/plans/homebrew-distribution.md's `doctor` checks: how
6
+ # Phase 4 of homebrew-distribution.md's `doctor` checks (see
7
+ # https://github.com/tomtom87/Portage/blob/d51200f/docs/plans/homebrew-distribution.md): how
7
8
  # this copy of portage-cli was installed (Homebrew formula or plain
8
9
  # `gem install`), which Ruby it runs on, which first-party adapter gems
9
10
  # it can load, and whether the `portage` a shell would actually run is
@@ -55,21 +55,25 @@ module Portage
55
55
  def call(payload)
56
56
  return nil unless enabled?
57
57
 
58
- response = post(URI(webhook_url), JSON.generate(payload))
59
- return nil if response.is_a?(Net::HTTPSuccess)
60
-
61
- "webhook answered #{response.code}: #{response.body.to_s[0, BODY_EXCERPT]}"
62
- rescue StandardError => e
63
- "webhook POST failed: #{e.message}"
58
+ self.class.post_json(webhook_url, payload, timeout: TIMEOUT, label: "webhook")
64
59
  end
65
60
 
66
- private
67
-
68
- def post(uri, body)
69
- Portage::Ucp::Support::Connection.start(uri, route: :notify, open_timeout: TIMEOUT,
70
- read_timeout: TIMEOUT) do |http|
71
- http.post(uri.request_uri, body, UserAgent.headers.merge("Content-Type" => "application/json"))
61
+ # The one JSON POST both this and HandoffAgents::Webhook make, on the
62
+ # :notify route. `label` starts each failure message.
63
+ #
64
+ # @return [String, nil] the failure message, or nil on any 2xx.
65
+ def self.post_json(url, payload, timeout:, label:)
66
+ uri = URI(url)
67
+ response = Portage::Ucp::Support::Connection.start(uri, route: :notify, open_timeout: timeout,
68
+ read_timeout: timeout) do |http|
69
+ http.post(uri.request_uri, JSON.generate(payload),
70
+ UserAgent.headers.merge("Content-Type" => "application/json"))
72
71
  end
72
+ return nil if response.is_a?(Net::HTTPSuccess)
73
+
74
+ "#{label} answered #{response.code}: #{response.body.to_s[0, BODY_EXCERPT]}"
75
+ rescue StandardError => e
76
+ "#{label} POST failed: #{e.message}"
73
77
  end
74
78
  end
75
79
  end
@@ -63,9 +63,8 @@ module Portage
63
63
  end
64
64
 
65
65
  # Shared by every retailer source below: an offer's `store` is the
66
- # origin of the item URL the API itself returned, same idea as
67
- # ShopifyCatalog#origin_of — kept as one module method instead of
68
- # four copies.
66
+ # origin of the item URL the API itself returned. The index builder
67
+ # and its sources use it too, rather than keeping their own copies.
69
68
  def self.origin_of(url)
70
69
  uri = URI.parse(url.to_s)
71
70
  return nil unless uri.host && uri.scheme.to_s.start_with?("http")
@@ -142,7 +141,7 @@ module Portage
142
141
 
143
142
  def offer(product)
144
143
  variant = Array(product["variants"]).first
145
- origin = variant && origin_of(variant["url"])
144
+ origin = variant && OfferSources.origin_of(variant["url"])
146
145
  return nil unless origin
147
146
 
148
147
  amount, currency = price_of(product)
@@ -152,16 +151,6 @@ module Portage
152
151
  )
153
152
  end
154
153
 
155
- def origin_of(url)
156
- uri = URI.parse(url.to_s)
157
- return nil unless uri.host && uri.scheme.to_s.start_with?("http")
158
-
159
- port = uri.port == uri.default_port ? "" : ":#{uri.port}"
160
- "#{uri.scheme}://#{uri.host}#{port}"
161
- rescue URI::InvalidURIError
162
- nil
163
- end
164
-
165
154
  # Same wire shape as Find#price_of — a `price_range.min` Money
166
155
  # object, wire-hash-keyed same as every result that's passed through
167
156
  # Dispatcher#wrap's `to_wire_h`.
@@ -2,19 +2,13 @@ require "json"
2
2
  require "fileutils"
3
3
  require "securerandom"
4
4
  require "uri"
5
- require "net/http"
6
5
  require "portage/ucp"
7
6
  require "portage/ucp/client"
8
- require "portage/ucp/support/connection"
9
7
 
10
- # Buy::PermissiveAuthenticator (adapter-loopback auth) is used by
11
- # #adapter_session below — not require_relative'd here to avoid a load
12
- # cycle (buy.rb will require this file too, for PaymentMethods.default);
13
- # cli.rb requires "cli/buy" before "cli/payment_methods", so it's already
14
- # loaded by the time #enroll actually runs.
15
8
  require_relative "payment_methods/keychain_backend"
16
9
  require_relative "payment_methods/secret_service_backend"
17
10
  require_relative "payment_methods/env_backend"
11
+ require_relative "adapter_session"
18
12
  require_relative "user_agent"
19
13
  require_relative "handoff_only"
20
14
 
@@ -220,7 +214,8 @@ module Portage
220
214
  # Same native-manifest-first, own-store-adapter-fallback discovery as
221
215
  # Buy#call — duplicated rather than extracted since Buy's version is
222
216
  # entangled with cart/checkout-specific branching this only needs the
223
- # session object from.
217
+ # session object from. The adapter fallback itself is AdapterSession,
218
+ # shared with HandoffReconciler.
224
219
  def discover_session(url)
225
220
  native = Portage::Ucp::Client.discover(url, headers: UserAgent.headers)
226
221
  native if native
@@ -228,29 +223,11 @@ module Portage
228
223
  adapter_session(url)
229
224
  end
230
225
 
226
+ # The homepage fetch stays on the :payment route (forced direct unless
227
+ # a proxy is named for it), redirects included.
231
228
  def adapter_session(url)
232
- uri = URI.parse(url.to_s =~ %r{\Ahttps?://}i ? url.to_s : "https://#{url}")
233
- body, headers = fetch_homepage(uri)
234
- platform = body && Portage::Ucp::Resolver.detect_platform(body, headers)
235
- return nil unless platform
236
-
237
- env = Portage::Ucp::Resolver.env_for(platform)
238
- return nil if Portage::Ucp::Resolver.missing_env(platform, env).any?
239
-
240
- adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
241
- Portage::Ucp::Client.for_adapter(adapter, authenticator: Buy::PermissiveAuthenticator.new)
242
- rescue StandardError
243
- nil
244
- end
245
-
246
- def fetch_homepage(uri)
247
- response = Portage::Ucp::Support::Connection.start(uri, route: :payment, open_timeout: 5,
248
- read_timeout: 5) do |http|
249
- http.get(uri.request_uri, UserAgent.headers)
250
- end
251
- response.is_a?(Net::HTTPSuccess) ? [response.body, response.to_hash] : [nil, {}]
252
- rescue StandardError
253
- [nil, {}]
229
+ uri = normalized_uri(url)
230
+ uri && AdapterSession.call(uri, route: :payment)
254
231
  end
255
232
 
256
233
  def payment_enrollment_advertised?(session)
@@ -1,6 +1,7 @@
1
1
  require_relative "notifier"
2
2
  require_relative "macos_notifier"
3
3
  require_relative "reconcile_notify"
4
+ require_relative "money"
4
5
 
5
6
  module Portage
6
7
  module Cli
@@ -55,11 +56,7 @@ module Portage
55
56
  parts.join(" — ")
56
57
  end
57
58
 
58
- def format_amount(amount, currency)
59
- return nil unless amount
60
-
61
- "#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
62
- end
59
+ def format_amount(amount, currency) = amount && Money.format_amount(amount, currency)
63
60
  end
64
61
  end
65
62
  end
@@ -0,0 +1,40 @@
1
+ require_relative "../../dot_env"
2
+
3
+ module Portage
4
+ module Cli
5
+ class SetupWizard
6
+ module Steps
7
+ # Shared body of the wizard steps that only prompt for a few env vars
8
+ # and write the answers to ~/.portage/.env (Shipping, SearchKeys,
9
+ # RetailerKeys). A subclass defines #title, #default_yes?, FIELDS
10
+ # ({ "ENV_VAR" => "label" }), #intro and #unchanged_message, and
11
+ # overrides #secret? for fields that must never echo back.
12
+ class EnvKeysStep
13
+ def initialize(prompt:) = @prompt = prompt
14
+
15
+ def call
16
+ @prompt.say(intro)
17
+ assignments = collect
18
+ return @prompt.say(unchanged_message) if assignments.empty?
19
+
20
+ path = DotEnv.update!(assignments)
21
+ @prompt.say("Saved #{assignments.keys.join(', ')} to #{path} (chmod 600).")
22
+ end
23
+
24
+ private
25
+
26
+ def fields = self.class::FIELDS
27
+ def secret?(_var) = false
28
+
29
+ def collect
30
+ fields.each_with_object({}) do |(var, label), assignments|
31
+ hint = ENV.fetch(var, nil).to_s.empty? ? "not set" : "already set"
32
+ answer = secret?(var) ? @prompt.ask_secret(label, hint: hint) : @prompt.ask(label, hint: hint)
33
+ assignments[var] = answer if answer
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end