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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +98 -1
- data/README.md +88 -18
- data/lib/portage/cli/adapter_session.rb +34 -0
- data/lib/portage/cli/browser_import/importer.rb +1 -1
- data/lib/portage/cli/browser_import/plist_xml.rb +5 -12
- data/lib/portage/cli/browser_import/sqlite.rb +19 -25
- data/lib/portage/cli/browser_profile/cdp.rb +0 -9
- data/lib/portage/cli/buy.rb +217 -39
- data/lib/portage/cli/compare.rb +4 -3
- data/lib/portage/cli/confidence_check.rb +27 -7
- data/lib/portage/cli/confidence_state.rb +109 -0
- data/lib/portage/cli/find.rb +66 -9
- data/lib/portage/cli/generate/agent_profile.rb +1 -1
- data/lib/portage/cli/handoff_agents.rb +7 -21
- data/lib/portage/cli/handoff_reconciler.rb +2 -22
- data/lib/portage/cli/homepage_fetch.rb +5 -3
- data/lib/portage/cli/index/builder.rb +2 -11
- data/lib/portage/cli/index/known_cache.rb +9 -2
- data/lib/portage/cli/index/sources/stores_file.rb +2 -11
- data/lib/portage/cli/index/sources/wikidata.rb +2 -11
- data/lib/portage/cli/install_doctor.rb +2 -1
- data/lib/portage/cli/notifier.rb +16 -12
- data/lib/portage/cli/offer_sources.rb +3 -14
- data/lib/portage/cli/payment_methods.rb +7 -30
- data/lib/portage/cli/reconcile_notifier.rb +2 -5
- data/lib/portage/cli/setup_wizard/steps/env_keys_step.rb +40 -0
- data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +11 -23
- data/lib/portage/cli/setup_wizard/steps/search_keys.rb +10 -22
- data/lib/portage/cli/setup_wizard/steps/shipping.rb +5 -19
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +25 -173
- metadata +5 -3
- data/lib/portage/cli/known_stores_url.rb +0 -15
data/lib/portage/cli/find.rb
CHANGED
|
@@ -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),
|
|
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::
|
|
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,
|
|
154
|
-
#
|
|
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
|
-
|
|
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 "
|
|
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) ||
|
|
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:
|
|
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:
|
|
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
|
|
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
|
data/lib/portage/cli/notifier.rb
CHANGED
|
@@ -55,21 +55,25 @@ module Portage
|
|
|
55
55
|
def call(payload)
|
|
56
56
|
return nil unless enabled?
|
|
57
57
|
|
|
58
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
67
|
-
#
|
|
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 =
|
|
233
|
-
|
|
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
|